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 crate::{FxHashMap, FxHashSet};
22use aube_lockfile::{DepType, DirectDep, LockedPackage, LockfileGraph};
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) -> Result<LockfileGraph, crate::Error> {
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    // Carry the post-iteration hash forward as the next iteration's
352    // pre-hash. Saves one full graph walk per iteration (the loop runs
353    // up to 16 times; each `graph_hash` allocates a Vec<&str> sized
354    // to `pkgs * 3 + deps * 2` tokens — ~25k entries on a 1000-pkg
355    // graph). One hash per iter instead of two.
356    let mut before = graph_hash(&current);
357    for i in 0..MAX_ITERATIONS {
358        let after_once = apply_peer_contexts_once(current, options);
359        let next = if options.dedupe_peer_dependents {
360            dedupe_peer_variants(after_once)
361        } else {
362            after_once
363        };
364        let after = graph_hash(&next);
365        if before == after {
366            tracing::debug!("peer-context pass converged after {i} iteration(s)");
367            current = next;
368            converged = true;
369            break;
370        }
371        current = next;
372        before = after;
373    }
374    if !converged {
375        // Iteration cap hit. Returning the partial graph would ship
376        // broken node_modules. Now fatal.
377        tracing::error!(
378            code = aube_codes::errors::ERR_AUBE_PEER_CONTEXT_NOT_CONVERGED,
379            max_iterations = MAX_ITERATIONS,
380            "peer-context hit MAX_ITERATIONS={MAX_ITERATIONS} without convergence"
381        );
382        return Err(crate::Error::PeerContextDivergence(MAX_ITERATIONS));
383    }
384    // Propagate each package's peer-suffix segments up through its
385    // non-peer-declaring ancestors so a parent that pulls in a peer-
386    // bearing descendant carries the same `(peer@version)` suffix on
387    // its own dep_path. Matches pnpm's lockfile shape — pnpm 9 emits
388    // every peer-bearing package's resolved peer set on every
389    // ancestor in the chain (importer rows included), even when the
390    // ancestor itself doesn't declare those peers. Without the
391    // propagation aube would tag the suffix only on the package that
392    // declares peers, which differs from pnpm-lock.yaml in the
393    // `importers:` section any time a non-peer-declaring middle node
394    // sits between an importer and its peer-bearing descendant.
395    //
396    // Runs after the fixed-point loop converges so all self-suffixes
397    // are stable, and before `dedupe_peer_suffixes` so the latter's
398    // `(name@version)` → `(version)` collapse acts on the propagated
399    // form too.
400    let current = propagate_peer_suffixes_to_ancestors(current, options);
401    // `dedupe-peers=true` rewrites the parenthesized peer suffix to
402    // drop the `name@` prefix. Done as a post-pass rather than inline
403    // so cycle detection during the fixed-point loop keeps the full
404    // `name@version` form (otherwise unrelated same-version packages
405    // would false-positive as back-references).
406    let result = if options.dedupe_peers {
407        dedupe_peer_suffixes(current)
408    } else {
409        current
410    };
411    Ok(result)
412}
413
414/// Cross-subtree peer-variant dedupe. When `dedupe-peer-dependents` is
415/// on, packages that landed at different contextualized dep_paths but
416/// resolved every declared peer to the *same* version (ignoring the
417/// nested peer suffix on each peer tail) collapse into a single
418/// canonical variant — chosen as the lexicographically smallest key in
419/// the equivalence class. References in every surviving
420/// `LockedPackage.dependencies` map and every `importers[*]` direct
421/// dep get rewritten through the old→canonical map, and the
422/// non-canonical entries are dropped from `packages`.
423///
424/// Packages whose `peer_dependencies` map is empty — i.e. the canonical
425/// base already has only one variant — are skipped.
426pub(crate) fn dedupe_peer_variants(graph: LockfileGraph) -> LockfileGraph {
427    let canonical_base = |key: &str| -> String { canonical_tail(key).to_string() };
428    // Only the peer-bearing part of the resolved peer tail is
429    // comparable across subtrees — the nested suffix could differ even
430    // for peer-equivalent variants on mid-iterations of the outer
431    // fixed-point loop.
432    let peer_base = |tail: &str| -> String { canonical_tail(tail).to_string() };
433
434    // Group dep_paths by their peer-free base name.
435    let mut groups: BTreeMap<String, Vec<String>> = BTreeMap::new();
436    for key in graph.packages.keys() {
437        groups
438            .entry(canonical_base(key))
439            .or_default()
440            .push(key.clone());
441    }
442
443    let mut rewrite: BTreeMap<String, String> = BTreeMap::new();
444    for (_base, mut keys) in groups {
445        if keys.len() < 2 {
446            continue;
447        }
448        // Deterministic order for canonical selection + stable hashing.
449        keys.sort();
450        // Union-find over equivalence classes. Two variants are
451        // equivalent when each declared peer name resolves to the same
452        // peer base in both (or is missing from both).
453        let mut parent: Vec<usize> = (0..keys.len()).collect();
454        fn find(parent: &mut [usize], i: usize) -> usize {
455            if parent[i] == i {
456                i
457            } else {
458                let r = find(parent, parent[i]);
459                parent[i] = r;
460                r
461            }
462        }
463        for i in 0..keys.len() {
464            for j in (i + 1)..keys.len() {
465                let pa = &graph.packages[&keys[i]];
466                let pb = &graph.packages[&keys[j]];
467                // Same canonical version is required — packages with
468                // different versions but the same name would share no
469                // canonical_base only if the name-without-version
470                // collided, which doesn't happen (version is in the
471                // base). Still, belt-and-suspenders.
472                if pa.version != pb.version {
473                    continue;
474                }
475                let peer_names: BTreeSet<&String> = pa
476                    .peer_dependencies
477                    .keys()
478                    .chain(pb.peer_dependencies.keys())
479                    .collect();
480                let equivalent = peer_names.iter().all(|name| {
481                    match (
482                        pa.dependencies.get(name.as_str()),
483                        pb.dependencies.get(name.as_str()),
484                    ) {
485                        (Some(va), Some(vb)) => peer_base(va) == peer_base(vb),
486                        (None, None) => true,
487                        _ => false,
488                    }
489                });
490                if equivalent {
491                    let ri = find(&mut parent, i);
492                    let rj = find(&mut parent, j);
493                    if ri != rj {
494                        parent[ri] = rj;
495                    }
496                }
497            }
498        }
499        // Build class → canonical (smallest key) mapping. Using
500        // index-based iteration here because `find` takes a mutable
501        // reference into `parent`, so holding an immutable borrow
502        // from `keys.iter()` at the same time would double-borrow.
503        #[allow(clippy::needless_range_loop)]
504        {
505            let mut class_rep: BTreeMap<usize, String> = BTreeMap::new();
506            for i in 0..keys.len() {
507                let root = find(&mut parent, i);
508                class_rep
509                    .entry(root)
510                    .and_modify(|cur| {
511                        if keys[i] < *cur {
512                            *cur = keys[i].clone();
513                        }
514                    })
515                    .or_insert_with(|| keys[i].clone());
516            }
517            for i in 0..keys.len() {
518                let root = find(&mut parent, i);
519                let canonical = class_rep[&root].clone();
520                if keys[i] != canonical {
521                    rewrite.insert(keys[i].clone(), canonical);
522                }
523            }
524        }
525    }
526
527    if rewrite.is_empty() {
528        return graph;
529    }
530
531    // Rewrite package dependency tails and keep only canonicals.
532    let LockfileGraph {
533        importers,
534        packages,
535        settings,
536        overrides,
537        ignored_optional_dependencies,
538        times,
539        skipped_optional_dependencies,
540        catalogs,
541        bun_config_version,
542        patched_dependencies,
543        trusted_dependencies,
544        runtimes,
545        extra_fields,
546        workspace_extra_fields,
547    } = graph;
548
549    let mut new_packages: BTreeMap<String, LockedPackage> = BTreeMap::new();
550    for (key, mut pkg) in packages {
551        if rewrite.contains_key(&key) {
552            continue;
553        }
554        for (dep_name, dep_tail) in pkg.dependencies.iter_mut() {
555            let dep_key = format!("{dep_name}@{dep_tail}");
556            if let Some(canonical) = rewrite.get(&dep_key) {
557                let new_tail = canonical
558                    .strip_prefix(&format!("{dep_name}@"))
559                    .map(|s| s.to_string())
560                    .unwrap_or_else(|| canonical.clone());
561                *dep_tail = new_tail;
562            }
563        }
564        new_packages.insert(key, pkg);
565    }
566
567    let mut new_importers: BTreeMap<String, Vec<DirectDep>> = BTreeMap::new();
568    for (importer_path, deps) in importers {
569        let mut new_deps = Vec::with_capacity(deps.len());
570        for mut dep in deps {
571            if let Some(canonical) = rewrite.get(&dep.dep_path) {
572                dep.dep_path = canonical.clone();
573            }
574            new_deps.push(dep);
575        }
576        new_importers.insert(importer_path, new_deps);
577    }
578
579    LockfileGraph {
580        importers: new_importers,
581        packages: new_packages,
582        settings,
583        overrides,
584        ignored_optional_dependencies,
585        times,
586        skipped_optional_dependencies,
587        catalogs,
588        bun_config_version,
589        patched_dependencies,
590        trusted_dependencies,
591        runtimes,
592        extra_fields,
593        workspace_extra_fields,
594    }
595}
596
597/// Single pass of the peer-context computation. See `apply_peer_contexts`
598/// for the wrapping fixed-point loop.
599///
600/// Algorithm per visited package P, reached at some point in a DFS from an
601/// importer with `ancestor_scope: name -> dep_path_tail`:
602///
603///  1. For each peer name declared by P, look it up in `ancestor_scope`
604///     (nearest-ancestor-wins, since the scope is rebuilt per recursion).
605///     If missing, fall back to P's own entry in `dependencies` — the BFS
606///     enqueue auto-installed it as a transitive, matching pnpm's
607///     `auto-install-peers=true` default.
608///  2. Sort the (peer_name, resolution) pairs and serialize as
609///     `(n1@v1)(n2@v2)…` for the suffix.
610///  3. Produce a contextualized dep_path `name@version{suffix}`. If that
611///     key is already in `out_packages` (or currently on the DFS stack via
612///     `visiting`), short-circuit — we've already emitted this variant.
613///  4. Build a new scope for P's children by merging the ancestor scope
614///     with P's own `dependencies` and the resolved peer map. Recurse.
615///  5. Emit the contextualized LockedPackage.
616///
617/// Cycles: protected by `visiting` — if a package is re-entered via a
618/// dependency cycle, we return the already-computed dep_path without
619/// recursing again. The peer context is fixed at first visit; any cycle
620/// traversal uses whatever context was live at that first visit.
621fn apply_peer_contexts_once(
622    canonical: LockfileGraph,
623    options: &PeerContextOptions,
624) -> LockfileGraph {
625    let mut out_packages: BTreeMap<String, LockedPackage> = BTreeMap::new();
626    let mut new_importers: BTreeMap<String, Vec<DirectDep>> = BTreeMap::new();
627
628    // Name-indexed view of the canonical graph, shared across
629    // every `visit_peer_context` call in this pass. Peer-resolution
630    // scan-by-name is the resolver's hottest inner loop. Without
631    // this, each peer runs `O(|graph|)` per package per fixed-point
632    // iter. Prebuilt index drops the scan to O(1) average.
633    //
634    // Pre-size to the package count: most graphs have one entry per
635    // name and only a handful of multi-version names, so capacity
636    // headroom is small and the upper bound saves 8+ rehashes on
637    // medium graphs (default 16 → 2048 covers ~1200 pkgs).
638    let mut name_index: FxHashMap<&str, Vec<&LockedPackage>> =
639        FxHashMap::with_capacity_and_hasher(canonical.packages.len(), Default::default());
640    for pkg in canonical.packages.values() {
641        name_index.entry(pkg.name.as_str()).or_default().push(pkg);
642    }
643
644    // Root-importer scope used by `resolve-peers-from-workspace-root`.
645    // Computed once from the canonical input so it reflects the
646    // contextualized state of every root dep on fixed-point iterations
647    // 2+ — same logic as per-importer `importer_scope` below.
648    let root_scope: FxHashMap<String, String> = canonical
649        .importers
650        .get(".")
651        .map(|deps| scope_map_from_deps(deps))
652        .unwrap_or_default();
653
654    for (importer_path, direct_deps) in &canonical.importers {
655        // An importer's own direct deps are in scope for its children's
656        // peer resolution — this is how pnpm's "auto-install at the root"
657        // path gets peer links that point at root-level packages.
658        //
659        // Use the *full contextualized tail* off each DirectDep rather
660        // than the package's plain version. On Pass 1 of the fixed-point
661        // loop the tail is canonical and equal to `p.version`; on Pass 2+
662        // it's already contextualized, and passing the plain version
663        // would make descendants look up keys that don't exist in the
664        // (now-nested) graph.
665        let importer_scope = scope_map_from_deps(direct_deps);
666
667        let mut new_deps = Vec::with_capacity(direct_deps.len());
668        for dep in direct_deps {
669            // `visiting` is the DFS stack guard for this particular descent
670            // — reset per direct dep so we don't incorrectly flag a package
671            // as a cycle when it's reached again from a sibling subtree.
672            // The shared `out_packages` still dedupes across siblings since
673            // the second visit hits the `contains_key` short-circuit below.
674            //
675            // Invariant (see `visit_peer_context` for the detailed handling):
676            // a dep_path returned from the cycle-break branch may not yet
677            // be present in `out_packages` at the moment of return, because
678            // the package is still being assembled up the call stack. The
679            // parent that records the returned tail will complete its own
680            // insertion before the recursion unwinds, so by the time
681            // anything reads the graph, every referenced dep_path exists.
682            let mut visiting: FxHashSet<String> = FxHashSet::default();
683            let new_dep_path = visit_peer_context(
684                &dep.dep_path,
685                &canonical,
686                &name_index,
687                &importer_scope,
688                &root_scope,
689                &mut out_packages,
690                &mut visiting,
691                options,
692            )
693            .unwrap_or_else(|| dep.dep_path.clone());
694            new_deps.push(DirectDep {
695                name: dep.name.clone(),
696                dep_path: new_dep_path,
697                dep_type: dep.dep_type,
698                specifier: dep.specifier.clone(),
699            });
700        }
701        new_importers.insert(importer_path.clone(), new_deps);
702    }
703
704    // Any canonical package that was never reached by the DFS (orphaned
705    // from every importer) is dropped — that matches the filter_deps
706    // semantics and avoids emitting dead entries into the lockfile.
707
708    LockfileGraph {
709        importers: new_importers,
710        packages: out_packages,
711        // The post-pass is pure — settings + overrides carry through
712        // from the input graph untouched.
713        settings: canonical.settings,
714        overrides: canonical.overrides,
715        ignored_optional_dependencies: canonical.ignored_optional_dependencies,
716        runtimes: canonical.runtimes,
717        times: canonical.times,
718        skipped_optional_dependencies: canonical.skipped_optional_dependencies,
719        catalogs: canonical.catalogs,
720        bun_config_version: canonical.bun_config_version,
721        patched_dependencies: canonical.patched_dependencies,
722        trusted_dependencies: canonical.trusted_dependencies,
723        extra_fields: canonical.extra_fields,
724        workspace_extra_fields: canonical.workspace_extra_fields,
725    }
726}
727
728/// DFS helper for `apply_peer_contexts`. Returns the peer-contextualized
729/// dep_path of the visited package, or `None` if the canonical package is
730/// missing (shouldn't happen in practice but we degrade gracefully).
731/// Does `value` contain a peer-suffix reference to `canonical` as a
732/// proper name@version boundary (i.e. preceded by `(` and followed by
733/// `(` / `)` / end-of-string)? Used by the peer-context pass to detect
734/// when a nested tail loops back to the current package so it can
735/// short-circuit the chain instead of growing the suffix forever.
736/// If `s` ends with `_<10 lowercase hex>` (the marker written by
737/// `hash_peer_suffix`), strip it and return the prefix. Otherwise
738/// return `s` unchanged.
739///
740/// Safe against false positives: `s` here is always a post-split
741/// `name@version` base, and semver forbids `_` inside a version, so
742/// an underscore 10 chars from the end of `name@version` can only be
743/// our marker.
744/// Everything before the first `(` — i.e. the canonical `name@version`
745/// part of a dep-path with the peer-context suffix stripped. Returns
746/// the original string when no `(` is present. Borrowed; callers that
747/// need owned bump with `.to_string()`.
748fn canonical_tail(s: &str) -> &str {
749    s.split('(').next().unwrap_or(s)
750}
751
752/// Build a `name → contextualized tail` map from a direct-dep slice.
753/// The tail is the dep_path with the `{name}@` prefix stripped, which
754/// on pass 1 is equal to `pkg.version` and on pass 2+ carries the
755/// nested peer-context suffix. Used both for the root scope and for
756/// each importer's own scope inside `apply_peer_contexts_once`.
757fn scope_map_from_deps(deps: &[DirectDep]) -> FxHashMap<String, String> {
758    let mut out = FxHashMap::with_capacity_and_hasher(deps.len(), Default::default());
759    for d in deps {
760        let prefix_len = d.name.len() + 1;
761        let tail = if d.dep_path.len() > prefix_len
762            && d.dep_path.as_bytes().get(d.name.len()) == Some(&b'@')
763            && d.dep_path.as_bytes().starts_with(d.name.as_bytes())
764        {
765            d.dep_path[prefix_len..].to_string()
766        } else {
767            d.dep_path.clone()
768        };
769        out.insert(d.name.clone(), tail);
770    }
771    out
772}
773
774fn strip_hashed_peer_suffix(s: &str) -> &str {
775    const MARKER_LEN: usize = 11; // `_` + 10 hex chars
776    if s.len() < MARKER_LEN {
777        return s;
778    }
779    let tail = &s[s.len() - MARKER_LEN..];
780    if !tail.starts_with('_') {
781        return s;
782    }
783    if tail[1..]
784        .chars()
785        .all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c))
786    {
787        &s[..s.len() - MARKER_LEN]
788    } else {
789        s
790    }
791}
792
793/// Hash a peer-ID suffix with SHA-256 and return `_<10-char-hex>`.
794/// Used by the peer-context pass when the raw suffix length exceeds
795/// `peersSuffixMaxLength`. Matches pnpm's format so lockfile dep_path
796/// keys stay portable.
797pub(crate) fn hash_peer_suffix(suffix: &str) -> String {
798    use sha2::{Digest, Sha256};
799    let digest = Sha256::digest(suffix.as_bytes());
800    let mut out = String::with_capacity(11);
801    out.push('_');
802    for byte in digest.iter().take(5) {
803        use std::fmt::Write;
804        let _ = write!(out, "{byte:02x}");
805    }
806    out
807}
808
809pub(crate) fn contains_canonical_back_ref(value: &str, canonical: &str) -> bool {
810    let bytes = value.as_bytes();
811    let target = canonical.as_bytes();
812    if target.is_empty() || target.len() > bytes.len() {
813        return false;
814    }
815    let mut i = 0;
816    while i + target.len() <= bytes.len() {
817        if &bytes[i..i + target.len()] == target {
818            let before = if i == 0 { b'\0' } else { bytes[i - 1] };
819            let after = bytes.get(i + target.len()).copied().unwrap_or(b'\0');
820            let before_ok = before == b'(';
821            let after_ok = after == b'(' || after == b')' || after == b'\0';
822            if before_ok && after_ok {
823                return true;
824            }
825        }
826        i += 1;
827    }
828    false
829}
830
831/// Split a dep_path tail's peer suffix into outer-level paren segments
832/// (each ending in a balanced `)`). Returns each segment with its parens
833/// included — `react-dom@18.2.0(react@18.2.0)(scheduler@1.0.0)` yields
834/// `["(react@18.2.0)", "(scheduler@1.0.0)"]`; nested forms like
835/// `consumer@1.0.0(react-dom@18.2.0(react@18.2.0))` yield the single
836/// segment `["(react-dom@18.2.0(react@18.2.0))"]` with the inner
837/// `(react@18.2.0)` preserved verbatim inside it.
838///
839/// Used by `propagate_peer_suffixes_to_ancestors` to lift a child's
840/// peer segments onto its non-peer-declaring ancestors.
841fn outer_paren_segments(s: &str) -> Vec<&str> {
842    let bytes = s.as_bytes();
843    let mut segments = Vec::new();
844    let mut i = 0;
845    // Skip canonical `name@version` head — anything up to the first `(`.
846    while i < bytes.len() && bytes[i] != b'(' {
847        i += 1;
848    }
849    while i < bytes.len() {
850        if bytes[i] != b'(' {
851            i += 1;
852            continue;
853        }
854        let start = i;
855        let mut depth: i32 = 0;
856        while i < bytes.len() {
857            match bytes[i] {
858                b'(' => depth += 1,
859                b')' => {
860                    depth -= 1;
861                    if depth == 0 {
862                        i += 1;
863                        segments.push(&s[start..i]);
864                        break;
865                    }
866                }
867                _ => {}
868            }
869            i += 1;
870        }
871        if depth != 0 {
872            // Unbalanced — bail out of further segmenting. Shouldn't
873            // happen on output of `apply_peer_contexts_once`, where every
874            // suffix segment is balanced by construction.
875            break;
876        }
877    }
878    segments
879}
880
881/// Extract the peer name from a paren segment like `(@scope/name@1.2.3)`
882/// or `(name@1.2.3(nested@9.9.9))`. The peer name is everything between
883/// the opening `(` and the LAST `@` that occurs before any nested `(`.
884/// Scoped packages contain two `@`s (`@scope/name@version`) and we want
885/// the rightmost outer one.
886///
887/// Returns `None` if the segment doesn't start with `(` or has no
888/// usable `@` separator.
889fn peer_name_from_segment(seg: &str) -> Option<&str> {
890    let inner = seg.strip_prefix('(')?;
891    // Scan for the last `@` that occurs before any `(` (the version-or-
892    // nested boundary). For a flat segment `name@version` everything
893    // between `(` and the last `@` is the name; for a nested segment
894    // `name@version(inner)` the last `@` BEFORE the first inner `(` is
895    // the boundary. We search up to the first `(` (or end-of-string).
896    let scan_end = inner.find('(').unwrap_or(inner.len());
897    let head = &inner[..scan_end];
898    head.rfind('@').map(|idx| &head[..idx])
899}
900
901/// Collect every peer name reachable from a set of outer-paren segments,
902/// recursing into nested `(name@version(...))` forms so that a self
903/// segment like `(helper@1.0.0(core@1.0.0))` reports both `helper` and
904/// `core`. Used by `propagate_peer_suffixes_to_ancestors` to suppress
905/// flat-segment additions for peer names already encoded transitively
906/// in a package's own (possibly nested) self-suffix.
907fn peer_names_in_segments_recursive(segments: &[&str]) -> BTreeSet<String> {
908    let mut names = BTreeSet::new();
909    for seg in segments {
910        if let Some(name) = peer_name_from_segment(seg) {
911            names.insert(name.to_string());
912        }
913        // Recurse into the nested portion (everything after the first
914        // inner `(` and before the final `)`).
915        let Some(inner) = seg.strip_prefix('(').and_then(|s| s.strip_suffix(')')) else {
916            continue;
917        };
918        if let Some(open) = inner.find('(') {
919            let nested = &inner[open..];
920            let nested_segments = outer_paren_segments(nested);
921            for nested_name in peer_names_in_segments_recursive(&nested_segments) {
922                names.insert(nested_name);
923            }
924        }
925    }
926    names
927}
928
929/// Walk the resolved graph from each node and accumulate the union of
930/// peer-suffix segments contributed by self + every reachable
931/// descendant (gated on the package having no declared peers of its
932/// own), then rewrite each node's dep_path to embed that union.
933///
934/// Why: pnpm's lockfile shape tags non-peer-declaring intermediaries
935/// with the same `(peer@version)` suffix their peer-declaring
936/// descendants produced — so a parent that pulls in a peer-bearing
937/// child carries the resolved peer set on its own dep_path. aube's
938/// `apply_peer_contexts_once` only emits the suffix on the package
939/// that *declares* the peer; without this post-pass an importer row
940/// for `parent → leaf(peer)` would render `parent: 1.0.0` (no
941/// suffix) where pnpm renders `parent: 1.0.0(peer@v)`.
942///
943/// pnpm-parity gate (inferred from observed lockfile shape): **a
944/// package gets descendant-peer propagation only if its own
945/// `peerDependencies` map is empty.** Packages that declare their
946/// own peers have an authoritative self-suffix encoding exactly the
947/// peers they care about; descendant peers don't bubble through
948/// because the descendant peers belong to a NESTED child, which the
949/// snapshot already encodes via the nested-tail form (see
950/// `apply_peer_contexts_once`'s nested-suffix handling). Two
951/// observable shapes this gate lines up with:
952///   - `@testing-library/react@14.0.0(react@18.2.0)(react-dom@18.2.0(react@18.2.0))`
953///     — declares peers, gets self-suffix only; `@types/react` from a
954///     descendant doesn't bubble up.
955///   - `abc-parent-with-missing-peers@1.0.0(peer-a@…)(peer-b@…)(peer-c@…)`
956///     — no declared peers, picks up descendant peers from `abc`.
957///
958/// Algorithm:
959///  1. Build a forward dep map: `pkg_key → [child_key]` from each
960///     LockedPackage's `dependencies`.
961///  2. Memoized DFS. For each node, compute
962///     `cumulative_segments = outer_paren_segments(node.key)`. If the
963///     node has no declared peers, also union in
964///     `⋃ cumulative(child)` (gated by the rule above).
965///  3. Cycles short-circuit via a `visiting` guard — cycle members
966///     can't add new peers from each other beyond what reaches them
967///     through non-cycle paths, so returning the empty set on
968///     re-entry is safe (the non-cycle entry path computes the full
969///     set).
970///  4. Dedupe by peer name. Suppressed names: every peer name reachable
971///     transitively in self-segments (so `(helper@1(core@1))` covers
972///     `core` and a flat `(core@1)` from descendants is dropped) plus
973///     the package's own canonical name (mutual-peer cycle break).
974///  5. Build a rewrite map `old_key → new_key` and apply to package
975///     keys, dep edges (each dep's stored tail), and importer
976///     dep_paths.
977fn propagate_peer_suffixes_to_ancestors(
978    graph: LockfileGraph,
979    options: &PeerContextOptions,
980) -> LockfileGraph {
981    // Forward dep map. Edges that don't resolve to a present package
982    // (e.g. an unresolved peer that `detect_unmet_peers` will warn
983    // about) are dropped — they can't contribute cumulative peers.
984    let mut forward: BTreeMap<String, Vec<String>> = BTreeMap::new();
985    // Per-package "has declared peers" lookup. Packages that declare
986    // their own peers don't accept descendant-peer propagation (see
987    // the rule in the doc comment above).
988    let mut has_own_peers: BTreeMap<String, bool> = BTreeMap::new();
989    for (key, pkg) in &graph.packages {
990        let children: Vec<String> = pkg
991            .dependencies
992            .iter()
993            .map(|(n, t)| format!("{n}@{t}"))
994            .filter(|k| graph.packages.contains_key(k))
995            .collect();
996        forward.insert(key.clone(), children);
997        has_own_peers.insert(key.clone(), !pkg.peer_dependencies.is_empty());
998    }
999
1000    // Memoized DFS. `cumulative` stores the by-name segment map per
1001    // package key; `visiting` is the cycle-break stack.
1002    let mut cumulative: BTreeMap<String, BTreeMap<String, String>> = BTreeMap::new();
1003    let mut visiting: BTreeSet<String> = BTreeSet::new();
1004
1005    fn collect(
1006        key: &str,
1007        forward: &BTreeMap<String, Vec<String>>,
1008        has_own_peers: &BTreeMap<String, bool>,
1009        cumulative: &mut BTreeMap<String, BTreeMap<String, String>>,
1010        visiting: &mut BTreeSet<String>,
1011    ) -> BTreeMap<String, String> {
1012        if let Some(c) = cumulative.get(key) {
1013            return c.clone();
1014        }
1015        if !visiting.insert(key.to_string()) {
1016            // Cycle: contribute nothing. Whichever cycle member is
1017            // first reached from outside the cycle will compute the
1018            // full set; the visit guard cap on the others prevents
1019            // infinite recursion. Edge case: a fully-isolated cycle
1020            // never gets a non-cycle entry, in which case all members
1021            // compute empty cumulatives — that's identical to their
1022            // canonical state, so they get no rewrite. Acceptable.
1023            return BTreeMap::new();
1024        }
1025
1026        // Self-suffix segments. Each segment becomes one (name → segment)
1027        // entry. Nested segments like `(react-dom@18.2.0(react@18.2.0))`
1028        // are preserved as a single segment with the nested form intact.
1029        let self_segments = outer_paren_segments(key);
1030        let mut acc: BTreeMap<String, String> = BTreeMap::new();
1031        for seg in &self_segments {
1032            if let Some(name) = peer_name_from_segment(seg) {
1033                acc.entry(name.to_string())
1034                    .or_insert_with(|| seg.to_string());
1035            }
1036        }
1037
1038        // Pnpm-parity gate: only packages with no declared peers absorb
1039        // descendant-peer propagation. The cycle-break visiting guard
1040        // is still released for symmetry with the non-gated branch.
1041        if has_own_peers.get(key).copied().unwrap_or(false) {
1042            visiting.remove(key);
1043            cumulative.insert(key.to_string(), acc.clone());
1044            return acc;
1045        }
1046
1047        // Names suppressed when merging child contributions:
1048        //   1. Every peer name reachable transitively in self segments —
1049        //      e.g. a self segment `(helper@1.0.0(core@1.0.0))` covers
1050        //      both `helper` and `core`, so a descendant flat-listing
1051        //      `(core@1.0.0)` shouldn't double-emit. Pnpm lists each
1052        //      peer name once; we match.
1053        //   2. The package's own canonical name — for mutual-peer
1054        //      cycles `a` peers on `b` and `b` peers on `a`, the
1055        //      descendant set lifts `(a@…)` back up onto `a` itself,
1056        //      which would write `a@1.0.0(a@…)(b@…)`. Self-listing
1057        //      isn't valid pnpm shape; suppress it. (Reachable here
1058        //      only when this branch handles a node with no declared
1059        //      peers — but defensive in case future graph shapes
1060        //      surface a self-cycle through a peer-less node.)
1061        let canonical_name = canonical_tail(key)
1062            .rsplit_once('@')
1063            .map(|(name, _ver)| name.to_string())
1064            .unwrap_or_default();
1065        let mut suppressed: BTreeSet<String> = peer_names_in_segments_recursive(&self_segments);
1066        if !canonical_name.is_empty() {
1067            suppressed.insert(canonical_name);
1068        }
1069
1070        // Child contributions.
1071        if let Some(children) = forward.get(key) {
1072            for child in children {
1073                let child_peers = collect(child, forward, has_own_peers, cumulative, visiting);
1074                for (name, seg) in child_peers {
1075                    if suppressed.contains(&name) {
1076                        continue;
1077                    }
1078                    acc.entry(name).or_insert(seg);
1079                }
1080            }
1081        }
1082        visiting.remove(key);
1083        cumulative.insert(key.to_string(), acc.clone());
1084        acc
1085    }
1086
1087    // Compute cumulative for every package + every importer DirectDep
1088    // root. Done in stable order so the lex-smaller old-key tiebreaker
1089    // below is deterministic.
1090    let pkg_keys: Vec<String> = graph.packages.keys().cloned().collect();
1091    for key in &pkg_keys {
1092        collect(
1093            key,
1094            &forward,
1095            &has_own_peers,
1096            &mut cumulative,
1097            &mut visiting,
1098        );
1099    }
1100    for deps in graph.importers.values() {
1101        for dep in deps {
1102            collect(
1103                &dep.dep_path,
1104                &forward,
1105                &has_own_peers,
1106                &mut cumulative,
1107                &mut visiting,
1108            );
1109        }
1110    }
1111
1112    // Build rewrite map. A package's new key is its canonical_base
1113    // (`name@version`) plus the cumulative segments concatenated in
1114    // peer-name lex order — same order `apply_peer_contexts_once`
1115    // already produces for self segments, so when a package's
1116    // cumulative is identical to its self set the rewrite is a no-op
1117    // and we skip it.
1118    //
1119    // Hashed-suffix keys (`name@version_<10hex>`, produced when a
1120    // package's own peer suffix exceeded `peersSuffixMaxLength`) are
1121    // left untouched. The hash form discards the textual peer set
1122    // by design — `outer_paren_segments` can't recover its
1123    // contribution, so any rewrite we built for it would either drop
1124    // the hash entirely (losing identity) or merge an incomplete
1125    // descendant set with the hashed self. Preserving the original
1126    // form is the conservative choice; pnpm's parity gap in that
1127    // regime is bounded by the hash collision space anyway.
1128    //
1129    // If the propagated suffix itself exceeds the cap, hash it the
1130    // same way `visit_peer_context` does for self suffixes — keeps
1131    // dep_path keys bounded across the whole graph.
1132    let mut rewrite: BTreeMap<String, String> = BTreeMap::new();
1133    for key in &pkg_keys {
1134        let Some(segments) = cumulative.get(key) else {
1135            continue;
1136        };
1137        let original_tail = canonical_tail(key);
1138        let canonical = strip_hashed_peer_suffix(original_tail);
1139        if canonical.len() != original_tail.len() {
1140            // Original key already has the hashed marker. Skip — see
1141            // comment above.
1142            continue;
1143        }
1144        let suffix: String = segments.values().cloned().collect();
1145        let effective_suffix = if suffix.len() > options.peers_suffix_max_length {
1146            hash_peer_suffix(&suffix)
1147        } else {
1148            suffix
1149        };
1150        let new_key = format!("{canonical}{effective_suffix}");
1151        if new_key != *key {
1152            rewrite.insert(key.clone(), new_key);
1153        }
1154    }
1155
1156    if rewrite.is_empty() {
1157        return graph;
1158    }
1159
1160    // Helper: rewrite a `dependencies` tail (the part after `name@`).
1161    // Reconstruct the target's old full key, look up its rewrite, and
1162    // strip the `name@` prefix off the result to recover the new tail.
1163    // Targets without a rewrite keep the original tail.
1164    let rewrite_tail = |child_name: &str, tail: &str| -> String {
1165        let old_key = format!("{child_name}@{tail}");
1166        match rewrite.get(&old_key) {
1167            Some(new_key) => new_key
1168                .strip_prefix(&format!("{child_name}@"))
1169                .map(|s| s.to_string())
1170                .unwrap_or_else(|| tail.to_string()),
1171            None => tail.to_string(),
1172        }
1173    };
1174
1175    let LockfileGraph {
1176        importers,
1177        packages,
1178        settings,
1179        overrides,
1180        ignored_optional_dependencies,
1181        times,
1182        skipped_optional_dependencies,
1183        catalogs,
1184        bun_config_version,
1185        patched_dependencies,
1186        trusted_dependencies,
1187        runtimes,
1188        extra_fields,
1189        workspace_extra_fields,
1190    } = graph;
1191
1192    let mut new_packages: BTreeMap<String, LockedPackage> = BTreeMap::new();
1193    for (old_key, mut pkg) in packages {
1194        let new_key = rewrite.get(&old_key).cloned().unwrap_or(old_key);
1195        for (name, tail) in pkg.dependencies.iter_mut() {
1196            *tail = rewrite_tail(name, tail);
1197        }
1198        for (name, tail) in pkg.optional_dependencies.iter_mut() {
1199            *tail = rewrite_tail(name, tail);
1200        }
1201        pkg.dep_path = new_key.clone();
1202        // Two old keys mapping to one new key: the lex-smaller old key
1203        // wins. Because `packages` is a `BTreeMap` we iterate
1204        // `(old_key, pkg)` pairs in lex order — the first insertion
1205        // for any given `new_key` is therefore the one whose old_key
1206        // sorts lowest, and `or_insert` makes every subsequent
1207        // collision a no-op. Bodies are equal in the common case
1208        // anyway (same canonical_base + same cumulative ⇒ same dep
1209        // tree), so this is effectively cosmetic determinism.
1210        new_packages.entry(new_key).or_insert(pkg);
1211    }
1212
1213    let new_importers: BTreeMap<String, Vec<DirectDep>> = importers
1214        .into_iter()
1215        .map(|(path, deps)| {
1216            let rewritten = deps
1217                .into_iter()
1218                .map(|d| {
1219                    let new_dep_path = rewrite.get(&d.dep_path).cloned().unwrap_or(d.dep_path);
1220                    DirectDep {
1221                        name: d.name,
1222                        dep_path: new_dep_path,
1223                        dep_type: d.dep_type,
1224                        specifier: d.specifier,
1225                    }
1226                })
1227                .collect();
1228            (path, rewritten)
1229        })
1230        .collect();
1231
1232    LockfileGraph {
1233        importers: new_importers,
1234        packages: new_packages,
1235        settings,
1236        overrides,
1237        ignored_optional_dependencies,
1238        times,
1239        skipped_optional_dependencies,
1240        catalogs,
1241        bun_config_version,
1242        patched_dependencies,
1243        trusted_dependencies,
1244        runtimes,
1245        extra_fields,
1246        workspace_extra_fields,
1247    }
1248}
1249
1250/// Dedupe-peers post-pass: strip the `name@` prefix from every
1251/// parenthesized peer segment in every dep_path key and reference,
1252/// turning `react-dom@18.2.0(react@18.2.0)` into
1253/// `react-dom@18.2.0(18.2.0)`. Nested segments get the same treatment
1254/// so `a@1(b@2(c@3))` becomes `a@1(2(3))`.
1255///
1256/// Running this as a final post-pass (instead of inline during suffix
1257/// assembly in `visit_peer_context`) keeps cycle detection correct:
1258/// the detection path works against the full `name@version` form
1259/// throughout the fixed-point loop, and only the serialized output
1260/// gets the shorter form. A version-only inline approach would
1261/// false-positive on unrelated packages that coincidentally share a
1262/// version with the current package's canonical base.
1263///
1264/// Pure: no-op when `dedupe_peers` is off (caller gates the call);
1265/// otherwise rewrites every package key, every `LockedPackage.dep_path`
1266/// and `LockedPackage.dependencies` value, and every `importers[*]`
1267/// DirectDep `dep_path` through the same `apply_dedupe_peers_to_tail`
1268/// helper. Package bodies (integrity, metadata, etc.) are cloned
1269/// verbatim.
1270pub(crate) fn dedupe_peer_suffixes(graph: LockfileGraph) -> LockfileGraph {
1271    // Pass 1: compute the intended deduped key for each package and
1272    // tally how many distinct full-form keys map to it. Stripping
1273    // `name@` from suffix segments is lossy — two variants whose peer
1274    // *names* differ but whose peer *versions* coincide would collapse
1275    // onto the same deduped key (e.g. `consumer@1.0.0(foo@1.0.0)` and
1276    // `consumer@1.0.0(bar@1.0.0)` both → `consumer@1.0.0(1.0.0)`).
1277    // `dedupe_peer_variants` already merged the peer-equivalent
1278    // duplicates, so any remaining collision here represents genuinely
1279    // distinct variants — losing one would silently drop its
1280    // dependency wiring. We detect those collisions and keep both
1281    // sides in full form.
1282    let mut target_counts: BTreeMap<String, usize> = BTreeMap::new();
1283    let mut intended: BTreeMap<String, String> = BTreeMap::new();
1284    for key in graph.packages.keys() {
1285        let new_key = apply_dedupe_peers_to_key(key);
1286        *target_counts.entry(new_key.clone()).or_insert(0) += 1;
1287        intended.insert(key.clone(), new_key);
1288    }
1289    let rewrite: BTreeMap<String, String> = intended
1290        .into_iter()
1291        .map(|(old, new)| {
1292            if target_counts.get(&new).copied().unwrap_or(0) > 1 {
1293                tracing::warn!(
1294                    code = aube_codes::warnings::WARN_AUBE_PEER_DEDUPE_COLLISION,
1295                    "dedupe-peers: collision on {new} — keeping {old} in full form to avoid \
1296                     dropping a distinct peer-variant"
1297                );
1298                (old.clone(), old)
1299            } else {
1300                (old, new)
1301            }
1302        })
1303        .collect();
1304
1305    // Rewrite a `(child_name, tail)` reference by reconstructing the
1306    // target's full-form key, looking up its effective rewrite, and
1307    // stripping `child_name@` off the result to recover the tail.
1308    // Tails always follow their target package's rewrite decision,
1309    // so references stay consistent when a collision forces a target
1310    // back to full form.
1311    let rewrite_tail = |child_name: &str, tail: &str| -> String {
1312        let old_key = format!("{child_name}@{tail}");
1313        match rewrite.get(&old_key) {
1314            Some(new_key) => new_key
1315                .strip_prefix(&format!("{child_name}@"))
1316                .map(|s| s.to_string())
1317                .unwrap_or_else(|| tail.to_string()),
1318            None => apply_dedupe_peers_to_tail(tail),
1319        }
1320    };
1321
1322    let mut new_packages: BTreeMap<String, LockedPackage> = BTreeMap::new();
1323    for (old_key, pkg) in graph.packages {
1324        let new_key = rewrite
1325            .get(&old_key)
1326            .cloned()
1327            .unwrap_or_else(|| old_key.clone());
1328        let new_dependencies: BTreeMap<String, String> = pkg
1329            .dependencies
1330            .into_iter()
1331            .map(|(n, v)| {
1332                let new_v = rewrite_tail(&n, &v);
1333                (n, new_v)
1334            })
1335            .collect();
1336        let new_optional_dependencies: BTreeMap<String, String> = pkg
1337            .optional_dependencies
1338            .into_iter()
1339            .map(|(n, v)| {
1340                let new_v = rewrite_tail(&n, &v);
1341                (n, new_v)
1342            })
1343            .collect();
1344        new_packages.insert(
1345            new_key.clone(),
1346            LockedPackage {
1347                name: pkg.name,
1348                version: pkg.version,
1349                integrity: pkg.integrity,
1350                dependencies: new_dependencies,
1351                optional_dependencies: new_optional_dependencies,
1352                peer_dependencies: pkg.peer_dependencies,
1353                peer_dependencies_meta: pkg.peer_dependencies_meta,
1354                dep_path: new_key,
1355                local_source: pkg.local_source,
1356                os: pkg.os,
1357                cpu: pkg.cpu,
1358                libc: pkg.libc,
1359                bundled_dependencies: pkg.bundled_dependencies,
1360                optional: pkg.optional,
1361                transitive_peer_dependencies: pkg.transitive_peer_dependencies,
1362                tarball_url: pkg.tarball_url,
1363                registry_git_hosted: pkg.registry_git_hosted,
1364                alias_of: pkg.alias_of,
1365                yarn_checksum: pkg.yarn_checksum,
1366                engines: pkg.engines,
1367                bin: pkg.bin,
1368                declared_dependencies: pkg.declared_dependencies,
1369                license: pkg.license,
1370                funding_url: pkg.funding_url,
1371                extra_meta: pkg.extra_meta,
1372            },
1373        );
1374    }
1375
1376    let new_importers: BTreeMap<String, Vec<DirectDep>> = graph
1377        .importers
1378        .into_iter()
1379        .map(|(path, deps)| {
1380            let rewritten = deps
1381                .into_iter()
1382                .map(|d| {
1383                    let new_dep_path = rewrite
1384                        .get(&d.dep_path)
1385                        .cloned()
1386                        .unwrap_or_else(|| apply_dedupe_peers_to_key(&d.dep_path));
1387                    DirectDep {
1388                        name: d.name,
1389                        dep_path: new_dep_path,
1390                        dep_type: d.dep_type,
1391                        specifier: d.specifier,
1392                    }
1393                })
1394                .collect();
1395            (path, rewritten)
1396        })
1397        .collect();
1398
1399    LockfileGraph {
1400        importers: new_importers,
1401        packages: new_packages,
1402        settings: graph.settings,
1403        overrides: graph.overrides,
1404        ignored_optional_dependencies: graph.ignored_optional_dependencies,
1405        runtimes: graph.runtimes,
1406        times: graph.times,
1407        skipped_optional_dependencies: graph.skipped_optional_dependencies,
1408        catalogs: graph.catalogs,
1409        bun_config_version: graph.bun_config_version,
1410        patched_dependencies: graph.patched_dependencies,
1411        trusted_dependencies: graph.trusted_dependencies,
1412        extra_fields: graph.extra_fields,
1413        workspace_extra_fields: graph.workspace_extra_fields,
1414    }
1415}
1416
1417/// Strip `name@` from inside every parenthesized segment of a full
1418/// dep_path key (e.g. `react-dom@18.2.0(react@18.2.0)` →
1419/// `react-dom@18.2.0(18.2.0)`). The first `name@version` outside any
1420/// parens is preserved verbatim — that's the canonical head of the
1421/// dep_path and `dedupe-peers` only affects the peer suffix.
1422pub(crate) fn apply_dedupe_peers_to_key(key: &str) -> String {
1423    let mut parts = key.split('(');
1424    let Some(first) = parts.next() else {
1425        return key.to_string();
1426    };
1427    let mut out = String::with_capacity(key.len());
1428    out.push_str(first);
1429    for part in parts {
1430        out.push('(');
1431        // In a well-formed key, `part` looks like `name@version)` /
1432        // `name@version` / `version)` / ... We strip everything up to
1433        // and including the LAST `@` (scoped packages like
1434        // `@types/react@18.2.0` contain two `@`s; the separator is the
1435        // rightmost one). We only strip if that `@` comes before the
1436        // first `)` or `(` (i.e. the segment actually starts with
1437        // `name@`, not the outer parens closing with no name inside).
1438        if let Some(at_idx) = part.rfind('@') {
1439            let close_idx = part.find([')', '(']).unwrap_or(usize::MAX);
1440            if at_idx < close_idx {
1441                out.push_str(&part[at_idx + 1..]);
1442                continue;
1443            }
1444        }
1445        out.push_str(part);
1446    }
1447    out
1448}
1449
1450/// Same as [`apply_dedupe_peers_to_key`] but for dep-tail values
1451/// stored in `LockedPackage.dependencies` (e.g. `18.2.0(react@18.2.0)`
1452/// → `18.2.0(18.2.0)`). Tails differ from keys only by lacking the
1453/// leading `name@` prefix — both use the same parens-based suffix
1454/// shape, so the algorithm is identical.
1455fn apply_dedupe_peers_to_tail(tail: &str) -> String {
1456    apply_dedupe_peers_to_key(tail)
1457}
1458
1459#[allow(clippy::too_many_arguments)]
1460fn visit_peer_context<'g>(
1461    input_dep_path: &str,
1462    graph: &'g LockfileGraph,
1463    name_index: &FxHashMap<&'g str, Vec<&'g LockedPackage>>,
1464    ancestor_scope: &FxHashMap<String, String>,
1465    root_scope: &FxHashMap<String, String>,
1466    out_packages: &mut BTreeMap<String, LockedPackage>,
1467    visiting: &mut FxHashSet<String>,
1468    options: &PeerContextOptions,
1469) -> Option<String> {
1470    let pkg = graph.packages.get(input_dep_path)?;
1471
1472    // The input key may already carry a peer suffix (fixed-point loop
1473    // Pass 2+). Drop it before we build a new one — otherwise we'd
1474    // append the new suffix on top of the old and grow unboundedly
1475    // across iterations (classic mutual-peer-cycle blow-up).
1476    //
1477    // Two suffix forms can be present from a prior pass:
1478    //   1. `(name@version)(…)` — the normal nested peer suffix. Stripped
1479    //      by splitting on the first `(`.
1480    //   2. `_<10-char-sha256-hex>` — the hashed form produced when the
1481    //      normal suffix exceeded `peersSuffixMaxLength`. Must also be
1482    //      stripped; otherwise each pass re-hashes the already-hashed
1483    //      key and appends another marker (exposed by the
1484    //      `peer_suffix_is_hashed_when_exceeding_cap` unit test).
1485    let canonical_base = canonical_tail(input_dep_path);
1486    let canonical_base = strip_hashed_peer_suffix(canonical_base).to_string();
1487
1488    // Compute peer context: walk declared peers, resolve from ancestors
1489    // (nearest wins — the scope is rebuilt as we recurse) or from the
1490    // package's own dependency map as the auto-install fallback. Both
1491    // sides may produce nested tails on the second and later iterations
1492    // of the fixed-point loop.
1493    // Resolution source priority for each declared peer:
1494    //   1. Ancestor scope — if the ancestor's version actually
1495    //      satisfies the declared peer range. Different subtrees
1496    //      naturally see different ancestors (lib-a in subtree-A
1497    //      and lib-b in subtree-B keep their own peer pins), so
1498    //      preferring the closest ancestor here doesn't conflate
1499    //      cross-subtree variants.
1500    //   2. The current package's own `pkg.dependencies` entry — the
1501    //      BFS peer-walk enqueued this peer with the declared range,
1502    //      so whatever got picked there is guaranteed to satisfy.
1503    //      Captures the case where a single subtree holds two
1504    //      consumers with conflicting peer ranges (lib-a@^17 next to
1505    //      a parent that pins react@18): the BFS auto-installs the
1506    //      satisfying version into lib-a's own deps, which beats the
1507    //      ancestor's incompatible version.
1508    //   3. Ancestor scope — even when the version doesn't satisfy
1509    //      the declared range. This mirrors what Node's module
1510    //      resolution would surface (`require('peer')` from the
1511    //      package would walk up node_modules and find the parent's
1512    //      version). pnpm and bun do the same and emit an unmet-peer
1513    //      warning rather than picking a more-distant matching
1514    //      version. `detect_unmet_peers` flags the mismatch after
1515    //      the pass.
1516    //   4. The current package's own `pkg.dependencies` entry,
1517    //      ignoring range satisfaction — symmetric to (3) for the
1518    //      BFS-installed case.
1519    //   5. Workspace root scope (compatible) — `resolve-peers-from-
1520    //      workspace-root` fallback for monorepos that pin shared
1521    //      peers at the root.
1522    //   6. A graph-wide scan: any package whose name matches and
1523    //      whose version satisfies the declared range. Last resort
1524    //      for nested-context callers when nothing closer has it.
1525    //   7. Workspace root scope, ignoring range satisfaction.
1526    //
1527    // If nothing in the graph holds a version of this peer at all,
1528    // it's left out of the context entirely — `detect_unmet_peers`
1529    // will surface it as a warning after the pass.
1530    let mut peer_context: Vec<(String, String)> = Vec::new();
1531    for (peer_name, declared_range) in &pkg.peer_dependencies {
1532        let satisfies_declared = |v: &str| -> bool {
1533            // The tail may carry a nested peer suffix on fixed-point
1534            // iterations 2+; strip it before checking the semver.
1535            let canonical = canonical_tail(v);
1536            version_satisfies(canonical, declared_range)
1537        };
1538
1539        let from_ancestor = ancestor_scope
1540            .get(peer_name)
1541            .filter(|v| satisfies_declared(v))
1542            .cloned();
1543        let from_ancestor_incompatible = ancestor_scope.get(peer_name).cloned();
1544
1545        let from_pkg_deps = pkg
1546            .dependencies
1547            .get(peer_name)
1548            .filter(|v| satisfies_declared(v))
1549            .cloned();
1550        let from_pkg_deps_incompatible = pkg.dependencies.get(peer_name).cloned();
1551
1552        // `resolve-peers-from-workspace-root`: fall back to the root
1553        // importer's direct deps before the graph-wide scan. Common in
1554        // monorepos where the workspace root pins shared peers (e.g.
1555        // `react`) that leaf packages peer on without declaring them
1556        // in their own subtree. Skipped when the setting is off —
1557        // matches pnpm's `resolve-peers-from-workspace-root=false`.
1558        let from_root = if options.resolve_from_workspace_root {
1559            root_scope
1560                .get(peer_name)
1561                .filter(|v| satisfies_declared(v))
1562                .cloned()
1563        } else {
1564            None
1565        };
1566        let from_root_incompatible = if options.resolve_from_workspace_root {
1567            root_scope.get(peer_name).cloned()
1568        } else {
1569            None
1570        };
1571
1572        // Return the full dep_path TAIL (the part after `name@`), not
1573        // just `p.version`. On fixed-point iteration 2+, the input
1574        // graph's keys are contextualized — e.g. `react-dom` lives at
1575        // `react-dom@18.2.0(react@18.2.0)`. Downstream code
1576        // reconstructs the child lookup key with
1577        // `format!("{child_name}@{tail}")` and needs the tail to
1578        // match whatever the graph has keyed it under, otherwise the
1579        // lookup returns None and the peer gets silently dropped
1580        // from `new_dependencies`. The semver check is against the
1581        // package's canonical `version` field, not the tail, because
1582        // the tail may carry a peer suffix that isn't valid semver.
1583        let from_graph_scan = || {
1584            name_index
1585                .get(peer_name.as_str())
1586                .into_iter()
1587                .flat_map(|bucket| bucket.iter().copied())
1588                .filter(|p| version_satisfies(&p.version, declared_range))
1589                .filter_map(|p| {
1590                    let tail = p
1591                        .dep_path
1592                        .strip_prefix(&format!("{}@", p.name))
1593                        .map(|s| s.to_string())
1594                        .unwrap_or_else(|| p.version.clone());
1595                    node_semver::Version::parse(&p.version)
1596                        .ok()
1597                        .map(|ver| (ver, tail))
1598                })
1599                .max_by(|a, b| a.0.cmp(&b.0))
1600                .map(|(_, tail)| tail)
1601        };
1602
1603        if let Some(version) = from_ancestor
1604            .or(from_pkg_deps)
1605            .or(from_ancestor_incompatible)
1606            .or(from_pkg_deps_incompatible)
1607            .or(from_root)
1608            .or_else(from_graph_scan)
1609            .or(from_root_incompatible)
1610        {
1611            peer_context.push((peer_name.clone(), version));
1612        }
1613    }
1614    peer_context.sort_by(|a, b| a.0.cmp(&b.0));
1615
1616    // For the SUFFIX we build a cycle-broken copy: any peer value that
1617    // nests a reference back to the current package's canonical base
1618    // gets stripped to its plain version. Without this, mutual peer
1619    // cycles (a peers on b, b peers on a) grow the suffix one level
1620    // per iteration of the fixed-point loop and never converge.
1621    //
1622    // The non-cycle paths are untouched, so a regular nested chain
1623    // like `(react-dom@18.2.0(react@18.2.0))` still serializes fully.
1624    // We deliberately keep the full nested tails in `peer_context` for
1625    // downstream scope propagation and child lookups — suffix cycle-
1626    // breaking is cosmetic and should not change what packages exist
1627    // or which snapshot entries reference each other.
1628    //
1629    // Cycle detection is always done against the full `name@version`
1630    // canonical base — even when `dedupe-peers=true` is on, because
1631    // the version-only form is ambiguous (two unrelated packages at
1632    // the same version would false-positive). `dedupe-peers` is
1633    // applied as a post-pass over the final graph in
1634    // `dedupe_peer_suffixes` after cycle detection is done.
1635    let suffix: String = peer_context
1636        .iter()
1637        .map(|(n, v)| {
1638            let cycles_back = contains_canonical_back_ref(v, &canonical_base);
1639            let display_v = if cycles_back {
1640                canonical_tail(v).to_string()
1641            } else {
1642                v.clone()
1643            };
1644            format!("({n}@{display_v})")
1645        })
1646        .collect();
1647    // pnpm's `peersSuffixMaxLength`: when the built suffix exceeds the
1648    // cap, replace the entire suffix with `_<10-char-sha256-hex>` so the
1649    // lockfile key stays bounded. Matches pnpm's lockfile format, so
1650    // lockfiles shared between aube and pnpm stay comparable.
1651    let effective_suffix = if suffix.len() > options.peers_suffix_max_length {
1652        hash_peer_suffix(&suffix)
1653    } else {
1654        suffix
1655    };
1656    let contextualized = format!("{canonical_base}{effective_suffix}");
1657
1658    if out_packages.contains_key(&contextualized) || visiting.contains(&contextualized) {
1659        return Some(contextualized);
1660    }
1661    visiting.insert(contextualized.clone());
1662
1663    // Build the scope for P's children. This is ancestor_scope, overlaid
1664    // with P's own dependencies and its resolved peer map. Children see
1665    // their grandparents too — this mirrors pnpm's all-the-way-up peer
1666    // walk.
1667    //
1668    // We deliberately do NOT strip any existing peer-context suffix
1669    // off the tails we put into the scope. On the first pass the
1670    // values are plain (BFS output has no suffixes), so preserving
1671    // them is a no-op; on subsequent passes (see the fixed-point loop
1672    // in `apply_peer_contexts`) the input graph already carries
1673    // contextualized tails, and keeping them in scope is exactly how
1674    // nested peer suffixes propagate down to consumers — a package
1675    // that peers on `react-dom` and reaches it through a parent whose
1676    // `react-dom` entry is already `18.2.0(react@18.2.0)` will see
1677    // that nested tail in its own scope, and its own suffix will
1678    // serialize as `(react-dom@18.2.0(react@18.2.0))`. That's the
1679    // nested form pnpm writes.
1680    let mut child_scope = ancestor_scope.clone();
1681    for (name, version) in &pkg.dependencies {
1682        child_scope.insert(name.clone(), version.clone());
1683    }
1684    for (name, version) in &peer_context {
1685        child_scope.insert(name.clone(), version.clone());
1686    }
1687
1688    // Recurse into each child, rewriting its dependency map entry to
1689    // point at the contextualized dep_path's tail. A child whose visit
1690    // fails (orphaned / missing) keeps its own tail.
1691    //
1692    // For declared peer names, the peer context (filled from the
1693    // ancestor scope) is authoritative — we override whatever the BFS
1694    // peer walk auto-installed. Otherwise the snapshot suffix and the
1695    // actual wired `dependencies[peer]` could disagree, which made the
1696    // sibling symlink target inconsistent with the peer-context claim.
1697    // When the ancestor's version doesn't satisfy the declared range,
1698    // `detect_unmet_peers` will flag it as a warning after the pass.
1699    let peer_context_versions: FxHashMap<String, String> = peer_context.iter().cloned().collect();
1700
1701    let mut new_dependencies: BTreeMap<String, String> = BTreeMap::new();
1702    let mut visited_dep_names: FxHashSet<String> = FxHashSet::default();
1703
1704    for (child_name, child_version_tail) in &pkg.dependencies {
1705        // If this child is a declared peer, its tail comes from the
1706        // peer context (which may be nested). Otherwise we use the
1707        // tail we already have — also possibly nested on a 2nd pass.
1708        let lookup_tail = match peer_context_versions.get(child_name) {
1709            Some(v) => v.clone(),
1710            None => child_version_tail.clone(),
1711        };
1712        let child_canonical_dep_path = format!("{child_name}@{lookup_tail}");
1713        let child_new = visit_peer_context(
1714            &child_canonical_dep_path,
1715            graph,
1716            name_index,
1717            &child_scope,
1718            root_scope,
1719            out_packages,
1720            visiting,
1721            options,
1722        );
1723        let new_tail = match child_new {
1724            Some(new_dep_path) => new_dep_path
1725                .strip_prefix(&format!("{child_name}@"))
1726                .map(|s| s.to_string())
1727                .unwrap_or_else(|| lookup_tail.clone()),
1728            None => lookup_tail.clone(),
1729        };
1730        new_dependencies.insert(child_name.clone(), new_tail);
1731        visited_dep_names.insert(child_name.clone());
1732    }
1733
1734    // Peers that were satisfied purely from the ancestor scope may not
1735    // have been in `pkg.dependencies` at all (no auto-install needed).
1736    // Wire them as deps now so the linker creates the sibling symlink
1737    // and the lockfile snapshot records them.
1738    for (peer_name, peer_version) in &peer_context {
1739        if visited_dep_names.contains(peer_name) {
1740            continue;
1741        }
1742        let child_canonical_dep_path = format!("{peer_name}@{peer_version}");
1743        let child_new = visit_peer_context(
1744            &child_canonical_dep_path,
1745            graph,
1746            name_index,
1747            &child_scope,
1748            root_scope,
1749            out_packages,
1750            visiting,
1751            options,
1752        );
1753        if let Some(new_dep_path) = child_new {
1754            let new_tail = new_dep_path
1755                .strip_prefix(&format!("{peer_name}@"))
1756                .map(|s| s.to_string())
1757                .unwrap_or_else(|| peer_version.clone());
1758            new_dependencies.insert(peer_name.clone(), new_tail);
1759        }
1760    }
1761
1762    visiting.remove(&contextualized);
1763    let new_optional_dependencies: BTreeMap<String, String> = pkg
1764        .optional_dependencies
1765        .keys()
1766        .filter_map(|name| {
1767            new_dependencies
1768                .get(name)
1769                .map(|tail| (name.clone(), tail.clone()))
1770        })
1771        .collect();
1772
1773    out_packages.insert(
1774        contextualized.clone(),
1775        LockedPackage {
1776            name: pkg.name.clone(),
1777            version: pkg.version.clone(),
1778            integrity: pkg.integrity.clone(),
1779            dependencies: new_dependencies,
1780            optional_dependencies: new_optional_dependencies,
1781            peer_dependencies: pkg.peer_dependencies.clone(),
1782            peer_dependencies_meta: pkg.peer_dependencies_meta.clone(),
1783            dep_path: contextualized.clone(),
1784            local_source: pkg.local_source.clone(),
1785            os: pkg.os.clone(),
1786            cpu: pkg.cpu.clone(),
1787            libc: pkg.libc.clone(),
1788            bundled_dependencies: pkg.bundled_dependencies.clone(),
1789            optional: pkg.optional,
1790            transitive_peer_dependencies: pkg.transitive_peer_dependencies.clone(),
1791            tarball_url: pkg.tarball_url.clone(),
1792            registry_git_hosted: pkg.registry_git_hosted,
1793            alias_of: pkg.alias_of.clone(),
1794            yarn_checksum: pkg.yarn_checksum.clone(),
1795            engines: pkg.engines.clone(),
1796            bin: pkg.bin.clone(),
1797            declared_dependencies: pkg.declared_dependencies.clone(),
1798            license: pkg.license.clone(),
1799            funding_url: pkg.funding_url.clone(),
1800            extra_meta: pkg.extra_meta.clone(),
1801        },
1802    );
1803    Some(contextualized)
1804}