Skip to main content

aube_resolver/
peer_context.rs

1//! Peer-dependency post-processing over an already-resolved graph.
2//!
3//! Two user-visible passes live here:
4//!
5//! * [`hoist_auto_installed_peers`] — promotes peers declared by direct
6//!   dependencies up to importer direct deps, matching pnpm's
7//!   `auto-install-peers=true` behavior. Idempotent on graphs that already
8//!   ship with those hoists (npm v7+ output, lockfile-driven installs).
9//! * [`apply_peer_contexts`] — computes pnpm-style `(peer@ver)` suffixes
10//!   on contextualized `dep_path`s. Drives the sibling-symlink wiring in
11//!   `aube-linker` so each subtree that pins different peer versions gets
12//!   its own virtual-store entry.
13//!
14//! [`detect_unmet_peers`] reports what the two passes above couldn't wire
15//! up, so the CLI can surface warnings.
16//!
17//! Call order from `Resolver::resolve`: `hoist_auto_installed_peers`
18//! (fresh resolves only) → `apply_peer_contexts` → `detect_unmet_peers`.
19
20use crate::version_satisfies;
21use aube_lockfile::{DepType, DirectDep, LockedPackage, LockfileGraph};
22use rustc_hash::{FxHashMap, FxHashSet};
23use std::collections::{BTreeMap, BTreeSet};
24
25/// A peer dependency whose declared range doesn't match the version the
26/// tree actually ends up providing. Emitted as a warning by `aube install`.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct UnmetPeer {
29    /// dep_path of the package that declared the peer.
30    pub from_dep_path: String,
31    /// Human-friendly package name (pre-context) for display.
32    pub from_name: String,
33    /// Name of the peer being declared (e.g. `"react"`).
34    pub peer_name: String,
35    /// The declared peer range from the package's packument
36    /// (e.g. `"^16.8.0 || ^17.0.0 || ^18.0.0"`).
37    pub declared: String,
38    /// What the tree actually provides, if anything. `None` means the
39    /// peer is completely missing — rare in practice because the BFS
40    /// auto-install path usually drags *some* version in, but it can
41    /// happen for corner cases.
42    pub found: Option<String>,
43}
44
45/// Scan the resolved graph and return every declared required peer whose
46/// resolved version doesn't satisfy its declared range. Optional peers
47/// (`peerDependenciesMeta.optional = true`) are skipped — pnpm treats
48/// those as "warn suppressed" with `auto-install-peers=true`. The result
49/// is purely informational; aube never fails an install on unmet peers,
50/// matching pnpm.
51///
52/// The "found" version for each package comes from its own
53/// `dependencies` map — the peer-context pass writes the resolved peer
54/// tail there, so we don't have to re-walk ancestors. Any peer suffix on
55/// the stored tail is stripped before the semver check so `18.2.0(foo@1)`
56/// is treated as `18.2.0`.
57pub fn detect_unmet_peers(graph: &LockfileGraph) -> Vec<UnmetPeer> {
58    let mut unmet = Vec::new();
59    for pkg in graph.packages.values() {
60        for (peer_name, declared_range) in &pkg.peer_dependencies {
61            let optional = pkg
62                .peer_dependencies_meta
63                .get(peer_name)
64                .map(|m| m.optional)
65                .unwrap_or(false);
66            if optional {
67                continue;
68            }
69
70            let found_tail = pkg.dependencies.get(peer_name);
71            let found_version = found_tail.map(|t| canonical_tail(t).to_string());
72
73            let satisfied = match &found_version {
74                Some(v) => version_satisfies(v, declared_range),
75                None => false,
76            };
77            if satisfied {
78                continue;
79            }
80
81            unmet.push(UnmetPeer {
82                from_dep_path: pkg.dep_path.clone(),
83                from_name: pkg.name.clone(),
84                peer_name: peer_name.clone(),
85                declared: declared_range.clone(),
86                found: found_version,
87            });
88        }
89    }
90    // Stable order for deterministic test output and readable warnings.
91    unmet.sort_by(|a, b| {
92        (a.from_dep_path.as_str(), a.peer_name.as_str())
93            .cmp(&(b.from_dep_path.as_str(), b.peer_name.as_str()))
94    });
95    unmet
96}
97
98/// Promote direct dependencies' unmet peers to importer direct deps.
99///
100/// Walks each importer's direct dependencies and hoists any peer they
101/// declare that isn't already a direct dep of the importer up to the
102/// importer's `dependencies` list — what pnpm's
103/// `auto-install-peers=true` produces in its v9 lockfile. Peers declared by
104/// transitive dependencies stay in the resolved graph for peer-context
105/// sibling wiring, but they are not surfaced as top-level
106/// `node_modules/<peer>` entries.
107///
108/// Public so lockfile-driven installs that need to re-derive peer
109/// wiring (npm/yarn/bun formats, which don't record peer contexts)
110/// can run this before [`apply_peer_contexts`] to match fresh-resolve
111/// behavior. Idempotent in the npm case: npm v7+ already hoists
112/// auto-installed peers into root's `dependencies`, so they arrive
113/// pre-`satisfied` and no additions are emitted.
114///
115/// Algorithm:
116///   1. For each importer, collect the set of names already in its
117///      direct deps. Those are "satisfied" and need no hoist.
118///   2. Visit only those direct dependency packages and examine their
119///      `peer_dependencies` declarations. For each declared peer not
120///      already satisfied by the importer, find a resolved version somewhere
121///      in the graph and synthesize a `DirectDep` entry. Mark it as
122///      satisfied so a second direct dep doesn't add a duplicate.
123///   3. Stable: we walk in-order and take the first declared peer range
124///      encountered per name as the specifier. Conflicting ranges across
125///      the tree are not reconciled — first one wins. This matches pnpm
126///      for the simple case; the complex case is deferred.
127///
128/// Leaves everything else about the graph untouched — no packages are
129/// added or removed, only importer entries grow.
130pub fn hoist_auto_installed_peers(mut graph: LockfileGraph) -> LockfileGraph {
131    let importer_paths: Vec<String> = graph.importers.keys().cloned().collect();
132    for importer_path in importer_paths {
133        let Some(direct_deps) = graph.importers.get(&importer_path) else {
134            continue;
135        };
136        let mut satisfied: FxHashSet<String> = direct_deps.iter().map(|d| d.name.clone()).collect();
137
138        // Additions are gathered into a separate vec so we don't mutate
139        // the importer's direct-dep list while still borrowing from it.
140        let mut additions: Vec<DirectDep> = Vec::new();
141
142        for dep_path in direct_deps.iter().map(|d| &d.dep_path) {
143            let Some(pkg) = graph.packages.get(dep_path) else {
144                continue;
145            };
146
147            // Collect unmet peer declarations from this package.
148            for (peer_name, peer_range) in &pkg.peer_dependencies {
149                if satisfied.contains(peer_name) {
150                    continue;
151                }
152                // Find any resolved version in the graph for this peer.
153                // Prefer the one the package already wired via its own
154                // dependencies map (the BFS auto-install result), and
155                // fall back to scanning `graph.packages` for a name
156                // match. If nothing matches, we quietly drop the peer —
157                // that's the only path where aube stays stricter than
158                // pnpm today; a future PR will emit an unmet warning.
159                //
160                // Fallback takes the semver-max version rather than
161                // whatever `BTreeMap` iteration order surfaces first —
162                // otherwise two resolved `react` entries like `18.0.0`
163                // and `18.3.1` would pick the lexicographically-earlier
164                // (older) one.
165                let resolved_version = pkg.dependencies.get(peer_name).cloned().or_else(|| {
166                    // Filter to parseable semver versions *before* the
167                    // max_by — returning `Equal` on parse failure makes
168                    // the comparator non-transitive, so an unparseable
169                    // entry sitting between two valid ones would cause
170                    // `max_by` to pick an iteration-order-dependent
171                    // result instead of the true maximum.
172                    graph
173                        .packages
174                        .values()
175                        .filter(|p| p.name == *peer_name)
176                        .filter_map(|p| {
177                            node_semver::Version::parse(&p.version)
178                                .ok()
179                                .map(|v| (v, p.version.clone()))
180                        })
181                        .max_by(|a, b| a.0.cmp(&b.0))
182                        .map(|(_, s)| s)
183                });
184                let Some(version) = resolved_version else {
185                    continue;
186                };
187                let canonical_version = canonical_tail(&version).to_string();
188                let synth_dep_path = format!("{peer_name}@{canonical_version}");
189                if !graph.packages.contains_key(&synth_dep_path) {
190                    // The peer version the package wired didn't match an
191                    // actual package entry — bail out for this peer
192                    // rather than writing a dangling DirectDep.
193                    continue;
194                }
195                satisfied.insert(peer_name.clone());
196                additions.push(DirectDep {
197                    name: peer_name.clone(),
198                    dep_path: synth_dep_path,
199                    // Peers auto-hoisted to the root are in the prod
200                    // graph by convention — matches what pnpm writes.
201                    dep_type: DepType::Production,
202                    specifier: Some(peer_range.clone()),
203                });
204            }
205        }
206
207        if !additions.is_empty() {
208            tracing::debug!(
209                "hoisted {} auto-installed peer(s) into importer {}",
210                additions.len(),
211                importer_path
212            );
213            if let Some(deps) = graph.importers.get_mut(&importer_path) {
214                deps.extend(additions);
215                deps.sort_by(|a, b| a.name.cmp(&b.name));
216            }
217        }
218    }
219    graph
220}
221
222/// Walk the resolved graph top-down from each importer and compute a
223/// peer-dependency context for every package, producing a new graph whose
224/// dep_paths carry pnpm-style `(peer@ver)` suffixes.
225///
226/// The goal is parity with pnpm's v9 lockfile output: the same
227/// `name@version` can appear multiple times — once per distinct set of peer
228/// resolutions — so different subtrees that pin incompatible peers get
229/// isolated virtual-store entries and truly different sibling-symlink
230/// neighborhoods.
231///
232/// Algorithm per visited package P, reached at some point in a DFS from an
233/// importer with `ancestor_scope: name -> dep_path_tail`:
234///
235///  1. For each peer name declared by P, look it up in `ancestor_scope`
236///     (nearest-ancestor-wins, since the scope is rebuilt per recursion).
237///     If missing, fall back to P's own entry in `dependencies` — the BFS
238///     enqueue above auto-installed it as a transitive, which matches
239///     pnpm's `auto-install-peers=true` default.
240///  2. Sort the (peer_name, resolution) pairs and serialize as
241///     `(n1@v1)(n2@v2)…` for the suffix.
242///  3. Produce a contextualized dep_path `name@version{suffix}`. If that
243///     key is already in `out_packages` (or currently on the DFS stack via
244///     `visiting`), short-circuit — we've already emitted this variant.
245///  4. Build a new scope for P's children by merging the ancestor scope
246///     with P's own `dependencies` (rewritten to point at contextualized
247///     children) and the resolved peer map. Recurse.
248///  5. Emit the contextualized LockedPackage.
249///
250/// Cycles: protected by `visiting` — if a package is re-entered via a
251/// dependency cycle, we return the already-computed dep_path without
252/// recursing again. The peer context is fixed at first visit; any cycle
253/// traversal uses whatever context was live at that first visit.
254///
255/// Nested peer suffixes: pnpm writes `(react-dom@18.2.0(react@18.2.0))`
256/// when a declared peer has its own resolved peers. A single top-down
257/// DFS pass can't produce that form, because when a parent P records
258/// a peer version in its children's scope, it only knows the canonical
259/// tail — the peer's OWN suffix is computed later when the peer itself
260/// gets visited. We solve this by running `apply_peer_contexts_once` in
261/// a fixed-point loop: the second iteration's input has Pass 1's
262/// contextualized tails in every `pkg.dependencies` map, so when a
263/// descendant looks a peer up in ancestor scope it sees the full
264/// nested tail and serializes it as such. Most peer chains converge in
265/// 2–3 iterations; we cap at 16 as a safety belt.
266///
267/// Limitations (documented as follow-ups in the README):
268///   - No per-peer range satisfaction — we take whatever the ancestor has,
269///     even if it technically doesn't match P's declared peer range.
270///
271/// Knobs controlling the peer-context pass. Plumbed from four
272/// pnpm-compatible settings (`dedupe-peer-dependents`, `dedupe-peers`,
273/// `resolve-peers-from-workspace-root`, `peers-suffix-max-length`)
274/// through the `Resolver`'s `with_*` setters.
275#[derive(Debug, Clone, Copy)]
276pub struct PeerContextOptions {
277    /// When true, run the cross-subtree peer-variant collapse pass
278    /// after every iteration of the fixed-point loop. Matches pnpm's
279    /// default.
280    pub dedupe_peer_dependents: bool,
281    /// When true, emit suffixes as `(version)` instead of
282    /// `(name@version)`. Affects both the package key, the reference
283    /// tails stored in `dependencies`, and the cycle-break form of
284    /// `contains_canonical_back_ref`.
285    pub dedupe_peers: bool,
286    /// When true, unresolved peers can be satisfied by a dep declared
287    /// at the root importer (`"."`) even if no ancestor scope carries
288    /// the peer. Runs between own-deps and graph-wide scan in the
289    /// peer-context visitor — see `visit_peer_context` in this
290    /// module for the owning implementation (intentionally crate-
291    /// private; the public API here is the option flag itself).
292    pub resolve_from_workspace_root: bool,
293    /// Byte cap on the peer-ID suffix after which the entire suffix
294    /// is hashed to `_<10-char-sha256-hex>`. pnpm's default is 1000.
295    pub peers_suffix_max_length: usize,
296}
297
298impl Default for PeerContextOptions {
299    fn default() -> Self {
300        Self {
301            dedupe_peer_dependents: true,
302            dedupe_peers: false,
303            resolve_from_workspace_root: true,
304            peers_suffix_max_length: 1000,
305        }
306    }
307}
308
309/// Compute peer-context suffixes over an already-resolved graph.
310///
311/// Takes a *canonical* graph — one `LockedPackage` per `(name,
312/// version)` with `peer_dependencies` populated — and produces a
313/// *contextualized* graph whose keys and transitive references carry
314/// `(peer@ver)` suffixes when packages resolve peers differently in
315/// different subtrees. Drives the sibling-symlink wiring in
316/// `aube-linker` for peers, so every fetch/materialize site sees a
317/// per-context identity for any package whose peers disambiguate.
318///
319/// Public so lockfile-driven installs can run the pass over graphs
320/// parsed from npm/yarn/bun lockfiles (which emit canonical form —
321/// no peer suffixes — and would otherwise leave peer-dependent
322/// packages without their peers as `.aube/<pkg>/node_modules/<peer>`
323/// siblings). Fresh resolves call it internally from
324/// `Resolver::resolve`.
325pub fn apply_peer_contexts(
326    canonical: LockfileGraph,
327    options: &PeerContextOptions,
328) -> LockfileGraph {
329    const MAX_ITERATIONS: usize = 16;
330    let mut current = canonical;
331    let mut converged = false;
332    // Hash both keys and dependency tails. A peer-context iteration can
333    // rewrite a dependency value to point at an existing key without
334    // adding a new key, so a key-only convergence test ships partially
335    // rewritten tails. Linker reads tails directly to locate sibling
336    // symlink targets, stale tails produce broken `node_modules`.
337    let graph_hash = |g: &LockfileGraph| -> u64 {
338        let total_deps: usize = g.packages.values().map(|p| p.dependencies.len()).sum();
339        let mut tokens: Vec<&str> = Vec::with_capacity(g.packages.len() * 3 + total_deps * 2);
340        for (k, pkg) in &g.packages {
341            tokens.push(k.as_str());
342            tokens.push("\x1f");
343            for (name, tail) in &pkg.dependencies {
344                tokens.push(name.as_str());
345                tokens.push(tail.as_str());
346            }
347            tokens.push("\x1e");
348        }
349        aube_util::hash::ordered_seq_hash(tokens.iter().copied())
350    };
351    for i in 0..MAX_ITERATIONS {
352        let before = graph_hash(&current);
353        let after_once = apply_peer_contexts_once(current, options);
354        let next = if options.dedupe_peer_dependents {
355            dedupe_peer_variants(after_once)
356        } else {
357            after_once
358        };
359        if before == graph_hash(&next) {
360            tracing::debug!("peer-context pass converged after {i} iteration(s)");
361            current = next;
362            converged = true;
363            break;
364        }
365        current = next;
366    }
367    if !converged {
368        // Hit iteration cap. Means mutually recursive peers or
369        // genuine cycle. Lockfile now has partial nested suffixes.
370        // Linker downstream will wire symlinks against incomplete
371        // graph. Returning this silently ships broken node_modules.
372        // Old code used warn!, warn gets swallowed in CI. Bump to
373        // error! so ops see it. Proper fix is returning a Result
374        // from apply_peer_contexts but that cascades up through
375        // Resolver::resolve signature, do that separately.
376        tracing::error!(
377            "peer-context hit MAX_ITERATIONS={MAX_ITERATIONS} without convergence. \
378             mutually recursive peers likely. lockfile incomplete, linker output will be wrong"
379        );
380    }
381    // `dedupe-peers=true` rewrites the parenthesized peer suffix to
382    // drop the `name@` prefix. Done as a post-pass rather than inline
383    // so cycle detection during the fixed-point loop keeps the full
384    // `name@version` form (otherwise unrelated same-version packages
385    // would false-positive as back-references).
386    if options.dedupe_peers {
387        dedupe_peer_suffixes(current)
388    } else {
389        current
390    }
391}
392
393/// Cross-subtree peer-variant dedupe. When `dedupe-peer-dependents` is
394/// on, packages that landed at different contextualized dep_paths but
395/// resolved every declared peer to the *same* version (ignoring the
396/// nested peer suffix on each peer tail) collapse into a single
397/// canonical variant — chosen as the lexicographically smallest key in
398/// the equivalence class. References in every surviving
399/// `LockedPackage.dependencies` map and every `importers[*]` direct
400/// dep get rewritten through the old→canonical map, and the
401/// non-canonical entries are dropped from `packages`.
402///
403/// Packages whose `peer_dependencies` map is empty — i.e. the canonical
404/// base already has only one variant — are skipped.
405pub(crate) fn dedupe_peer_variants(graph: LockfileGraph) -> LockfileGraph {
406    let canonical_base = |key: &str| -> String { canonical_tail(key).to_string() };
407    // Only the peer-bearing part of the resolved peer tail is
408    // comparable across subtrees — the nested suffix could differ even
409    // for peer-equivalent variants on mid-iterations of the outer
410    // fixed-point loop.
411    let peer_base = |tail: &str| -> String { canonical_tail(tail).to_string() };
412
413    // Group dep_paths by their peer-free base name.
414    let mut groups: BTreeMap<String, Vec<String>> = BTreeMap::new();
415    for key in graph.packages.keys() {
416        groups
417            .entry(canonical_base(key))
418            .or_default()
419            .push(key.clone());
420    }
421
422    let mut rewrite: BTreeMap<String, String> = BTreeMap::new();
423    for (_base, mut keys) in groups {
424        if keys.len() < 2 {
425            continue;
426        }
427        // Deterministic order for canonical selection + stable hashing.
428        keys.sort();
429        // Union-find over equivalence classes. Two variants are
430        // equivalent when each declared peer name resolves to the same
431        // peer base in both (or is missing from both).
432        let mut parent: Vec<usize> = (0..keys.len()).collect();
433        fn find(parent: &mut [usize], i: usize) -> usize {
434            if parent[i] == i {
435                i
436            } else {
437                let r = find(parent, parent[i]);
438                parent[i] = r;
439                r
440            }
441        }
442        for i in 0..keys.len() {
443            for j in (i + 1)..keys.len() {
444                let pa = &graph.packages[&keys[i]];
445                let pb = &graph.packages[&keys[j]];
446                // Same canonical version is required — packages with
447                // different versions but the same name would share no
448                // canonical_base only if the name-without-version
449                // collided, which doesn't happen (version is in the
450                // base). Still, belt-and-suspenders.
451                if pa.version != pb.version {
452                    continue;
453                }
454                let peer_names: BTreeSet<&String> = pa
455                    .peer_dependencies
456                    .keys()
457                    .chain(pb.peer_dependencies.keys())
458                    .collect();
459                let equivalent = peer_names.iter().all(|name| {
460                    match (
461                        pa.dependencies.get(name.as_str()),
462                        pb.dependencies.get(name.as_str()),
463                    ) {
464                        (Some(va), Some(vb)) => peer_base(va) == peer_base(vb),
465                        (None, None) => true,
466                        _ => false,
467                    }
468                });
469                if equivalent {
470                    let ri = find(&mut parent, i);
471                    let rj = find(&mut parent, j);
472                    if ri != rj {
473                        parent[ri] = rj;
474                    }
475                }
476            }
477        }
478        // Build class → canonical (smallest key) mapping. Using
479        // index-based iteration here because `find` takes a mutable
480        // reference into `parent`, so holding an immutable borrow
481        // from `keys.iter()` at the same time would double-borrow.
482        #[allow(clippy::needless_range_loop)]
483        {
484            let mut class_rep: BTreeMap<usize, String> = BTreeMap::new();
485            for i in 0..keys.len() {
486                let root = find(&mut parent, i);
487                class_rep
488                    .entry(root)
489                    .and_modify(|cur| {
490                        if keys[i] < *cur {
491                            *cur = keys[i].clone();
492                        }
493                    })
494                    .or_insert_with(|| keys[i].clone());
495            }
496            for i in 0..keys.len() {
497                let root = find(&mut parent, i);
498                let canonical = class_rep[&root].clone();
499                if keys[i] != canonical {
500                    rewrite.insert(keys[i].clone(), canonical);
501                }
502            }
503        }
504    }
505
506    if rewrite.is_empty() {
507        return graph;
508    }
509
510    // Rewrite package dependency tails and keep only canonicals.
511    let LockfileGraph {
512        importers,
513        packages,
514        settings,
515        overrides,
516        ignored_optional_dependencies,
517        times,
518        skipped_optional_dependencies,
519        catalogs,
520        bun_config_version,
521        patched_dependencies,
522        trusted_dependencies,
523        extra_fields,
524        workspace_extra_fields,
525    } = graph;
526
527    let mut new_packages: BTreeMap<String, LockedPackage> = BTreeMap::new();
528    for (key, mut pkg) in packages {
529        if rewrite.contains_key(&key) {
530            continue;
531        }
532        for (dep_name, dep_tail) in pkg.dependencies.iter_mut() {
533            let dep_key = format!("{dep_name}@{dep_tail}");
534            if let Some(canonical) = rewrite.get(&dep_key) {
535                let new_tail = canonical
536                    .strip_prefix(&format!("{dep_name}@"))
537                    .map(|s| s.to_string())
538                    .unwrap_or_else(|| canonical.clone());
539                *dep_tail = new_tail;
540            }
541        }
542        new_packages.insert(key, pkg);
543    }
544
545    let mut new_importers: BTreeMap<String, Vec<DirectDep>> = BTreeMap::new();
546    for (importer_path, deps) in importers {
547        let mut new_deps = Vec::with_capacity(deps.len());
548        for mut dep in deps {
549            if let Some(canonical) = rewrite.get(&dep.dep_path) {
550                dep.dep_path = canonical.clone();
551            }
552            new_deps.push(dep);
553        }
554        new_importers.insert(importer_path, new_deps);
555    }
556
557    LockfileGraph {
558        importers: new_importers,
559        packages: new_packages,
560        settings,
561        overrides,
562        ignored_optional_dependencies,
563        times,
564        skipped_optional_dependencies,
565        catalogs,
566        bun_config_version,
567        patched_dependencies,
568        trusted_dependencies,
569        extra_fields,
570        workspace_extra_fields,
571    }
572}
573
574/// Single pass of the peer-context computation. See `apply_peer_contexts`
575/// for the wrapping fixed-point loop.
576///
577/// Algorithm per visited package P, reached at some point in a DFS from an
578/// importer with `ancestor_scope: name -> dep_path_tail`:
579///
580///  1. For each peer name declared by P, look it up in `ancestor_scope`
581///     (nearest-ancestor-wins, since the scope is rebuilt per recursion).
582///     If missing, fall back to P's own entry in `dependencies` — the BFS
583///     enqueue auto-installed it as a transitive, matching pnpm's
584///     `auto-install-peers=true` default.
585///  2. Sort the (peer_name, resolution) pairs and serialize as
586///     `(n1@v1)(n2@v2)…` for the suffix.
587///  3. Produce a contextualized dep_path `name@version{suffix}`. If that
588///     key is already in `out_packages` (or currently on the DFS stack via
589///     `visiting`), short-circuit — we've already emitted this variant.
590///  4. Build a new scope for P's children by merging the ancestor scope
591///     with P's own `dependencies` and the resolved peer map. Recurse.
592///  5. Emit the contextualized LockedPackage.
593///
594/// Cycles: protected by `visiting` — if a package is re-entered via a
595/// dependency cycle, we return the already-computed dep_path without
596/// recursing again. The peer context is fixed at first visit; any cycle
597/// traversal uses whatever context was live at that first visit.
598fn apply_peer_contexts_once(
599    canonical: LockfileGraph,
600    options: &PeerContextOptions,
601) -> LockfileGraph {
602    let mut out_packages: BTreeMap<String, LockedPackage> = BTreeMap::new();
603    let mut new_importers: BTreeMap<String, Vec<DirectDep>> = BTreeMap::new();
604
605    // Name-indexed view of the canonical graph, shared across
606    // every `visit_peer_context` call in this pass. Peer-resolution
607    // scan-by-name is the resolver's hottest inner loop. Without
608    // this, each peer runs `O(|graph|)` per package per fixed-point
609    // iter. Prebuilt index drops the scan to O(1) average.
610    let mut name_index: FxHashMap<&str, Vec<&LockedPackage>> = FxHashMap::default();
611    for pkg in canonical.packages.values() {
612        name_index.entry(pkg.name.as_str()).or_default().push(pkg);
613    }
614
615    // Root-importer scope used by `resolve-peers-from-workspace-root`.
616    // Computed once from the canonical input so it reflects the
617    // contextualized state of every root dep on fixed-point iterations
618    // 2+ — same logic as per-importer `importer_scope` below.
619    let root_scope: FxHashMap<String, String> = canonical
620        .importers
621        .get(".")
622        .map(|deps| scope_map_from_deps(deps))
623        .unwrap_or_default();
624
625    for (importer_path, direct_deps) in &canonical.importers {
626        // An importer's own direct deps are in scope for its children's
627        // peer resolution — this is how pnpm's "auto-install at the root"
628        // path gets peer links that point at root-level packages.
629        //
630        // Use the *full contextualized tail* off each DirectDep rather
631        // than the package's plain version. On Pass 1 of the fixed-point
632        // loop the tail is canonical and equal to `p.version`; on Pass 2+
633        // it's already contextualized, and passing the plain version
634        // would make descendants look up keys that don't exist in the
635        // (now-nested) graph.
636        let importer_scope = scope_map_from_deps(direct_deps);
637
638        let mut new_deps = Vec::with_capacity(direct_deps.len());
639        for dep in direct_deps {
640            // `visiting` is the DFS stack guard for this particular descent
641            // — reset per direct dep so we don't incorrectly flag a package
642            // as a cycle when it's reached again from a sibling subtree.
643            // The shared `out_packages` still dedupes across siblings since
644            // the second visit hits the `contains_key` short-circuit below.
645            //
646            // Invariant (see `visit_peer_context` for the detailed handling):
647            // a dep_path returned from the cycle-break branch may not yet
648            // be present in `out_packages` at the moment of return, because
649            // the package is still being assembled up the call stack. The
650            // parent that records the returned tail will complete its own
651            // insertion before the recursion unwinds, so by the time
652            // anything reads the graph, every referenced dep_path exists.
653            let mut visiting: FxHashSet<String> = FxHashSet::default();
654            let new_dep_path = visit_peer_context(
655                &dep.dep_path,
656                &canonical,
657                &name_index,
658                &importer_scope,
659                &root_scope,
660                &mut out_packages,
661                &mut visiting,
662                options,
663            )
664            .unwrap_or_else(|| dep.dep_path.clone());
665            new_deps.push(DirectDep {
666                name: dep.name.clone(),
667                dep_path: new_dep_path,
668                dep_type: dep.dep_type,
669                specifier: dep.specifier.clone(),
670            });
671        }
672        new_importers.insert(importer_path.clone(), new_deps);
673    }
674
675    // Any canonical package that was never reached by the DFS (orphaned
676    // from every importer) is dropped — that matches the filter_deps
677    // semantics and avoids emitting dead entries into the lockfile.
678
679    LockfileGraph {
680        importers: new_importers,
681        packages: out_packages,
682        // The post-pass is pure — settings + overrides carry through
683        // from the input graph untouched.
684        settings: canonical.settings,
685        overrides: canonical.overrides,
686        ignored_optional_dependencies: canonical.ignored_optional_dependencies,
687        times: canonical.times,
688        skipped_optional_dependencies: canonical.skipped_optional_dependencies,
689        catalogs: canonical.catalogs,
690        bun_config_version: canonical.bun_config_version,
691        patched_dependencies: canonical.patched_dependencies,
692        trusted_dependencies: canonical.trusted_dependencies,
693        extra_fields: canonical.extra_fields,
694        workspace_extra_fields: canonical.workspace_extra_fields,
695    }
696}
697
698/// DFS helper for `apply_peer_contexts`. Returns the peer-contextualized
699/// dep_path of the visited package, or `None` if the canonical package is
700/// missing (shouldn't happen in practice but we degrade gracefully).
701/// Does `value` contain a peer-suffix reference to `canonical` as a
702/// proper name@version boundary (i.e. preceded by `(` and followed by
703/// `(` / `)` / end-of-string)? Used by the peer-context pass to detect
704/// when a nested tail loops back to the current package so it can
705/// short-circuit the chain instead of growing the suffix forever.
706/// If `s` ends with `_<10 lowercase hex>` (the marker written by
707/// `hash_peer_suffix`), strip it and return the prefix. Otherwise
708/// return `s` unchanged.
709///
710/// Safe against false positives: `s` here is always a post-split
711/// `name@version` base, and semver forbids `_` inside a version, so
712/// an underscore 10 chars from the end of `name@version` can only be
713/// our marker.
714/// Everything before the first `(` — i.e. the canonical `name@version`
715/// part of a dep-path with the peer-context suffix stripped. Returns
716/// the original string when no `(` is present. Borrowed; callers that
717/// need owned bump with `.to_string()`.
718fn canonical_tail(s: &str) -> &str {
719    s.split('(').next().unwrap_or(s)
720}
721
722/// Build a `name → contextualized tail` map from a direct-dep slice.
723/// The tail is the dep_path with the `{name}@` prefix stripped, which
724/// on pass 1 is equal to `pkg.version` and on pass 2+ carries the
725/// nested peer-context suffix. Used both for the root scope and for
726/// each importer's own scope inside `apply_peer_contexts_once`.
727fn scope_map_from_deps(deps: &[DirectDep]) -> FxHashMap<String, String> {
728    let mut out = FxHashMap::with_capacity_and_hasher(deps.len(), Default::default());
729    for d in deps {
730        let prefix_len = d.name.len() + 1;
731        let tail = if d.dep_path.len() > prefix_len
732            && d.dep_path.as_bytes().get(d.name.len()) == Some(&b'@')
733            && d.dep_path.as_bytes().starts_with(d.name.as_bytes())
734        {
735            d.dep_path[prefix_len..].to_string()
736        } else {
737            d.dep_path.clone()
738        };
739        out.insert(d.name.clone(), tail);
740    }
741    out
742}
743
744fn strip_hashed_peer_suffix(s: &str) -> &str {
745    const MARKER_LEN: usize = 11; // `_` + 10 hex chars
746    if s.len() < MARKER_LEN {
747        return s;
748    }
749    let tail = &s[s.len() - MARKER_LEN..];
750    if !tail.starts_with('_') {
751        return s;
752    }
753    if tail[1..]
754        .chars()
755        .all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c))
756    {
757        &s[..s.len() - MARKER_LEN]
758    } else {
759        s
760    }
761}
762
763/// Hash a peer-ID suffix with SHA-256 and return `_<10-char-hex>`.
764/// Used by the peer-context pass when the raw suffix length exceeds
765/// `peersSuffixMaxLength`. Matches pnpm's format so lockfile dep_path
766/// keys stay portable.
767pub(crate) fn hash_peer_suffix(suffix: &str) -> String {
768    use sha2::{Digest, Sha256};
769    let digest = Sha256::digest(suffix.as_bytes());
770    let mut out = String::with_capacity(11);
771    out.push('_');
772    for byte in digest.iter().take(5) {
773        use std::fmt::Write;
774        let _ = write!(out, "{byte:02x}");
775    }
776    out
777}
778
779pub(crate) fn contains_canonical_back_ref(value: &str, canonical: &str) -> bool {
780    let bytes = value.as_bytes();
781    let target = canonical.as_bytes();
782    if target.is_empty() || target.len() > bytes.len() {
783        return false;
784    }
785    let mut i = 0;
786    while i + target.len() <= bytes.len() {
787        if &bytes[i..i + target.len()] == target {
788            let before = if i == 0 { b'\0' } else { bytes[i - 1] };
789            let after = bytes.get(i + target.len()).copied().unwrap_or(b'\0');
790            let before_ok = before == b'(';
791            let after_ok = after == b'(' || after == b')' || after == b'\0';
792            if before_ok && after_ok {
793                return true;
794            }
795        }
796        i += 1;
797    }
798    false
799}
800
801/// Dedupe-peers post-pass: strip the `name@` prefix from every
802/// parenthesized peer segment in every dep_path key and reference,
803/// turning `react-dom@18.2.0(react@18.2.0)` into
804/// `react-dom@18.2.0(18.2.0)`. Nested segments get the same treatment
805/// so `a@1(b@2(c@3))` becomes `a@1(2(3))`.
806///
807/// Running this as a final post-pass (instead of inline during suffix
808/// assembly in `visit_peer_context`) keeps cycle detection correct:
809/// the detection path works against the full `name@version` form
810/// throughout the fixed-point loop, and only the serialized output
811/// gets the shorter form. A version-only inline approach would
812/// false-positive on unrelated packages that coincidentally share a
813/// version with the current package's canonical base.
814///
815/// Pure: no-op when `dedupe_peers` is off (caller gates the call);
816/// otherwise rewrites every package key, every `LockedPackage.dep_path`
817/// and `LockedPackage.dependencies` value, and every `importers[*]`
818/// DirectDep `dep_path` through the same `apply_dedupe_peers_to_tail`
819/// helper. Package bodies (integrity, metadata, etc.) are cloned
820/// verbatim.
821pub(crate) fn dedupe_peer_suffixes(graph: LockfileGraph) -> LockfileGraph {
822    // Pass 1: compute the intended deduped key for each package and
823    // tally how many distinct full-form keys map to it. Stripping
824    // `name@` from suffix segments is lossy — two variants whose peer
825    // *names* differ but whose peer *versions* coincide would collapse
826    // onto the same deduped key (e.g. `consumer@1.0.0(foo@1.0.0)` and
827    // `consumer@1.0.0(bar@1.0.0)` both → `consumer@1.0.0(1.0.0)`).
828    // `dedupe_peer_variants` already merged the peer-equivalent
829    // duplicates, so any remaining collision here represents genuinely
830    // distinct variants — losing one would silently drop its
831    // dependency wiring. We detect those collisions and keep both
832    // sides in full form.
833    let mut target_counts: BTreeMap<String, usize> = BTreeMap::new();
834    let mut intended: BTreeMap<String, String> = BTreeMap::new();
835    for key in graph.packages.keys() {
836        let new_key = apply_dedupe_peers_to_key(key);
837        *target_counts.entry(new_key.clone()).or_insert(0) += 1;
838        intended.insert(key.clone(), new_key);
839    }
840    let rewrite: BTreeMap<String, String> = intended
841        .into_iter()
842        .map(|(old, new)| {
843            if target_counts.get(&new).copied().unwrap_or(0) > 1 {
844                tracing::warn!(
845                    "dedupe-peers: collision on {new} — keeping {old} in full form to avoid \
846                     dropping a distinct peer-variant"
847                );
848                (old.clone(), old)
849            } else {
850                (old, new)
851            }
852        })
853        .collect();
854
855    // Rewrite a `(child_name, tail)` reference by reconstructing the
856    // target's full-form key, looking up its effective rewrite, and
857    // stripping `child_name@` off the result to recover the tail.
858    // Tails always follow their target package's rewrite decision,
859    // so references stay consistent when a collision forces a target
860    // back to full form.
861    let rewrite_tail = |child_name: &str, tail: &str| -> String {
862        let old_key = format!("{child_name}@{tail}");
863        match rewrite.get(&old_key) {
864            Some(new_key) => new_key
865                .strip_prefix(&format!("{child_name}@"))
866                .map(|s| s.to_string())
867                .unwrap_or_else(|| tail.to_string()),
868            None => apply_dedupe_peers_to_tail(tail),
869        }
870    };
871
872    let mut new_packages: BTreeMap<String, LockedPackage> = BTreeMap::new();
873    for (old_key, pkg) in graph.packages {
874        let new_key = rewrite
875            .get(&old_key)
876            .cloned()
877            .unwrap_or_else(|| old_key.clone());
878        let new_dependencies: BTreeMap<String, String> = pkg
879            .dependencies
880            .into_iter()
881            .map(|(n, v)| {
882                let new_v = rewrite_tail(&n, &v);
883                (n, new_v)
884            })
885            .collect();
886        let new_optional_dependencies: BTreeMap<String, String> = pkg
887            .optional_dependencies
888            .into_iter()
889            .map(|(n, v)| {
890                let new_v = rewrite_tail(&n, &v);
891                (n, new_v)
892            })
893            .collect();
894        new_packages.insert(
895            new_key.clone(),
896            LockedPackage {
897                name: pkg.name,
898                version: pkg.version,
899                integrity: pkg.integrity,
900                dependencies: new_dependencies,
901                optional_dependencies: new_optional_dependencies,
902                peer_dependencies: pkg.peer_dependencies,
903                peer_dependencies_meta: pkg.peer_dependencies_meta,
904                dep_path: new_key,
905                local_source: pkg.local_source,
906                os: pkg.os,
907                cpu: pkg.cpu,
908                libc: pkg.libc,
909                bundled_dependencies: pkg.bundled_dependencies,
910                optional: pkg.optional,
911                transitive_peer_dependencies: pkg.transitive_peer_dependencies,
912                tarball_url: pkg.tarball_url,
913                alias_of: pkg.alias_of,
914                yarn_checksum: pkg.yarn_checksum,
915                engines: pkg.engines,
916                bin: pkg.bin,
917                declared_dependencies: pkg.declared_dependencies,
918                license: pkg.license,
919                funding_url: pkg.funding_url,
920                extra_meta: pkg.extra_meta,
921            },
922        );
923    }
924
925    let new_importers: BTreeMap<String, Vec<DirectDep>> = graph
926        .importers
927        .into_iter()
928        .map(|(path, deps)| {
929            let rewritten = deps
930                .into_iter()
931                .map(|d| {
932                    let new_dep_path = rewrite
933                        .get(&d.dep_path)
934                        .cloned()
935                        .unwrap_or_else(|| apply_dedupe_peers_to_key(&d.dep_path));
936                    DirectDep {
937                        name: d.name,
938                        dep_path: new_dep_path,
939                        dep_type: d.dep_type,
940                        specifier: d.specifier,
941                    }
942                })
943                .collect();
944            (path, rewritten)
945        })
946        .collect();
947
948    LockfileGraph {
949        importers: new_importers,
950        packages: new_packages,
951        settings: graph.settings,
952        overrides: graph.overrides,
953        ignored_optional_dependencies: graph.ignored_optional_dependencies,
954        times: graph.times,
955        skipped_optional_dependencies: graph.skipped_optional_dependencies,
956        catalogs: graph.catalogs,
957        bun_config_version: graph.bun_config_version,
958        patched_dependencies: graph.patched_dependencies,
959        trusted_dependencies: graph.trusted_dependencies,
960        extra_fields: graph.extra_fields,
961        workspace_extra_fields: graph.workspace_extra_fields,
962    }
963}
964
965/// Strip `name@` from inside every parenthesized segment of a full
966/// dep_path key (e.g. `react-dom@18.2.0(react@18.2.0)` →
967/// `react-dom@18.2.0(18.2.0)`). The first `name@version` outside any
968/// parens is preserved verbatim — that's the canonical head of the
969/// dep_path and `dedupe-peers` only affects the peer suffix.
970pub(crate) fn apply_dedupe_peers_to_key(key: &str) -> String {
971    let mut parts = key.split('(');
972    let Some(first) = parts.next() else {
973        return key.to_string();
974    };
975    let mut out = String::with_capacity(key.len());
976    out.push_str(first);
977    for part in parts {
978        out.push('(');
979        // In a well-formed key, `part` looks like `name@version)` /
980        // `name@version` / `version)` / ... We strip everything up to
981        // and including the LAST `@` (scoped packages like
982        // `@types/react@18.2.0` contain two `@`s; the separator is the
983        // rightmost one). We only strip if that `@` comes before the
984        // first `)` or `(` (i.e. the segment actually starts with
985        // `name@`, not the outer parens closing with no name inside).
986        if let Some(at_idx) = part.rfind('@') {
987            let close_idx = part.find([')', '(']).unwrap_or(usize::MAX);
988            if at_idx < close_idx {
989                out.push_str(&part[at_idx + 1..]);
990                continue;
991            }
992        }
993        out.push_str(part);
994    }
995    out
996}
997
998/// Same as [`apply_dedupe_peers_to_key`] but for dep-tail values
999/// stored in `LockedPackage.dependencies` (e.g. `18.2.0(react@18.2.0)`
1000/// → `18.2.0(18.2.0)`). Tails differ from keys only by lacking the
1001/// leading `name@` prefix — both use the same parens-based suffix
1002/// shape, so the algorithm is identical.
1003fn apply_dedupe_peers_to_tail(tail: &str) -> String {
1004    apply_dedupe_peers_to_key(tail)
1005}
1006
1007#[allow(clippy::too_many_arguments)]
1008fn visit_peer_context<'g>(
1009    input_dep_path: &str,
1010    graph: &'g LockfileGraph,
1011    name_index: &FxHashMap<&'g str, Vec<&'g LockedPackage>>,
1012    ancestor_scope: &FxHashMap<String, String>,
1013    root_scope: &FxHashMap<String, String>,
1014    out_packages: &mut BTreeMap<String, LockedPackage>,
1015    visiting: &mut FxHashSet<String>,
1016    options: &PeerContextOptions,
1017) -> Option<String> {
1018    let pkg = graph.packages.get(input_dep_path)?;
1019
1020    // The input key may already carry a peer suffix (fixed-point loop
1021    // Pass 2+). Drop it before we build a new one — otherwise we'd
1022    // append the new suffix on top of the old and grow unboundedly
1023    // across iterations (classic mutual-peer-cycle blow-up).
1024    //
1025    // Two suffix forms can be present from a prior pass:
1026    //   1. `(name@version)(…)` — the normal nested peer suffix. Stripped
1027    //      by splitting on the first `(`.
1028    //   2. `_<10-char-sha256-hex>` — the hashed form produced when the
1029    //      normal suffix exceeded `peersSuffixMaxLength`. Must also be
1030    //      stripped; otherwise each pass re-hashes the already-hashed
1031    //      key and appends another marker (exposed by the
1032    //      `peer_suffix_is_hashed_when_exceeding_cap` unit test).
1033    let canonical_base = canonical_tail(input_dep_path);
1034    let canonical_base = strip_hashed_peer_suffix(canonical_base).to_string();
1035
1036    // Compute peer context: walk declared peers, resolve from ancestors
1037    // (nearest wins — the scope is rebuilt as we recurse) or from the
1038    // package's own dependency map as the auto-install fallback. Both
1039    // sides may produce nested tails on the second and later iterations
1040    // of the fixed-point loop.
1041    // Resolution source priority for each declared peer:
1042    //   1. Ancestor scope — if the ancestor's version actually
1043    //      satisfies the declared peer range. Different subtrees can
1044    //      pin different versions of the same peer name (classic
1045    //      `lib-a peers on react@^17`, `lib-b peers on react@^18`),
1046    //      and silently reusing the ancestor's version regardless of
1047    //      the declared range would force both libs onto the same
1048    //      version — exactly the behavior we want to fix here.
1049    //   2. The current package's own `pkg.dependencies` entry — the
1050    //      BFS peer-walk enqueued this peer with the declared range,
1051    //      so whatever got picked there is guaranteed to satisfy.
1052    //   3. A graph-wide scan as a last resort: any package whose name
1053    //      matches and whose version satisfies the declared range.
1054    //      This keeps nested-context callers from losing their peer
1055    //      resolution when neither ancestor nor own-deps has it.
1056    //   4. If no satisfying version exists, fall back to the nearest
1057    //      incompatible ancestor/root/pkg dependency. pnpm still wires
1058    //      that user-declared version into the peer context and then
1059    //      reports the semver mismatch; omitting it would produce a
1060    //      weaker "missing peer" warning and an unsuffixed snapshot.
1061    //
1062    // If nothing in the graph satisfies, the peer is left out of the
1063    // context entirely — `detect_unmet_peers` will surface it as a
1064    // warning after the pass.
1065    let mut peer_context: Vec<(String, String)> = Vec::new();
1066    for (peer_name, declared_range) in &pkg.peer_dependencies {
1067        let satisfies_declared = |v: &str| -> bool {
1068            // The tail may carry a nested peer suffix on fixed-point
1069            // iterations 2+; strip it before checking the semver.
1070            let canonical = canonical_tail(v);
1071            version_satisfies(canonical, declared_range)
1072        };
1073
1074        let from_ancestor = ancestor_scope
1075            .get(peer_name)
1076            .filter(|v| satisfies_declared(v))
1077            .cloned();
1078        let from_ancestor_incompatible = ancestor_scope.get(peer_name).cloned();
1079
1080        let from_pkg_deps = pkg
1081            .dependencies
1082            .get(peer_name)
1083            .filter(|v| satisfies_declared(v))
1084            .cloned();
1085        let from_pkg_deps_incompatible = pkg.dependencies.get(peer_name).cloned();
1086
1087        // `resolve-peers-from-workspace-root`: fall back to the root
1088        // importer's direct deps before the graph-wide scan. Common in
1089        // monorepos where the workspace root pins shared peers (e.g.
1090        // `react`) that leaf packages peer on without declaring them
1091        // in their own subtree. Skipped when the setting is off —
1092        // matches pnpm's `resolve-peers-from-workspace-root=false`.
1093        let from_root = if options.resolve_from_workspace_root {
1094            root_scope
1095                .get(peer_name)
1096                .filter(|v| satisfies_declared(v))
1097                .cloned()
1098        } else {
1099            None
1100        };
1101        let from_root_incompatible = if options.resolve_from_workspace_root {
1102            root_scope.get(peer_name).cloned()
1103        } else {
1104            None
1105        };
1106
1107        // Return the full dep_path TAIL (the part after `name@`), not
1108        // just `p.version`. On fixed-point iteration 2+, the input
1109        // graph's keys are contextualized — e.g. `react-dom` lives at
1110        // `react-dom@18.2.0(react@18.2.0)`. Downstream code
1111        // reconstructs the child lookup key with
1112        // `format!("{child_name}@{tail}")` and needs the tail to
1113        // match whatever the graph has keyed it under, otherwise the
1114        // lookup returns None and the peer gets silently dropped
1115        // from `new_dependencies`. The semver check is against the
1116        // package's canonical `version` field, not the tail, because
1117        // the tail may carry a peer suffix that isn't valid semver.
1118        let from_graph_scan = || {
1119            name_index
1120                .get(peer_name.as_str())
1121                .into_iter()
1122                .flat_map(|bucket| bucket.iter().copied())
1123                .filter(|p| version_satisfies(&p.version, declared_range))
1124                .filter_map(|p| {
1125                    let tail = p
1126                        .dep_path
1127                        .strip_prefix(&format!("{}@", p.name))
1128                        .map(|s| s.to_string())
1129                        .unwrap_or_else(|| p.version.clone());
1130                    node_semver::Version::parse(&p.version)
1131                        .ok()
1132                        .map(|ver| (ver, tail))
1133                })
1134                .max_by(|a, b| a.0.cmp(&b.0))
1135                .map(|(_, tail)| tail)
1136        };
1137
1138        if let Some(version) = from_ancestor
1139            .or(from_pkg_deps)
1140            .or(from_root)
1141            .or_else(from_graph_scan)
1142            .or(from_ancestor_incompatible)
1143            .or(from_pkg_deps_incompatible)
1144            .or(from_root_incompatible)
1145        {
1146            peer_context.push((peer_name.clone(), version));
1147        }
1148    }
1149    peer_context.sort_by(|a, b| a.0.cmp(&b.0));
1150
1151    // For the SUFFIX we build a cycle-broken copy: any peer value that
1152    // nests a reference back to the current package's canonical base
1153    // gets stripped to its plain version. Without this, mutual peer
1154    // cycles (a peers on b, b peers on a) grow the suffix one level
1155    // per iteration of the fixed-point loop and never converge.
1156    //
1157    // The non-cycle paths are untouched, so a regular nested chain
1158    // like `(react-dom@18.2.0(react@18.2.0))` still serializes fully.
1159    // We deliberately keep the full nested tails in `peer_context` for
1160    // downstream scope propagation and child lookups — suffix cycle-
1161    // breaking is cosmetic and should not change what packages exist
1162    // or which snapshot entries reference each other.
1163    //
1164    // Cycle detection is always done against the full `name@version`
1165    // canonical base — even when `dedupe-peers=true` is on, because
1166    // the version-only form is ambiguous (two unrelated packages at
1167    // the same version would false-positive). `dedupe-peers` is
1168    // applied as a post-pass over the final graph in
1169    // `dedupe_peer_suffixes` after cycle detection is done.
1170    let suffix: String = peer_context
1171        .iter()
1172        .map(|(n, v)| {
1173            let cycles_back = contains_canonical_back_ref(v, &canonical_base);
1174            let display_v = if cycles_back {
1175                canonical_tail(v).to_string()
1176            } else {
1177                v.clone()
1178            };
1179            format!("({n}@{display_v})")
1180        })
1181        .collect();
1182    // pnpm's `peersSuffixMaxLength`: when the built suffix exceeds the
1183    // cap, replace the entire suffix with `_<10-char-sha256-hex>` so the
1184    // lockfile key stays bounded. Matches pnpm's lockfile format, so
1185    // lockfiles shared between aube and pnpm stay comparable.
1186    let effective_suffix = if suffix.len() > options.peers_suffix_max_length {
1187        hash_peer_suffix(&suffix)
1188    } else {
1189        suffix
1190    };
1191    let contextualized = format!("{canonical_base}{effective_suffix}");
1192
1193    if out_packages.contains_key(&contextualized) || visiting.contains(&contextualized) {
1194        return Some(contextualized);
1195    }
1196    visiting.insert(contextualized.clone());
1197
1198    // Build the scope for P's children. This is ancestor_scope, overlaid
1199    // with P's own dependencies and its resolved peer map. Children see
1200    // their grandparents too — this mirrors pnpm's all-the-way-up peer
1201    // walk.
1202    //
1203    // We deliberately do NOT strip any existing peer-context suffix
1204    // off the tails we put into the scope. On the first pass the
1205    // values are plain (BFS output has no suffixes), so preserving
1206    // them is a no-op; on subsequent passes (see the fixed-point loop
1207    // in `apply_peer_contexts`) the input graph already carries
1208    // contextualized tails, and keeping them in scope is exactly how
1209    // nested peer suffixes propagate down to consumers — a package
1210    // that peers on `react-dom` and reaches it through a parent whose
1211    // `react-dom` entry is already `18.2.0(react@18.2.0)` will see
1212    // that nested tail in its own scope, and its own suffix will
1213    // serialize as `(react-dom@18.2.0(react@18.2.0))`. That's the
1214    // nested form pnpm writes.
1215    let mut child_scope = ancestor_scope.clone();
1216    for (name, version) in &pkg.dependencies {
1217        child_scope.insert(name.clone(), version.clone());
1218    }
1219    for (name, version) in &peer_context {
1220        child_scope.insert(name.clone(), version.clone());
1221    }
1222
1223    // Recurse into each child, rewriting its dependency map entry to
1224    // point at the contextualized dep_path's tail. A child whose visit
1225    // fails (orphaned / missing) keeps its own tail.
1226    //
1227    // For declared peer names, the peer context (filled from the
1228    // ancestor scope) is authoritative — we override whatever the BFS
1229    // peer walk auto-installed. Otherwise the snapshot suffix and the
1230    // actual wired `dependencies[peer]` could disagree, which made the
1231    // sibling symlink target inconsistent with the peer-context claim.
1232    // When the ancestor's version doesn't satisfy the declared range,
1233    // `detect_unmet_peers` will flag it as a warning after the pass.
1234    let peer_context_versions: FxHashMap<String, String> = peer_context.iter().cloned().collect();
1235
1236    let mut new_dependencies: BTreeMap<String, String> = BTreeMap::new();
1237    let mut visited_dep_names: FxHashSet<String> = FxHashSet::default();
1238
1239    for (child_name, child_version_tail) in &pkg.dependencies {
1240        // If this child is a declared peer, its tail comes from the
1241        // peer context (which may be nested). Otherwise we use the
1242        // tail we already have — also possibly nested on a 2nd pass.
1243        let lookup_tail = match peer_context_versions.get(child_name) {
1244            Some(v) => v.clone(),
1245            None => child_version_tail.clone(),
1246        };
1247        let child_canonical_dep_path = format!("{child_name}@{lookup_tail}");
1248        let child_new = visit_peer_context(
1249            &child_canonical_dep_path,
1250            graph,
1251            name_index,
1252            &child_scope,
1253            root_scope,
1254            out_packages,
1255            visiting,
1256            options,
1257        );
1258        let new_tail = match child_new {
1259            Some(new_dep_path) => new_dep_path
1260                .strip_prefix(&format!("{child_name}@"))
1261                .map(|s| s.to_string())
1262                .unwrap_or_else(|| lookup_tail.clone()),
1263            None => lookup_tail.clone(),
1264        };
1265        new_dependencies.insert(child_name.clone(), new_tail);
1266        visited_dep_names.insert(child_name.clone());
1267    }
1268
1269    // Peers that were satisfied purely from the ancestor scope may not
1270    // have been in `pkg.dependencies` at all (no auto-install needed).
1271    // Wire them as deps now so the linker creates the sibling symlink
1272    // and the lockfile snapshot records them.
1273    for (peer_name, peer_version) in &peer_context {
1274        if visited_dep_names.contains(peer_name) {
1275            continue;
1276        }
1277        let child_canonical_dep_path = format!("{peer_name}@{peer_version}");
1278        let child_new = visit_peer_context(
1279            &child_canonical_dep_path,
1280            graph,
1281            name_index,
1282            &child_scope,
1283            root_scope,
1284            out_packages,
1285            visiting,
1286            options,
1287        );
1288        if let Some(new_dep_path) = child_new {
1289            let new_tail = new_dep_path
1290                .strip_prefix(&format!("{peer_name}@"))
1291                .map(|s| s.to_string())
1292                .unwrap_or_else(|| peer_version.clone());
1293            new_dependencies.insert(peer_name.clone(), new_tail);
1294        }
1295    }
1296
1297    visiting.remove(&contextualized);
1298    let new_optional_dependencies: BTreeMap<String, String> = pkg
1299        .optional_dependencies
1300        .keys()
1301        .filter_map(|name| {
1302            new_dependencies
1303                .get(name)
1304                .map(|tail| (name.clone(), tail.clone()))
1305        })
1306        .collect();
1307
1308    out_packages.insert(
1309        contextualized.clone(),
1310        LockedPackage {
1311            name: pkg.name.clone(),
1312            version: pkg.version.clone(),
1313            integrity: pkg.integrity.clone(),
1314            dependencies: new_dependencies,
1315            optional_dependencies: new_optional_dependencies,
1316            peer_dependencies: pkg.peer_dependencies.clone(),
1317            peer_dependencies_meta: pkg.peer_dependencies_meta.clone(),
1318            dep_path: contextualized.clone(),
1319            local_source: pkg.local_source.clone(),
1320            os: pkg.os.clone(),
1321            cpu: pkg.cpu.clone(),
1322            libc: pkg.libc.clone(),
1323            bundled_dependencies: pkg.bundled_dependencies.clone(),
1324            optional: pkg.optional,
1325            transitive_peer_dependencies: pkg.transitive_peer_dependencies.clone(),
1326            tarball_url: pkg.tarball_url.clone(),
1327            alias_of: pkg.alias_of.clone(),
1328            yarn_checksum: pkg.yarn_checksum.clone(),
1329            engines: pkg.engines.clone(),
1330            bin: pkg.bin.clone(),
1331            declared_dependencies: pkg.declared_dependencies.clone(),
1332            license: pkg.license.clone(),
1333            funding_url: pkg.funding_url.clone(),
1334            extra_meta: pkg.extra_meta.clone(),
1335        },
1336    );
1337    Some(contextualized)
1338}