Skip to main content

bynk_check/
packages.rs

1//! Packaging slice 2a (#2223): loading a project's path dependencies into
2//! one compile.
3//!
4//! [`apply_graph`] runs once, right after parsing, on both the build's and the
5//! editor's pipeline. It resolves the project's package graph
6//! ([`bynk_project::resolve_graph`]), then rewrites the parsed files so every
7//! later phase sees one flat set of units, as it always has:
8//!
9//! - each dependency file is tagged with its package, and its unit gets the
10//!   canonical name source can't spell ([`bynk_project::PackageId::canonical`],
11//!   decision C) — `currency` in `acme/money` becomes `$acme_money.currency`;
12//! - every `uses`/`consumes` target is resolved against its own file's
13//!   package: `money.currency`, where `money` is one of that package's
14//!   aliases, becomes the dependency's canonical name, and a dependency's
15//!   reference to its own unit becomes its own canonical name;
16//! - a whole-unit `consumes` of a dependency unit keeps the spelling source
17//!   wrote as its alias, so `money.fx.Rates` still names the capability;
18//! - only exported units cross a package boundary (decision D), only the
19//!   units a consumer actually reaches are kept (P-5), and a dependency's
20//!   contexts and suites are never compiled (ADR 0542). A reached dependency
21//!   adapter's npm `requires` fold into the build with the project's own
22//!   (#2255).
23//!
24//! The root project's own units and the first-party units keep their names,
25//! so a project without dependencies compiles byte-identically.
26
27use std::collections::{BTreeMap, BTreeSet, HashMap};
28use std::path::{Path, PathBuf};
29
30use bynk_project::{PackageGraph, PackageId, ParsedFile, UnitKind};
31use bynk_syntax::CompileError;
32use bynk_syntax::ast::{CommonsItem, Ident, QualifiedName, SourceUnit};
33use bynk_syntax::span::Span;
34
35use crate::project_model::ErrorSink;
36
37/// A consumer reaches a unit its dependency doesn't export.
38pub const UNIT_NOT_EXPORTED: &str = "bynk.package.unit_not_exported";
39/// A consumer reaches a unit its dependency doesn't declare.
40pub const UNKNOWN_UNIT: &str = "bynk.package.unknown_unit";
41/// An alias is also the first segment of one of the project's own units.
42pub const ALIAS_SHADOWS_UNIT: &str = "bynk.package.alias_shadows_unit";
43/// A dependency declares an `asset` or an `embed`, which needs a per-package namespace not
44/// yet designed.
45pub const UNSUPPORTED_IN_DEPENDENCY: &str = "bynk.package.unsupported_in_dependency";
46/// #2240: a non-suite unit reaches a `[dev-dependencies]` alias.
47pub const DEV_DEPENDENCY_IN_SOURCE: &str = "bynk.package.dev_dependency_in_source";
48/// #2296: a reached adapter of a fetched package requires an npm package the
49/// project hasn't approved in `bynk.lock`.
50pub const UNAPPROVED_REQUIREMENT: &str = "bynk.requires.unapproved";
51
52/// Resolve the package graph of the project at `project_root` and fold the
53/// dependencies' files in `parsed` into the compile. A project with no
54/// dependencies is untouched.
55///
56/// `tests` says whether this build checks suites: when it doesn't (a
57/// deployable build), suites reach nothing, so a unit only a suite uses —
58/// every `[dev-dependencies]` unit — is never compiled (#2240).
59pub fn apply_graph(
60    project_root: Option<&Path>,
61    overlay: &HashMap<PathBuf, String>,
62    parsed: &mut Vec<ParsedFile>,
63    tests: bool,
64    errors: &mut ErrorSink,
65) {
66    let Some(project_root) = project_root else {
67        return;
68    };
69    let (graph, problems) = bynk_project::resolve_graph(project_root, overlay);
70    let manifest = Path::new(crate::stability::MANIFEST);
71    for p in problems {
72        let mut err = CompileError::new(p.code, Span::default(), p.message);
73        if let Some(note) = p.note {
74            err = err.with_note(note);
75        }
76        errors.push_for(Some(manifest), err);
77    }
78    let Some(graph) = graph else {
79        return;
80    };
81
82    // Tag each dependency file with its package; drop what a dependency
83    // never contributes (contexts, suites).
84    let mut package_of: Vec<Option<usize>> = Vec::with_capacity(parsed.len());
85    for pf in parsed.iter_mut() {
86        let idx = (!pf.is_synthetic())
87            .then(|| pf.abs_path())
88            .flatten()
89            .and_then(|abs| graph.package_of(&abs));
90        if let Some(i) = idx {
91            pf.assign_package(graph.packages[i].id.clone());
92        }
93        package_of.push(idx);
94    }
95    let mut keep = vec![true; parsed.len()];
96    for (k, pf) in parsed.iter().enumerate() {
97        if package_of[k].is_some() && !matches!(pf.kind(), UnitKind::Commons | UnitKind::Adapter) {
98            keep[k] = false;
99        }
100    }
101
102    // The project's own top-level names, for the shadowing check.
103    let own_first: BTreeSet<String> = parsed
104        .iter()
105        .enumerate()
106        // A suite's name is its target, not a unit it declares.
107        .filter(|(k, pf)| {
108            package_of[*k].is_none()
109                && !pf.is_synthetic()
110                && !matches!(pf.kind(), UnitKind::Test | UnitKind::Integration)
111        })
112        .filter_map(|(_, pf)| pf.unit().name().parts.first().map(|i| i.name.clone()))
113        .collect();
114    for alias in graph.root_aliases.keys() {
115        if own_first.contains(alias) {
116            errors.push_for(
117                Some(manifest),
118                CompileError::new(
119                    ALIAS_SHADOWS_UNIT,
120                    Span::default(),
121                    format!(
122                        "the dependency alias `{alias}` is also the first segment of one of this project's own units, so `{alias}.…` would be ambiguous"
123                    ),
124                )
125                .with_note("rename the alias in `[dependencies]` (the key is the alias), or the unit"),
126            );
127        }
128    }
129
130    // Each dependency's declared units, by the name it declares them under.
131    let mut declared: Vec<BTreeMap<String, UnitKind>> = vec![BTreeMap::new(); graph.packages.len()];
132    for (k, pf) in parsed.iter().enumerate() {
133        if let Some(i) = package_of[k]
134            && keep[k]
135        {
136            declared[i].insert(pf.unit().name().joined(), pf.kind());
137        }
138    }
139
140    // Rename each dependency unit to its canonical name.
141    for (k, pf) in parsed.iter_mut().enumerate() {
142        if let Some(i) = package_of[k] {
143            let enc = graph.packages[i].id.encoded();
144            prefix_name(unit_name_mut(pf.unit_mut()), &enc);
145        }
146    }
147
148    // Resolve every target against its own file's package.
149    for (k, pf) in parsed.iter_mut().enumerate() {
150        if !keep[k] || pf.is_synthetic() {
151            continue;
152        }
153        let file = pf.identity_path();
154        let from = package_of[k];
155        let in_suite = matches!(pf.kind(), UnitKind::Test | UnitKind::Integration);
156        let mut found: Vec<CompileError> = Vec::new();
157        let resolve = |target: &mut QualifiedName, found: &mut Vec<CompileError>| {
158            resolve_target(&graph, &declared, from, in_suite, target, found)
159        };
160        let uses_pass = |uses: &mut Vec<bynk_syntax::ast::UsesDecl>,
161                         found: &mut Vec<CompileError>| {
162            uses.retain_mut(|u| resolve(&mut u.target, found) != Resolved::Dropped);
163        };
164        let consumes_pass = |consumes: &mut Vec<bynk_syntax::ast::ConsumesDecl>,
165                             found: &mut Vec<CompileError>| {
166            consumes.retain_mut(|cd| {
167                let written = cd.target.clone();
168                match resolve(&mut cd.target, found) {
169                    Resolved::Dropped => false,
170                    Resolved::Dependency => {
171                        // A whole-unit `consumes` keeps the spelling
172                        // source wrote as its alias, so `money.fx.Rates`
173                        // still names the capability.
174                        if cd.alias.is_none() && cd.selected.is_none() {
175                            cd.alias = Some(Ident {
176                                name: written.joined(),
177                                span: written.span,
178                            });
179                        }
180                        true
181                    }
182                    Resolved::Unchanged => true,
183                }
184            });
185        };
186        match pf.unit_mut() {
187            SourceUnit::Commons(c) => uses_pass(&mut c.uses, &mut found),
188            SourceUnit::Context(c) => {
189                uses_pass(&mut c.uses, &mut found);
190                consumes_pass(&mut c.consumes, &mut found);
191            }
192            SourceUnit::Adapter(a) => {
193                uses_pass(&mut a.uses, &mut found);
194                consumes_pass(&mut a.consumes, &mut found);
195            }
196            SourceUnit::Suite(s) => {
197                resolve(&mut s.target, &mut found);
198                uses_pass(&mut s.uses, &mut found);
199            }
200        }
201        errors.extend_for(Some(&file), found);
202    }
203
204    // What a dependency may not yet carry.
205    for (k, pf) in parsed.iter().enumerate() {
206        let Some(i) = package_of[k] else { continue };
207        if !keep[k] {
208            continue;
209        }
210        let pkg = graph.packages[i].id.name();
211        // #2255: a dependency adapter's npm `requires` fold into the build
212        // with the project's own (`phase_group`), so only an `asset` or an
213        // `embed` is refused here.
214        for item in pf.items() {
215            if let CommonsItem::Asset(a) = item {
216                errors.push_for(
217                    Some(&pf.identity_path()),
218                    CompileError::new(
219                        UNSUPPORTED_IN_DEPENDENCY,
220                        a.span,
221                        format!(
222                            "dependency `{pkg}` declares an `asset`, and a dependency's assets aren't supported yet: it would resolve against the consuming project's `assets/`"
223                        ),
224                    ),
225                );
226            }
227            // #2387, slice 1: the same for an embed and `embeds/`.
228            if let CommonsItem::Embed(e) = item {
229                errors.push_for(
230                    Some(&pf.identity_path()),
231                    CompileError::new(
232                        UNSUPPORTED_IN_DEPENDENCY,
233                        e.span,
234                        format!(
235                            "dependency `{pkg}` declares an `embed`, and a dependency's embeds aren't supported yet: it would resolve against the consuming project's `embeds/`"
236                        ),
237                    ),
238                );
239            }
240        }
241    }
242
243    // Keep only the dependency units a consumer reaches (P-5).
244    let reached = reached_units(parsed, &keep, &package_of, tests);
245    for (k, pf) in parsed.iter().enumerate() {
246        if package_of[k].is_some() && !reached.contains(&pf.unit().name().joined()) {
247            keep[k] = false;
248        }
249    }
250    // #2296: a reached adapter of someone else's package brings an npm
251    // package into the build only once the project approved it.
252    for (k, pf) in parsed.iter().enumerate() {
253        let Some(i) = package_of[k] else { continue };
254        if !keep[k] || !graph.packages[i].fetched {
255            continue;
256        }
257        let SourceUnit::Adapter(a) = pf.unit() else {
258            continue;
259        };
260        let Some(binding) = &a.binding else { continue };
261        let pkg = graph.packages[i].id.name();
262        let adapter = a
263            .name
264            .parts
265            .iter()
266            .skip(1)
267            .map(|p| p.name.as_str())
268            .collect::<Vec<_>>()
269            .join(".");
270        for dep in &binding.requires {
271            if graph
272                .npm_approved
273                .contains(&(dep.package.clone(), dep.range.clone()))
274            {
275                continue;
276            }
277            let chain = std::iter::once("this project".to_string())
278                .chain(graph.chain_to(i))
279                .collect::<Vec<_>>()
280                .join(" → ");
281            errors.push_for(
282                Some(&pf.identity_path()),
283                CompileError::new(
284                    UNAPPROVED_REQUIREMENT,
285                    dep.span,
286                    format!(
287                        "adapter `{pkg}:{adapter}` requires the npm package `{}` at `{}`, which this project hasn't approved",
288                        dep.package, dep.range
289                    ),
290                )
291                .with_note(format!("required through {chain}"))
292                .with_note(format!(
293                    "review it, then run `bynk approve {}`",
294                    dep.package
295                )),
296            );
297        }
298    }
299
300    let mut k = 0;
301    parsed.retain(|_| {
302        let kept = keep[k];
303        k += 1;
304        kept
305    });
306}
307
308/// What resolving one target did.
309#[derive(Clone, Copy, PartialEq, Eq)]
310enum Resolved {
311    /// Not a dependency reference; left as written.
312    Unchanged,
313    /// Now names a dependency unit by its canonical name.
314    Dependency,
315    /// Names nothing in the dependency; reported here, so the clause is
316    /// dropped rather than reported again as an unknown unit.
317    Dropped,
318}
319
320/// Resolve one `uses`/`consumes` target written in a file of package `from`
321/// (`None`: the project itself), rewriting `target` in place. A unit the
322/// dependency declares but doesn't export is reported and still resolved, so
323/// checking carries on without a second error for the same clause.
324fn resolve_target(
325    graph: &PackageGraph,
326    declared: &[BTreeMap<String, UnitKind>],
327    from: Option<usize>,
328    in_suite: bool,
329    target: &mut QualifiedName,
330    found: &mut Vec<CompileError>,
331) -> Resolved {
332    let Some(first) = target.parts.first().map(|p| p.name.clone()) else {
333        return Resolved::Unchanged;
334    };
335    if first == "bynk" {
336        return Resolved::Unchanged;
337    }
338    if from.is_none() && target.parts.len() >= 2 && graph.unresolved_aliases.contains(&first) {
339        // Its package wasn't resolved, and that is already reported.
340        return Resolved::Dropped;
341    }
342    let aliases = match from {
343        None => &graph.root_aliases,
344        Some(i) => &graph.packages[i].aliases,
345    };
346    if target.parts.len() >= 2
347        && let Some(&dep) = aliases.get(&first)
348    {
349        let pkg = &graph.packages[dep];
350        if from.is_none() && !in_suite && graph.dev_aliases.contains(&first) {
351            found.push(
352                CompileError::new(
353                    DEV_DEPENDENCY_IN_SOURCE,
354                    target.span,
355                    format!(
356                        "`{}` reaches dependency `{}` through `{first}`, which is a `[dev-dependencies]` alias: only a suite may use it",
357                        target.joined(),
358                        pkg.id.name()
359                    ),
360                )
361                .with_note("move it to `[dependencies]` to use it here"),
362            );
363            return Resolved::Dropped;
364        }
365        let unit = target.parts[1..]
366            .iter()
367            .map(|p| p.name.as_str())
368            .collect::<Vec<_>>()
369            .join(".");
370        match declared[dep].get(&unit) {
371            None => {
372                found.push(
373                    CompileError::new(
374                        UNKNOWN_UNIT,
375                        target.span,
376                        format!(
377                            "`{}` names no commons or adapter in dependency `{}` (alias `{first}`)",
378                            target.joined(),
379                            pkg.id.name()
380                        ),
381                    )
382                    .with_note(exports_note(pkg, &declared[dep])),
383                );
384                return Resolved::Dropped;
385            }
386            Some(_) => {
387                let exported = pkg.exports.as_ref().is_none_or(|list| list.contains(&unit));
388                if !exported {
389                    found.push(
390                        CompileError::new(
391                            UNIT_NOT_EXPORTED,
392                            target.span,
393                            format!("`{unit}` isn't exported by dependency `{}`", pkg.id.name()),
394                        )
395                        .with_note(exports_note(pkg, &declared[dep])),
396                    );
397                }
398            }
399        }
400        prefix_name_from(target, &pkg.id, 1);
401        return Resolved::Dependency;
402    }
403    if let Some(i) = from {
404        // A dependency's reference to one of its own units.
405        let joined = target.joined();
406        if declared[i].contains_key(&joined) {
407            prefix_name(target, &graph.packages[i].id.encoded());
408            return Resolved::Dependency;
409        }
410    }
411    Resolved::Unchanged
412}
413
414/// The note listing what a dependency shares.
415fn exports_note(pkg: &bynk_project::Package, declared: &BTreeMap<String, UnitKind>) -> String {
416    let shared: Vec<String> = match &pkg.exports {
417        Some(list) => list.clone(),
418        None => declared
419            .iter()
420            .filter(|(_, k)| matches!(k, UnitKind::Commons | UnitKind::Adapter))
421            .map(|(n, _)| n.clone())
422            .collect(),
423    };
424    if shared.is_empty() {
425        format!("`{}` shares no units", pkg.id.name())
426    } else {
427        format!(
428            "`{}` shares {}",
429            pkg.id.name(),
430            shared
431                .iter()
432                .map(|n| format!("`{n}`"))
433                .collect::<Vec<_>>()
434                .join(", ")
435        )
436    }
437}
438
439/// The canonical names of every unit reachable from the project's own files
440/// (and the first-party units), through `uses` and `consumes`.
441fn reached_units(
442    parsed: &[ParsedFile],
443    keep: &[bool],
444    package_of: &[Option<usize>],
445    tests: bool,
446) -> BTreeSet<String> {
447    let mut edges: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
448    let mut roots: BTreeSet<String> = BTreeSet::new();
449    for (k, pf) in parsed.iter().enumerate() {
450        if !keep[k] {
451            continue;
452        }
453        let name = pf.unit().name().joined();
454        let targets = edges.entry(name.clone()).or_default();
455        for u in pf.uses() {
456            targets.insert(u.target.joined());
457        }
458        for c in pf.consumes() {
459            targets.insert(c.target.joined());
460        }
461        let suite = matches!(pf.kind(), UnitKind::Test | UnitKind::Integration);
462        if package_of[k].is_none() && (tests || !suite) {
463            roots.insert(name);
464        }
465    }
466    let mut seen: BTreeSet<String> = BTreeSet::new();
467    let mut stack: Vec<String> = roots.into_iter().collect();
468    while let Some(n) = stack.pop() {
469        if !seen.insert(n.clone()) {
470            continue;
471        }
472        if let Some(next) = edges.get(&n) {
473            stack.extend(next.iter().cloned());
474        }
475    }
476    seen
477}
478
479/// A unit declaration's name, mutably.
480fn unit_name_mut(unit: &mut SourceUnit) -> &mut QualifiedName {
481    match unit {
482        SourceUnit::Commons(c) => &mut c.name,
483        SourceUnit::Context(c) => &mut c.name,
484        SourceUnit::Suite(s) => &mut s.target,
485        SourceUnit::Adapter(a) => &mut a.name,
486    }
487}
488
489/// Prepend the encoded package segment to `name`.
490fn prefix_name(name: &mut QualifiedName, encoded: &str) {
491    let span = name.parts.first().map_or(name.span, |p| p.span);
492    name.parts.insert(
493        0,
494        Ident {
495            name: encoded.to_owned(),
496            span,
497        },
498    );
499}
500
501/// Replace `name`'s first `drop` segments (the alias) with `id`'s encoded
502/// segment.
503fn prefix_name_from(name: &mut QualifiedName, id: &PackageId, drop: usize) {
504    let span = name.parts.first().map_or(name.span, |p| p.span);
505    name.parts.drain(..drop);
506    name.parts.insert(
507        0,
508        Ident {
509            name: id.encoded(),
510            span,
511        },
512    );
513}