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, LocalSource, 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 into a synthetic
102/// importer entry. Aube uses that entry to create the top-level
103/// `node_modules/<peer>` symlink required by its isolated linker. The
104/// synthetic entry inherits the requiring package's dependency kind so
105/// section-filtered installs retain it only when they retain the package
106/// that needs it. Peers declared by transitive dependencies stay in the
107/// resolved graph for peer-context sibling wiring, but they are not surfaced
108/// as top-level entries.
109///
110/// Public so lockfile-driven installs that need to re-derive peer
111/// wiring (npm/yarn/bun formats, which don't record peer contexts)
112/// can run this before [`apply_peer_contexts`] to match fresh-resolve
113/// behavior. Idempotent in the npm case: npm v7+ already hoists
114/// auto-installed peers into root's `dependencies`, so they arrive
115/// pre-`satisfied` and no additions are emitted.
116///
117/// Algorithm:
118///   1. For each importer, collect the set of names already in its
119///      direct deps. Those are "satisfied" and need no hoist.
120///   2. Visit only those direct dependency packages and examine their
121///      `peer_dependencies` declarations. For each declared peer not
122///      already satisfied by the importer, find a resolved version somewhere
123///      in the graph and synthesize one `DirectDep` entry. If another direct
124///      dependency needs the same peer, promote the synthetic entry to the
125///      strongest section classification required by either package.
126///   3. Stable: we walk in-order and take the first declared peer range
127///      encountered per name as the specifier. Conflicting ranges across
128///      the tree are not reconciled — first one wins. This matches pnpm
129///      for the simple case; the complex case is deferred.
130///
131/// Leaves everything else about the graph untouched — no packages are
132/// added or removed, only importer entries grow.
133pub fn hoist_auto_installed_peers(mut graph: LockfileGraph) -> LockfileGraph {
134    let importer_paths: Vec<String> = graph.importers.keys().cloned().collect();
135    for importer_path in importer_paths {
136        let Some(direct_deps) = graph.importers.get(&importer_path) else {
137            continue;
138        };
139        let satisfied: FxHashSet<String> = direct_deps.iter().map(|d| d.name.clone()).collect();
140
141        // Additions are gathered into a separate vec so we don't mutate
142        // the importer's direct-dep list while still borrowing from it.
143        let mut additions: Vec<DirectDep> = Vec::new();
144        let mut additions_by_name: FxHashMap<String, usize> =
145            FxHashMap::with_capacity_and_hasher(direct_deps.len(), Default::default());
146
147        for direct_dep in direct_deps {
148            let Some(pkg) = graph.packages.get(&direct_dep.dep_path) else {
149                continue;
150            };
151
152            // Collect unmet peer declarations from this package.
153            for (peer_name, peer_range) in &pkg.peer_dependencies {
154                if satisfied.contains(peer_name) {
155                    continue;
156                }
157                // Optional peers (`peerDependenciesMeta.optional = true`)
158                // are opt-in integrations — pnpm's `auto-install-peers`
159                // only fills in *required* peers and never promotes an
160                // optional one to the importer, even when another
161                // dependency already pulled a matching version into the
162                // graph. Mirrors the enqueue-side skip in
163                // `resolve/driver.rs`. Skipped before the dep_type
164                // reconciliation below so an optional declaration can't
165                // reclassify an entry hoisted for a required peer.
166                let optional = pkg
167                    .peer_dependencies_meta
168                    .get(peer_name)
169                    .map(|m| m.optional)
170                    .unwrap_or(false);
171                if optional {
172                    continue;
173                }
174                if let Some(&idx) = additions_by_name.get(peer_name) {
175                    let current = additions[idx].dep_type;
176                    // A peer needed by roots from different sections cannot
177                    // safely inherit either narrower classification: the
178                    // other section's filter could then drop it while keeping
179                    // a requirer. Normalize mixed classifications to
180                    // Production, which survives both --production and
181                    // --no-optional. Matching classifications stay unchanged.
182                    additions[idx].dep_type = match (current, direct_dep.dep_type) {
183                        (left, right) if left == right => left,
184                        _ => DepType::Production,
185                    };
186                    continue;
187                }
188                // Find any resolved version in the graph for this peer.
189                // Prefer the one the package already wired via its own
190                // dependencies map (the BFS auto-install result), and
191                // fall back to scanning `graph.packages` for a name
192                // match. If nothing matches, we quietly drop the peer —
193                // that's the only path where aube stays stricter than
194                // pnpm today; a future PR will emit an unmet warning.
195                //
196                // Fallback takes the semver-max version rather than
197                // whatever `BTreeMap` iteration order surfaces first —
198                // otherwise two resolved `react` entries like `18.0.0`
199                // and `18.3.1` would pick the lexicographically-earlier
200                // (older) one.
201                let resolved_version = pkg.dependencies.get(peer_name).cloned().or_else(|| {
202                    // Filter to parseable semver versions *before* the
203                    // max_by — returning `Equal` on parse failure makes
204                    // the comparator non-transitive, so an unparseable
205                    // entry sitting between two valid ones would cause
206                    // `max_by` to pick an iteration-order-dependent
207                    // result instead of the true maximum.
208                    graph
209                        .packages
210                        .values()
211                        .filter(|p| p.name == *peer_name)
212                        .filter_map(|p| {
213                            node_semver::Version::parse(&p.version)
214                                .ok()
215                                .map(|v| (v, p.version.clone()))
216                        })
217                        .max_by(|a, b| a.0.cmp(&b.0))
218                        .map(|(_, s)| s)
219                });
220                let Some(version) = resolved_version else {
221                    continue;
222                };
223                let canonical_version = canonical_tail(&version).to_string();
224                let synth_dep_path = format!("{peer_name}@{canonical_version}");
225                if !graph.packages.contains_key(&synth_dep_path) {
226                    // The peer version the package wired didn't match an
227                    // actual package entry — bail out for this peer
228                    // rather than writing a dangling DirectDep.
229                    continue;
230                }
231                additions_by_name.insert(peer_name.clone(), additions.len());
232                additions.push(DirectDep {
233                    name: peer_name.clone(),
234                    dep_path: synth_dep_path,
235                    dep_type: direct_dep.dep_type,
236                    specifier: Some(peer_range.clone()),
237                });
238            }
239        }
240
241        if !additions.is_empty() {
242            tracing::debug!(
243                "hoisted {} auto-installed peer(s) into importer {}",
244                additions.len(),
245                importer_path
246            );
247            if let Some(deps) = graph.importers.get_mut(&importer_path) {
248                deps.extend(additions);
249                deps.sort_by(|a, b| a.name.cmp(&b.name));
250            }
251        }
252    }
253    graph
254}
255
256/// Walk the resolved graph top-down from each importer and compute a
257/// peer-dependency context for every package, producing a new graph whose
258/// dep_paths carry pnpm-style `(peer@ver)` suffixes.
259///
260/// The goal is parity with pnpm's v9 lockfile output: the same
261/// `name@version` can appear multiple times — once per distinct set of peer
262/// resolutions — so different subtrees that pin incompatible peers get
263/// isolated virtual-store entries and truly different sibling-symlink
264/// neighborhoods.
265///
266/// Algorithm per visited package P, reached at some point in a DFS from an
267/// importer with `ancestor_scope: name -> peer provider`:
268///
269///  1. For each peer name declared by P, look it up in `ancestor_scope`
270///     (nearest-ancestor-wins, since the scope is rebuilt per recursion).
271///     If missing, fall back to P's own entry in `dependencies` — the BFS
272///     enqueue above auto-installed it as a transitive, which matches
273///     pnpm's `auto-install-peers=true` default.
274///  2. Sort the (peer_name, resolution) pairs and serialize as
275///     `(n1@v1)(n2@v2)…` for the suffix.
276///  3. Produce a contextualized dep_path `name@version{suffix}`. If that
277///     key is already in `out_packages` (or currently on the DFS stack via
278///     `visiting`), short-circuit — we've already emitted this variant.
279///  4. Build a new scope for P's children by merging the ancestor scope
280///     with P's own `dependencies` (rewritten to point at contextualized
281///     children) and the resolved peer map. Recurse.
282///  5. Emit the contextualized LockedPackage.
283///
284/// Cycles: protected by `visiting` — if a package is re-entered via a
285/// dependency cycle, we return the already-computed dep_path without
286/// recursing again. The peer context is fixed at first visit; any cycle
287/// traversal uses whatever context was live at that first visit.
288///
289/// Nested peer suffixes: pnpm writes `(react-dom@18.2.0(react@18.2.0))`
290/// when a declared peer has its own resolved peers. A single top-down
291/// DFS pass can't produce that form, because when a parent P records
292/// a peer version in its children's scope, it only knows the canonical
293/// tail — the peer's OWN suffix is computed later when the peer itself
294/// gets visited. We solve this by running `apply_peer_contexts_once` in
295/// a fixed-point loop: the second iteration's input has Pass 1's
296/// contextualized tails in every `pkg.dependencies` map, so when a
297/// descendant looks a peer up in ancestor scope it sees the full
298/// nested tail and serializes it as such. Most peer chains converge in
299/// 2–3 iterations; we cap at 16 as a safety belt.
300///
301/// Limitations (documented as follow-ups in the README):
302///   - No per-peer range satisfaction — we take whatever the ancestor has,
303///     even if it technically doesn't match P's declared peer range.
304///
305/// Knobs controlling the peer-context pass. Plumbed from four
306/// pnpm-compatible settings (`dedupe-peer-dependents`, `dedupe-peers`,
307/// `resolve-peers-from-workspace-root`, `peers-suffix-max-length`)
308/// through the `Resolver`'s `with_*` setters.
309#[derive(Debug, Clone, Copy)]
310pub struct PeerContextOptions {
311    /// When true, run the cross-subtree peer-variant collapse pass
312    /// after every iteration of the fixed-point loop. Matches pnpm's
313    /// default.
314    pub dedupe_peer_dependents: bool,
315    /// When true, emit suffixes as `(version)` instead of
316    /// `(name@version)`. Affects both the package key, the reference
317    /// tails stored in `dependencies`, and the cycle-break form of
318    /// `contains_canonical_back_ref`.
319    pub dedupe_peers: bool,
320    /// When true, unresolved peers can be satisfied by a dep declared
321    /// at the root importer (`"."`) even if no ancestor scope carries
322    /// the peer. Runs between own-deps and graph-wide scan in the
323    /// peer-context visitor — see `visit_peer_context` in this
324    /// module for the owning implementation (intentionally crate-
325    /// private; the public API here is the option flag itself).
326    pub resolve_from_workspace_root: bool,
327    /// Byte cap on the peer-ID suffix body after which the entire
328    /// suffix is replaced by a parenthesized short hash `(<short-hash>)`
329    /// (pnpm's `createPeerDepGraphHash`). pnpm's default is 1000.
330    pub peers_suffix_max_length: usize,
331}
332
333impl Default for PeerContextOptions {
334    fn default() -> Self {
335        Self {
336            dedupe_peer_dependents: true,
337            dedupe_peers: false,
338            resolve_from_workspace_root: true,
339            peers_suffix_max_length: 1000,
340        }
341    }
342}
343
344/// Compute peer-context suffixes over an already-resolved graph.
345///
346/// Takes a *canonical* graph — one `LockedPackage` per `(name,
347/// version)` with `peer_dependencies` populated — and produces a
348/// *contextualized* graph whose keys and transitive references carry
349/// `(peer@ver)` suffixes when packages resolve peers differently in
350/// different subtrees. Drives the sibling-symlink wiring in
351/// `aube-linker` for peers, so every fetch/materialize site sees a
352/// per-context identity for any package whose peers disambiguate.
353///
354/// Public so lockfile-driven installs can run the pass over graphs
355/// parsed from npm/yarn/bun lockfiles (which emit canonical form —
356/// no peer suffixes — and would otherwise leave peer-dependent
357/// packages without their peers as `.aube/<pkg>/node_modules/<peer>`
358/// siblings). Fresh resolves call it internally from
359/// `Resolver::resolve`.
360pub fn apply_peer_contexts(
361    canonical: LockfileGraph,
362    options: &PeerContextOptions,
363) -> Result<LockfileGraph, crate::Error> {
364    const MAX_ITERATIONS: usize = 16;
365    let mut current = canonical;
366    let mut converged = false;
367    // Hash both keys and dependency tails. A peer-context iteration can
368    // rewrite a dependency value to point at an existing key without
369    // adding a new key, so a key-only convergence test ships partially
370    // rewritten tails. Linker reads tails directly to locate sibling
371    // symlink targets, stale tails produce broken `node_modules`.
372    let graph_hash = |g: &LockfileGraph| -> u64 {
373        let total_deps: usize = g.packages.values().map(|p| p.dependencies.len()).sum();
374        let mut tokens: Vec<&str> = Vec::with_capacity(g.packages.len() * 3 + total_deps * 2);
375        for (k, pkg) in &g.packages {
376            tokens.push(k.as_str());
377            tokens.push("\x1f");
378            for (name, tail) in &pkg.dependencies {
379                tokens.push(name.as_str());
380                tokens.push(tail.as_str());
381            }
382            tokens.push("\x1e");
383        }
384        aube_util::hash::ordered_seq_hash(tokens.iter().copied())
385    };
386    // Carry the post-iteration hash forward as the next iteration's
387    // pre-hash. Saves one full graph walk per iteration (the loop runs
388    // up to 16 times; each `graph_hash` allocates a Vec<&str> sized
389    // to `pkgs * 3 + deps * 2` tokens — ~25k entries on a 1000-pkg
390    // graph). One hash per iter instead of two.
391    let mut before = graph_hash(&current);
392    for i in 0..MAX_ITERATIONS {
393        let after_once = apply_peer_contexts_once(current, options);
394        let next = if options.dedupe_peer_dependents {
395            dedupe_peer_variants(after_once)
396        } else {
397            after_once
398        };
399        let after = graph_hash(&next);
400        if before == after {
401            tracing::debug!("peer-context pass converged after {i} iteration(s)");
402            current = next;
403            converged = true;
404            break;
405        }
406        current = next;
407        before = after;
408    }
409    if !converged {
410        // Iteration cap hit. Returning the partial graph would ship
411        // broken node_modules. Now fatal.
412        tracing::error!(
413            code = aube_codes::errors::ERR_AUBE_PEER_CONTEXT_NOT_CONVERGED,
414            max_iterations = MAX_ITERATIONS,
415            "peer-context hit MAX_ITERATIONS={MAX_ITERATIONS} without convergence"
416        );
417        return Err(crate::Error::PeerContextDivergence(MAX_ITERATIONS));
418    }
419    // Propagate each package's peer-suffix segments up through its
420    // non-peer-declaring ancestors so a parent that pulls in a peer-
421    // bearing descendant carries the same `(peer@version)` suffix on
422    // its own dep_path. Matches pnpm's lockfile shape — pnpm 9 emits
423    // every peer-bearing package's resolved peer set on every
424    // ancestor in the chain (importer rows included), even when the
425    // ancestor itself doesn't declare those peers. Without the
426    // propagation aube would tag the suffix only on the package that
427    // declares peers, which differs from pnpm-lock.yaml in the
428    // `importers:` section any time a non-peer-declaring middle node
429    // sits between an importer and its peer-bearing descendant.
430    //
431    // Runs after the fixed-point loop converges so all self-suffixes
432    // are stable, and before `dedupe_peer_suffixes` so the latter's
433    // `(name@version)` → `(version)` collapse acts on the propagated
434    // form too.
435    let current = propagate_peer_suffixes_to_ancestors(current, options);
436    // `dedupe-peers=true` rewrites the parenthesized peer suffix to
437    // drop the `name@` prefix. Done as a post-pass rather than inline
438    // so cycle detection during the fixed-point loop keeps the full
439    // `name@version` form (otherwise unrelated same-version packages
440    // would false-positive as back-references).
441    let result = if options.dedupe_peers {
442        dedupe_peer_suffixes(current)
443    } else {
444        current
445    };
446    Ok(result)
447}
448
449/// Cross-subtree peer-variant dedupe. When `dedupe-peer-dependents` is
450/// on, packages that landed at different contextualized dep_paths but
451/// resolved every declared peer to the *same* version (ignoring the
452/// nested peer suffix on each peer tail) collapse into a single
453/// canonical variant — chosen as the lexicographically smallest key in
454/// the equivalence class. References in every surviving
455/// `LockedPackage.dependencies` map and every `importers[*]` direct
456/// dep get rewritten through the old→canonical map, and the
457/// non-canonical entries are dropped from `packages`.
458///
459/// Packages whose `peer_dependencies` map is empty — i.e. the canonical
460/// base already has only one variant — are skipped.
461pub(crate) fn dedupe_peer_variants(graph: LockfileGraph) -> LockfileGraph {
462    let canonical_base = |key: &str| -> String { canonical_tail(key).to_string() };
463    // Only the peer-bearing part of the resolved peer tail is
464    // comparable across subtrees — the nested suffix could differ even
465    // for peer-equivalent variants on mid-iterations of the outer
466    // fixed-point loop.
467    let peer_base = |tail: &str| -> String { canonical_tail(tail).to_string() };
468
469    // Group dep_paths by their peer-free base name.
470    let mut groups: BTreeMap<String, Vec<String>> = BTreeMap::new();
471    for key in graph.packages.keys() {
472        groups
473            .entry(canonical_base(key))
474            .or_default()
475            .push(key.clone());
476    }
477
478    let mut rewrite: BTreeMap<String, String> = BTreeMap::new();
479    for (_base, mut keys) in groups {
480        if keys.len() < 2 {
481            continue;
482        }
483        // Deterministic order for canonical selection + stable hashing.
484        keys.sort();
485        // Union-find over equivalence classes. Two variants are
486        // equivalent when each declared peer name resolves to the same
487        // peer base in both (or is missing from both).
488        let mut parent: Vec<usize> = (0..keys.len()).collect();
489        fn find(parent: &mut [usize], i: usize) -> usize {
490            if parent[i] == i {
491                i
492            } else {
493                let r = find(parent, parent[i]);
494                parent[i] = r;
495                r
496            }
497        }
498        for i in 0..keys.len() {
499            for j in (i + 1)..keys.len() {
500                let pa = &graph.packages[&keys[i]];
501                let pb = &graph.packages[&keys[j]];
502                // Same canonical version is required — packages with
503                // different versions but the same name would share no
504                // canonical_base only if the name-without-version
505                // collided, which doesn't happen (version is in the
506                // base). Still, belt-and-suspenders.
507                if pa.version != pb.version {
508                    continue;
509                }
510                let peer_names: BTreeSet<&String> = pa
511                    .peer_dependencies
512                    .keys()
513                    .chain(pb.peer_dependencies.keys())
514                    .collect();
515                let equivalent = peer_names.iter().all(|name| {
516                    match (
517                        pa.dependencies.get(name.as_str()),
518                        pb.dependencies.get(name.as_str()),
519                    ) {
520                        (Some(va), Some(vb)) => peer_base(va) == peer_base(vb),
521                        (None, None) => true,
522                        _ => false,
523                    }
524                });
525                if equivalent {
526                    let ri = find(&mut parent, i);
527                    let rj = find(&mut parent, j);
528                    if ri != rj {
529                        parent[ri] = rj;
530                    }
531                }
532            }
533        }
534        // Build class → canonical (smallest key) mapping. Using
535        // index-based iteration here because `find` takes a mutable
536        // reference into `parent`, so holding an immutable borrow
537        // from `keys.iter()` at the same time would double-borrow.
538        #[allow(clippy::needless_range_loop)]
539        {
540            let mut class_rep: BTreeMap<usize, String> = BTreeMap::new();
541            for i in 0..keys.len() {
542                let root = find(&mut parent, i);
543                class_rep
544                    .entry(root)
545                    .and_modify(|cur| {
546                        if keys[i] < *cur {
547                            *cur = keys[i].clone();
548                        }
549                    })
550                    .or_insert_with(|| keys[i].clone());
551            }
552            for i in 0..keys.len() {
553                let root = find(&mut parent, i);
554                let canonical = class_rep[&root].clone();
555                if keys[i] != canonical {
556                    rewrite.insert(keys[i].clone(), canonical);
557                }
558            }
559        }
560    }
561
562    if rewrite.is_empty() {
563        return graph;
564    }
565
566    // Rewrite package dependency tails and keep only canonicals.
567    let LockfileGraph {
568        importers,
569        packages,
570        settings,
571        overrides,
572        package_extensions_checksum,
573        pnpmfile_checksum,
574        ignored_optional_dependencies,
575        times,
576        skipped_optional_dependencies,
577        catalogs,
578        bun_config_version,
579        patched_dependencies,
580        trusted_dependencies,
581        runtimes,
582        extra_fields,
583        workspace_extra_fields,
584    } = graph;
585
586    let mut new_packages: BTreeMap<String, LockedPackage> = BTreeMap::new();
587    for (key, mut pkg) in packages {
588        if rewrite.contains_key(&key) {
589            continue;
590        }
591        for (dep_name, dep_tail) in pkg.dependencies.iter_mut() {
592            let dep_key = format!("{dep_name}@{dep_tail}");
593            if let Some(canonical) = rewrite.get(&dep_key) {
594                let new_tail = canonical
595                    .strip_prefix(&format!("{dep_name}@"))
596                    .map(|s| s.to_string())
597                    .unwrap_or_else(|| canonical.clone());
598                *dep_tail = new_tail;
599            }
600        }
601        new_packages.insert(key, pkg);
602    }
603
604    let mut new_importers: BTreeMap<String, Vec<DirectDep>> = BTreeMap::new();
605    for (importer_path, deps) in importers {
606        let mut new_deps = Vec::with_capacity(deps.len());
607        for mut dep in deps {
608            if let Some(canonical) = rewrite.get(&dep.dep_path) {
609                dep.dep_path = canonical.clone();
610            }
611            new_deps.push(dep);
612        }
613        new_importers.insert(importer_path, new_deps);
614    }
615
616    LockfileGraph {
617        importers: new_importers,
618        packages: new_packages,
619        settings,
620        overrides,
621        package_extensions_checksum,
622        pnpmfile_checksum,
623        ignored_optional_dependencies,
624        times,
625        skipped_optional_dependencies,
626        catalogs,
627        bun_config_version,
628        patched_dependencies,
629        trusted_dependencies,
630        runtimes,
631        extra_fields,
632        workspace_extra_fields,
633    }
634}
635
636/// Single pass of the peer-context computation. See `apply_peer_contexts`
637/// for the wrapping fixed-point loop.
638///
639/// Algorithm per visited package P, reached at some point in a DFS from an
640/// importer with `ancestor_scope: name -> peer provider`:
641///
642///  1. For each peer name declared by P, look it up in `ancestor_scope`
643///     (nearest-ancestor-wins, since the scope is rebuilt per recursion).
644///     If missing, fall back to P's own entry in `dependencies` — the BFS
645///     enqueue auto-installed it as a transitive, matching pnpm's
646///     `auto-install-peers=true` default.
647///  2. Sort the (peer_name, resolution) pairs and serialize as
648///     `(n1@v1)(n2@v2)…` for the suffix.
649///  3. Produce a contextualized dep_path `name@version{suffix}`. If that
650///     key is already in `out_packages` (or currently on the DFS stack via
651///     `visiting`), short-circuit — we've already emitted this variant.
652///  4. Build a new scope for P's children by merging the ancestor scope
653///     with P's own `dependencies` and the resolved peer map. Recurse.
654///  5. Emit the contextualized LockedPackage.
655///
656/// Cycles: protected by `visiting` — if a package is re-entered via a
657/// dependency cycle, we return the already-computed dep_path without
658/// recursing again. The peer context is fixed at first visit; any cycle
659/// traversal uses whatever context was live at that first visit.
660fn apply_peer_contexts_once(
661    canonical: LockfileGraph,
662    options: &PeerContextOptions,
663) -> LockfileGraph {
664    let mut out_packages: BTreeMap<String, LockedPackage> = BTreeMap::new();
665    let mut new_importers: BTreeMap<String, Vec<DirectDep>> = BTreeMap::new();
666
667    // Name-indexed view of the canonical graph, shared across
668    // every `visit_peer_context` call in this pass. Peer-resolution
669    // scan-by-name is the resolver's hottest inner loop. Without
670    // this, each peer runs `O(|graph|)` per package per fixed-point
671    // iter. Prebuilt index drops the scan to O(1) average.
672    //
673    // Pre-size to the package count: most graphs have one entry per
674    // name and only a handful of multi-version names, so capacity
675    // headroom is small and the upper bound saves 8+ rehashes on
676    // medium graphs (default 16 → 2048 covers ~1200 pkgs).
677    let mut name_index: FxHashMap<&str, Vec<&LockedPackage>> =
678        FxHashMap::with_capacity_and_hasher(canonical.packages.len(), Default::default());
679    for pkg in canonical.packages.values() {
680        name_index.entry(pkg.name.as_str()).or_default().push(pkg);
681    }
682
683    // Root-importer scope used by `resolve-peers-from-workspace-root`.
684    // Computed once from the canonical input so it reflects the
685    // contextualized state of every root dep on fixed-point iterations
686    // 2+ — same logic as per-importer `importer_scope` below.
687    let root_scope: FxHashMap<String, PeerProvider> = canonical
688        .importers
689        .get(".")
690        .map(|deps| scope_map_from_deps(&canonical, deps))
691        .unwrap_or_default();
692
693    for (importer_path, direct_deps) in &canonical.importers {
694        // An importer's own direct deps are in scope for its children's
695        // peer resolution — this is how pnpm's "auto-install at the root"
696        // path gets peer links that point at root-level packages.
697        //
698        // Use the *full contextualized tail* off each DirectDep rather
699        // than the package's plain version. On Pass 1 of the fixed-point
700        // loop the tail is canonical and equal to `p.version`; on Pass 2+
701        // it's already contextualized, and passing the plain version
702        // would make descendants look up keys that don't exist in the
703        // (now-nested) graph.
704        let importer_scope = scope_map_from_deps(&canonical, direct_deps);
705
706        let mut new_deps = Vec::with_capacity(direct_deps.len());
707        for dep in direct_deps {
708            // `visiting` is the DFS stack guard for this particular descent
709            // — reset per direct dep so we don't incorrectly flag a package
710            // as a cycle when it's reached again from a sibling subtree.
711            // The shared `out_packages` still dedupes across siblings since
712            // the second visit hits the `contains_key` short-circuit below.
713            //
714            // Invariant (see `visit_peer_context` for the detailed handling):
715            // a dep_path returned from the cycle-break branch may not yet
716            // be present in `out_packages` at the moment of return, because
717            // the package is still being assembled up the call stack. The
718            // parent that records the returned tail will complete its own
719            // insertion before the recursion unwinds, so by the time
720            // anything reads the graph, every referenced dep_path exists.
721            let mut visiting: FxHashSet<String> = FxHashSet::default();
722            let new_dep_path = visit_peer_context(
723                &dep.dep_path,
724                &canonical,
725                &name_index,
726                &importer_scope,
727                &root_scope,
728                &mut out_packages,
729                &mut visiting,
730                options,
731            )
732            .unwrap_or_else(|| dep.dep_path.clone());
733            new_deps.push(DirectDep {
734                name: dep.name.clone(),
735                dep_path: new_dep_path,
736                dep_type: dep.dep_type,
737                specifier: dep.specifier.clone(),
738            });
739        }
740        new_importers.insert(importer_path.clone(), new_deps);
741    }
742
743    // Any canonical package that was never reached by the DFS (orphaned
744    // from every importer) is dropped — that matches the filter_deps
745    // semantics and avoids emitting dead entries into the lockfile.
746
747    LockfileGraph {
748        importers: new_importers,
749        packages: out_packages,
750        // The post-pass is pure — settings + overrides carry through
751        // from the input graph untouched.
752        settings: canonical.settings,
753        overrides: canonical.overrides,
754        package_extensions_checksum: canonical.package_extensions_checksum,
755        pnpmfile_checksum: canonical.pnpmfile_checksum,
756        ignored_optional_dependencies: canonical.ignored_optional_dependencies,
757        runtimes: canonical.runtimes,
758        times: canonical.times,
759        skipped_optional_dependencies: canonical.skipped_optional_dependencies,
760        catalogs: canonical.catalogs,
761        bun_config_version: canonical.bun_config_version,
762        patched_dependencies: canonical.patched_dependencies,
763        trusted_dependencies: canonical.trusted_dependencies,
764        extra_fields: canonical.extra_fields,
765        workspace_extra_fields: canonical.workspace_extra_fields,
766    }
767}
768
769/// DFS helper for `apply_peer_contexts`. Returns the peer-contextualized
770/// dep_path of the visited package, or `None` if the canonical package is
771/// missing (shouldn't happen in practice but we degrade gracefully).
772/// Does `value` contain a peer-suffix reference to `canonical` as a
773/// proper name@version boundary (i.e. preceded by `(` and followed by
774/// `(` / `)` / end-of-string)? Used by the peer-context pass to detect
775/// when a nested tail loops back to the current package so it can
776/// short-circuit the chain instead of growing the suffix forever.
777/// Everything before the first `(` — i.e. the canonical `name@version`
778/// part of a dep-path with the peer-context suffix stripped. Returns
779/// the original string when no `(` is present. Borrowed; callers that
780/// need owned bump with `.to_string()`.
781fn canonical_tail(s: &str) -> &str {
782    s.split('(').next().unwrap_or(s)
783}
784
785/// A peer provider's graph target and semver identity.
786///
787/// Those are normally the same tail. For linked directories, the target
788/// remains the content-addressed local dep path while peer matching and
789/// suffixes use the target manifest's version.
790#[derive(Debug, Clone)]
791struct PeerProvider {
792    /// Tail used to find and link the package in the graph.
793    target_tail: String,
794    /// Tail used for semver checks and the consumer's peer suffix.
795    context_tail: String,
796}
797
798fn peer_provider(graph: &LockfileGraph, name: &str, target_tail: &str) -> PeerProvider {
799    let context_tail =
800        aube_lockfile::resolve_dep_edge(name, target_tail, |key| graph.packages.contains_key(key))
801            .and_then(|key| graph.packages.get(&key))
802            .filter(|pkg| {
803                matches!(
804                    pkg.local_source,
805                    Some(LocalSource::Directory(_) | LocalSource::Link(_) | LocalSource::Portal(_))
806                )
807            })
808            .map(|pkg| pkg.version.clone())
809            .unwrap_or_else(|| target_tail.to_string());
810    PeerProvider {
811        target_tail: target_tail.to_string(),
812        context_tail,
813    }
814}
815
816/// Build a `name → provider` map from a direct-dependency slice.
817///
818/// Used for the workspace root and for each importer's own scope inside
819/// `apply_peer_contexts_once`.
820fn scope_map_from_deps(
821    graph: &LockfileGraph,
822    deps: &[DirectDep],
823) -> FxHashMap<String, PeerProvider> {
824    let mut out = FxHashMap::with_capacity_and_hasher(deps.len(), Default::default());
825    for d in deps {
826        let prefix_len = d.name.len() + 1;
827        let tail = if d.dep_path.len() > prefix_len
828            && d.dep_path.as_bytes().get(d.name.len()) == Some(&b'@')
829            && d.dep_path.as_bytes().starts_with(d.name.as_bytes())
830        {
831            d.dep_path[prefix_len..].to_string()
832        } else {
833            d.dep_path.clone()
834        };
835        out.insert(d.name.clone(), peer_provider(graph, &d.name, &tail));
836    }
837    out
838}
839
840/// True when `s` is a single hashed peer suffix `(<32 lowercase hex>)`
841/// as emitted by [`effective_peer_suffix`] once a suffix exceeds
842/// `peersSuffixMaxLength`. The hashed form discards the textual peer
843/// set, so the propagation pass recognizes such keys and leaves them
844/// untouched (their per-peer contribution can't be recovered). A real
845/// peer segment always contains `@`, so the all-hex check can't
846/// false-positive on a `(name@version)` group.
847pub(crate) fn is_hashed_peer_suffix(s: &str) -> bool {
848    let Some(inner) = s.strip_prefix('(').and_then(|x| x.strip_suffix(')')) else {
849        return false;
850    };
851    inner.len() == 32
852        && inner
853            .bytes()
854            .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
855}
856
857/// pnpm's `createShortHash`: the lowercase SHA-256 hex digest of
858/// `input`, truncated to its first 32 characters (16 bytes).
859fn short_peer_hash(input: &str) -> String {
860    use sha2::{Digest, Sha256};
861    let digest = Sha256::digest(input.as_bytes());
862    let mut out = String::with_capacity(32);
863    for byte in digest.iter().take(16) {
864        use std::fmt::Write;
865        let _ = write!(out, "{byte:02x}");
866    }
867    out
868}
869
870/// Final peer-context tail for an already-built `(name@version)…`
871/// `suffix`, mirroring pnpm's `createPeerDepGraphHash`. pnpm derives
872/// `dirName` by joining the sorted peer ids with `)(` — i.e. the suffix
873/// without its outer parens — hashes it with `createShortHash` when it
874/// exceeds `peersSuffixMaxLength`, and always re-wraps the result in a
875/// single `(...)`. Keeping that shape means a capped suffix aube writes
876/// into `pnpm-lock.yaml` is `(<short-hash>)` — byte-compatible with
877/// pnpm — never a bare `_<hex>` marker.
878pub(crate) fn effective_peer_suffix(suffix: &str, max_length: usize) -> String {
879    // `dir_name` == pnpm's `dirName`: the suffix without the outer `(`
880    // and `)` that wrap the first and last peer segment. `suffix` is
881    // always a concatenation of `(…)` groups here, so stripping one
882    // byte off each end is safe; an empty suffix degrades to empty.
883    let dir_name = suffix
884        .strip_prefix('(')
885        .and_then(|s| s.strip_suffix(')'))
886        .unwrap_or(suffix);
887    if dir_name.len() > max_length {
888        format!("({})", short_peer_hash(dir_name))
889    } else {
890        suffix.to_string()
891    }
892}
893
894pub(crate) fn contains_canonical_back_ref(value: &str, canonical: &str) -> bool {
895    let bytes = value.as_bytes();
896    let target = canonical.as_bytes();
897    if target.is_empty() || target.len() > bytes.len() {
898        return false;
899    }
900    let mut i = 0;
901    while i + target.len() <= bytes.len() {
902        if &bytes[i..i + target.len()] == target {
903            let before = if i == 0 { b'\0' } else { bytes[i - 1] };
904            let after = bytes.get(i + target.len()).copied().unwrap_or(b'\0');
905            let before_ok = before == b'(';
906            let after_ok = after == b'(' || after == b')' || after == b'\0';
907            if before_ok && after_ok {
908                return true;
909            }
910        }
911        i += 1;
912    }
913    false
914}
915
916/// Split a dep_path tail's peer suffix into outer-level paren segments
917/// (each ending in a balanced `)`). Returns each segment with its parens
918/// included — `react-dom@18.2.0(react@18.2.0)(scheduler@1.0.0)` yields
919/// `["(react@18.2.0)", "(scheduler@1.0.0)"]`; nested forms like
920/// `consumer@1.0.0(react-dom@18.2.0(react@18.2.0))` yield the single
921/// segment `["(react-dom@18.2.0(react@18.2.0))"]` with the inner
922/// `(react@18.2.0)` preserved verbatim inside it.
923///
924/// Used by `propagate_peer_suffixes_to_ancestors` to lift a child's
925/// peer segments onto its non-peer-declaring ancestors.
926fn outer_paren_segments(s: &str) -> Vec<&str> {
927    let bytes = s.as_bytes();
928    let mut segments = Vec::new();
929    let mut i = 0;
930    // Skip canonical `name@version` head — anything up to the first `(`.
931    while i < bytes.len() && bytes[i] != b'(' {
932        i += 1;
933    }
934    while i < bytes.len() {
935        if bytes[i] != b'(' {
936            i += 1;
937            continue;
938        }
939        let start = i;
940        let mut depth: i32 = 0;
941        while i < bytes.len() {
942            match bytes[i] {
943                b'(' => depth += 1,
944                b')' => {
945                    depth -= 1;
946                    if depth == 0 {
947                        i += 1;
948                        segments.push(&s[start..i]);
949                        break;
950                    }
951                }
952                _ => {}
953            }
954            i += 1;
955        }
956        if depth != 0 {
957            // Unbalanced — bail out of further segmenting. Shouldn't
958            // happen on output of `apply_peer_contexts_once`, where every
959            // suffix segment is balanced by construction.
960            break;
961        }
962    }
963    segments
964}
965
966/// Extract the peer name from a paren segment like `(@scope/name@1.2.3)`
967/// or `(name@1.2.3(nested@9.9.9))`. The peer name is everything between
968/// the opening `(` and the LAST `@` that occurs before any nested `(`.
969/// Scoped packages contain two `@`s (`@scope/name@version`) and we want
970/// the rightmost outer one.
971///
972/// Returns `None` if the segment doesn't start with `(` or has no
973/// usable `@` separator.
974fn peer_name_from_segment(seg: &str) -> Option<&str> {
975    let inner = seg.strip_prefix('(')?;
976    // Scan for the last `@` that occurs before any `(` (the version-or-
977    // nested boundary). For a flat segment `name@version` everything
978    // between `(` and the last `@` is the name; for a nested segment
979    // `name@version(inner)` the last `@` BEFORE the first inner `(` is
980    // the boundary. We search up to the first `(` (or end-of-string).
981    let scan_end = inner.find('(').unwrap_or(inner.len());
982    let head = &inner[..scan_end];
983    head.rfind('@').map(|idx| &head[..idx])
984}
985
986/// Collect every peer name reachable from a set of outer-paren segments,
987/// recursing into nested `(name@version(...))` forms so that a self
988/// segment like `(helper@1.0.0(core@1.0.0))` reports both `helper` and
989/// `core`. Used by `propagate_peer_suffixes_to_ancestors` to suppress
990/// flat-segment additions for peer names already encoded transitively
991/// in a package's own (possibly nested) self-suffix.
992fn peer_names_in_segments_recursive(segments: &[&str]) -> BTreeSet<String> {
993    let mut names = BTreeSet::new();
994    for seg in segments {
995        if let Some(name) = peer_name_from_segment(seg) {
996            names.insert(name.to_string());
997        }
998        // Recurse into the nested portion (everything after the first
999        // inner `(` and before the final `)`).
1000        let Some(inner) = seg.strip_prefix('(').and_then(|s| s.strip_suffix(')')) else {
1001            continue;
1002        };
1003        if let Some(open) = inner.find('(') {
1004            let nested = &inner[open..];
1005            let nested_segments = outer_paren_segments(nested);
1006            for nested_name in peer_names_in_segments_recursive(&nested_segments) {
1007                names.insert(nested_name);
1008            }
1009        }
1010    }
1011    names
1012}
1013
1014/// Walk the resolved graph from each node and accumulate the union of
1015/// peer-suffix segments contributed by self + every reachable
1016/// descendant (gated on the package having no declared peers of its
1017/// own), then rewrite each node's dep_path to embed that union.
1018///
1019/// Why: pnpm's lockfile shape tags non-peer-declaring intermediaries
1020/// with the same `(peer@version)` suffix their peer-declaring
1021/// descendants produced — so a parent that pulls in a peer-bearing
1022/// child carries the resolved peer set on its own dep_path. aube's
1023/// `apply_peer_contexts_once` only emits the suffix on the package
1024/// that *declares* the peer; without this post-pass an importer row
1025/// for `parent → leaf(peer)` would render `parent: 1.0.0` (no
1026/// suffix) where pnpm renders `parent: 1.0.0(peer@v)`.
1027///
1028/// pnpm-parity gate (inferred from observed lockfile shape): **a
1029/// package gets descendant-peer propagation only if its own
1030/// `peerDependencies` map is empty.** Packages that declare their
1031/// own peers have an authoritative self-suffix encoding exactly the
1032/// peers they care about; descendant peers don't bubble through
1033/// because the descendant peers belong to a NESTED child, which the
1034/// snapshot already encodes via the nested-tail form (see
1035/// `apply_peer_contexts_once`'s nested-suffix handling). Two
1036/// observable shapes this gate lines up with:
1037///   - `@testing-library/react@14.0.0(react@18.2.0)(react-dom@18.2.0(react@18.2.0))`
1038///     — declares peers, gets self-suffix only; `@types/react` from a
1039///     descendant doesn't bubble up.
1040///   - `abc-parent-with-missing-peers@1.0.0(peer-a@…)(peer-b@…)(peer-c@…)`
1041///     — no declared peers, picks up descendant peers from `abc`.
1042///
1043/// Algorithm:
1044///  1. Build a forward dep map: `pkg_key → [child_key]` from each
1045///     LockedPackage's `dependencies`.
1046///  2. Memoized DFS. For each node, compute
1047///     `cumulative_segments = outer_paren_segments(node.key)`. If the
1048///     node has no declared peers, also union in
1049///     `⋃ cumulative(child)` (gated by the rule above).
1050///  3. Cycles short-circuit via a `visiting` guard — cycle members
1051///     can't add new peers from each other beyond what reaches them
1052///     through non-cycle paths, so returning the empty set on
1053///     re-entry is safe (the non-cycle entry path computes the full
1054///     set).
1055///  4. Dedupe by peer name. Suppressed names: every peer name reachable
1056///     transitively in self-segments (so `(helper@1(core@1))` covers
1057///     `core` and a flat `(core@1)` from descendants is dropped) plus
1058///     the package's own canonical name (mutual-peer cycle break).
1059///  5. Build a rewrite map `old_key → new_key` and apply to package
1060///     keys, dep edges (each dep's stored tail), and importer
1061///     dep_paths.
1062fn propagate_peer_suffixes_to_ancestors(
1063    graph: LockfileGraph,
1064    options: &PeerContextOptions,
1065) -> LockfileGraph {
1066    // Forward dep map. Edges that don't resolve to a present package
1067    // (e.g. an unresolved peer that `detect_unmet_peers` will warn
1068    // about) are dropped — they can't contribute cumulative peers.
1069    let mut forward: BTreeMap<String, Vec<String>> = BTreeMap::new();
1070    // Per-package "has declared peers" lookup. Packages that declare
1071    // their own peers don't accept descendant-peer propagation (see
1072    // the rule in the doc comment above).
1073    let mut has_own_peers: BTreeMap<String, bool> = BTreeMap::new();
1074    // Per-package set of dependency names a package supplies itself
1075    // (regular or optional). A peer a descendant needs is *resolved* at
1076    // the nearest ancestor that supplies it, so the `(peer@version)`
1077    // suffix must stop there — that supplier, and everything above it,
1078    // stays bare. pnpm leaves e.g. `tinyglobby@0.2.17` bare because it
1079    // lists `picomatch` in its own `dependencies` (resolving `fdir`'s
1080    // optional peer); only `fdir` keeps the suffix. Without this gate the
1081    // suffix leaks onto every ancestor up to the importer.
1082    let mut provides: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
1083    for (key, pkg) in &graph.packages {
1084        let children: Vec<String> = pkg
1085            .dependencies
1086            .iter()
1087            .map(|(n, t)| format!("{n}@{t}"))
1088            .filter(|k| graph.packages.contains_key(k))
1089            .collect();
1090        forward.insert(key.clone(), children);
1091        has_own_peers.insert(key.clone(), !pkg.peer_dependencies.is_empty());
1092        let supplied: BTreeSet<String> = pkg
1093            .dependencies
1094            .keys()
1095            .chain(pkg.optional_dependencies.keys())
1096            .cloned()
1097            .collect();
1098        provides.insert(key.clone(), supplied);
1099    }
1100
1101    // Memoized DFS. `cumulative` stores the by-name segment map per
1102    // package key; `visiting` is the cycle-break stack.
1103    let mut cumulative: BTreeMap<String, BTreeMap<String, String>> = BTreeMap::new();
1104    let mut visiting: BTreeSet<String> = BTreeSet::new();
1105
1106    fn collect(
1107        key: &str,
1108        forward: &BTreeMap<String, Vec<String>>,
1109        has_own_peers: &BTreeMap<String, bool>,
1110        provides: &BTreeMap<String, BTreeSet<String>>,
1111        cumulative: &mut BTreeMap<String, BTreeMap<String, String>>,
1112        visiting: &mut BTreeSet<String>,
1113    ) -> BTreeMap<String, String> {
1114        if let Some(c) = cumulative.get(key) {
1115            return c.clone();
1116        }
1117        if !visiting.insert(key.to_string()) {
1118            // Cycle: contribute nothing. Whichever cycle member is
1119            // first reached from outside the cycle will compute the
1120            // full set; the visit guard cap on the others prevents
1121            // infinite recursion. Edge case: a fully-isolated cycle
1122            // never gets a non-cycle entry, in which case all members
1123            // compute empty cumulatives — that's identical to their
1124            // canonical state, so they get no rewrite. Acceptable.
1125            return BTreeMap::new();
1126        }
1127
1128        // Self-suffix segments. Each segment becomes one (name → segment)
1129        // entry. Nested segments like `(react-dom@18.2.0(react@18.2.0))`
1130        // are preserved as a single segment with the nested form intact.
1131        let self_segments = outer_paren_segments(key);
1132        let mut acc: BTreeMap<String, String> = BTreeMap::new();
1133        for seg in &self_segments {
1134            if let Some(name) = peer_name_from_segment(seg) {
1135                acc.entry(name.to_string())
1136                    .or_insert_with(|| seg.to_string());
1137            }
1138        }
1139
1140        // Pnpm-parity gate: only packages with no declared peers absorb
1141        // descendant-peer propagation. The cycle-break visiting guard
1142        // is still released for symmetry with the non-gated branch.
1143        if has_own_peers.get(key).copied().unwrap_or(false) {
1144            visiting.remove(key);
1145            cumulative.insert(key.to_string(), acc.clone());
1146            return acc;
1147        }
1148
1149        // Names suppressed when merging child contributions:
1150        //   1. Every peer name reachable transitively in self segments —
1151        //      e.g. a self segment `(helper@1.0.0(core@1.0.0))` covers
1152        //      both `helper` and `core`, so a descendant flat-listing
1153        //      `(core@1.0.0)` shouldn't double-emit. Pnpm lists each
1154        //      peer name once; we match.
1155        //   2. The package's own canonical name — for mutual-peer
1156        //      cycles `a` peers on `b` and `b` peers on `a`, the
1157        //      descendant set lifts `(a@…)` back up onto `a` itself,
1158        //      which would write `a@1.0.0(a@…)(b@…)`. Self-listing
1159        //      isn't valid pnpm shape; suppress it. (Reachable here
1160        //      only when this branch handles a node with no declared
1161        //      peers — but defensive in case future graph shapes
1162        //      surface a self-cycle through a peer-less node.)
1163        let canonical_name = canonical_tail(key)
1164            .rsplit_once('@')
1165            .map(|(name, _ver)| name.to_string())
1166            .unwrap_or_default();
1167        let mut suppressed: BTreeSet<String> = peer_names_in_segments_recursive(&self_segments);
1168        if !canonical_name.is_empty() {
1169            suppressed.insert(canonical_name);
1170        }
1171        // A peer this package supplies itself is resolved here, so it must
1172        // neither decorate this package's dep_path nor propagate above it
1173        // (pnpm stops the suffix at the supplier). This is what keeps a
1174        // supplier like `tinyglobby` (and its non-peer ancestors) bare
1175        // while `fdir`, which only *declares* the peer, keeps its suffix.
1176        if let Some(supplied) = provides.get(key) {
1177            suppressed.extend(supplied.iter().cloned());
1178        }
1179
1180        // Child contributions.
1181        if let Some(children) = forward.get(key) {
1182            for child in children {
1183                let child_peers = collect(
1184                    child,
1185                    forward,
1186                    has_own_peers,
1187                    provides,
1188                    cumulative,
1189                    visiting,
1190                );
1191                for (name, seg) in child_peers {
1192                    if suppressed.contains(&name) {
1193                        continue;
1194                    }
1195                    acc.entry(name).or_insert(seg);
1196                }
1197            }
1198        }
1199        visiting.remove(key);
1200        cumulative.insert(key.to_string(), acc.clone());
1201        acc
1202    }
1203
1204    // Compute cumulative for every package + every importer DirectDep
1205    // root. Done in stable order so the lex-smaller old-key tiebreaker
1206    // below is deterministic.
1207    let pkg_keys: Vec<String> = graph.packages.keys().cloned().collect();
1208    for key in &pkg_keys {
1209        collect(
1210            key,
1211            &forward,
1212            &has_own_peers,
1213            &provides,
1214            &mut cumulative,
1215            &mut visiting,
1216        );
1217    }
1218    for deps in graph.importers.values() {
1219        for dep in deps {
1220            collect(
1221                &dep.dep_path,
1222                &forward,
1223                &has_own_peers,
1224                &provides,
1225                &mut cumulative,
1226                &mut visiting,
1227            );
1228        }
1229    }
1230
1231    // Build rewrite map. A package's new key is its canonical_base
1232    // (`name@version`) plus the cumulative segments concatenated in
1233    // peer-name lex order — same order `apply_peer_contexts_once`
1234    // already produces for self segments, so when a package's
1235    // cumulative is identical to its self set the rewrite is a no-op
1236    // and we skip it.
1237    //
1238    // Hashed-suffix keys (`name@version(<short-hash>)`, produced when a
1239    // package's own peer suffix exceeded `peersSuffixMaxLength`) are
1240    // left untouched. The hash form discards the textual peer set
1241    // by design — `outer_paren_segments` can't recover its
1242    // contribution, so any rewrite we built for it would either drop
1243    // the hash entirely (losing identity) or merge an incomplete
1244    // descendant set with the hashed self. Preserving the original
1245    // form is the conservative choice; pnpm's parity gap in that
1246    // regime is bounded by the hash collision space anyway.
1247    //
1248    // If the propagated suffix itself exceeds the cap, hash it the
1249    // same way `visit_peer_context` does for self suffixes — keeps
1250    // dep_path keys bounded across the whole graph.
1251    let mut rewrite: BTreeMap<String, String> = BTreeMap::new();
1252    for key in &pkg_keys {
1253        let Some(segments) = cumulative.get(key) else {
1254            continue;
1255        };
1256        // Git / remote-tarball (globally-shareable) packages keep a bare
1257        // dep_path keyed solely by their content-pinned URL — pnpm never
1258        // appends a `(peer@ver)` suffix to a non-registry depPath. Every
1259        // git/tarball key in a real pnpm-lock.yaml is bare even when its
1260        // subtree resolves peers: e.g. `<pkg>@<url>` sits bare above a
1261        // registry descendant like `<child>@6.5.1(@types/node@…)`.
1262        // Absorbing a descendant's `(@types/node@…)` here would (a) diverge
1263        // from the lockfile and (b) give the same content-identical tarball
1264        // a different dep_path per consuming subtree, splitting the single
1265        // shared global-virtual-store entry into duplicates (so one
1266        // content-pinned singleton would load twice → "Cannot find
1267        // module"). The descendant peers still propagate onto this node's
1268        // *registry* ancestors through `cumulative`, so a registry parent
1269        // keeps its own `(@types/node@…)` suffix; only the git/tarball node
1270        // itself stays bare.
1271        if graph
1272            .packages
1273            .get(key)
1274            .and_then(|p| p.local_source.as_ref())
1275            .is_some_and(|s| s.is_globally_shareable())
1276        {
1277            continue;
1278        }
1279        let canonical = canonical_tail(key);
1280        if is_hashed_peer_suffix(&key[canonical.len()..]) {
1281            // Original key already carries the hashed suffix `(…)` — see
1282            // comment above. Its textual peer set is irrecoverable, so
1283            // leave the key untouched.
1284            continue;
1285        }
1286        let suffix: String = segments.values().cloned().collect();
1287        let effective_suffix = effective_peer_suffix(&suffix, options.peers_suffix_max_length);
1288        let new_key = format!("{canonical}{effective_suffix}");
1289        if new_key != *key {
1290            rewrite.insert(key.clone(), new_key);
1291        }
1292    }
1293
1294    if rewrite.is_empty() {
1295        return graph;
1296    }
1297
1298    // Helper: rewrite a `dependencies` tail (the part after `name@`).
1299    // Reconstruct the target's old full key, look up its rewrite, and
1300    // strip the `name@` prefix off the result to recover the new tail.
1301    // Targets without a rewrite keep the original tail.
1302    let rewrite_tail = |child_name: &str, tail: &str| -> String {
1303        let old_key = format!("{child_name}@{tail}");
1304        match rewrite.get(&old_key) {
1305            Some(new_key) => new_key
1306                .strip_prefix(&format!("{child_name}@"))
1307                .map(|s| s.to_string())
1308                .unwrap_or_else(|| tail.to_string()),
1309            None => tail.to_string(),
1310        }
1311    };
1312
1313    let LockfileGraph {
1314        importers,
1315        packages,
1316        settings,
1317        overrides,
1318        package_extensions_checksum,
1319        pnpmfile_checksum,
1320        ignored_optional_dependencies,
1321        times,
1322        skipped_optional_dependencies,
1323        catalogs,
1324        bun_config_version,
1325        patched_dependencies,
1326        trusted_dependencies,
1327        runtimes,
1328        extra_fields,
1329        workspace_extra_fields,
1330    } = graph;
1331
1332    let mut new_packages: BTreeMap<String, LockedPackage> = BTreeMap::new();
1333    for (old_key, mut pkg) in packages {
1334        let new_key = rewrite.get(&old_key).cloned().unwrap_or(old_key);
1335        for (name, tail) in pkg.dependencies.iter_mut() {
1336            *tail = rewrite_tail(name, tail);
1337        }
1338        for (name, tail) in pkg.optional_dependencies.iter_mut() {
1339            *tail = rewrite_tail(name, tail);
1340        }
1341        pkg.dep_path = new_key.clone();
1342        // Two old keys mapping to one new key: the lex-smaller old key
1343        // wins. Because `packages` is a `BTreeMap` we iterate
1344        // `(old_key, pkg)` pairs in lex order — the first insertion
1345        // for any given `new_key` is therefore the one whose old_key
1346        // sorts lowest, and `or_insert` makes every subsequent
1347        // collision a no-op. Bodies are equal in the common case
1348        // anyway (same canonical_base + same cumulative ⇒ same dep
1349        // tree), so this is effectively cosmetic determinism.
1350        new_packages.entry(new_key).or_insert(pkg);
1351    }
1352
1353    let new_importers: BTreeMap<String, Vec<DirectDep>> = importers
1354        .into_iter()
1355        .map(|(path, deps)| {
1356            let rewritten = deps
1357                .into_iter()
1358                .map(|d| {
1359                    let new_dep_path = rewrite.get(&d.dep_path).cloned().unwrap_or(d.dep_path);
1360                    DirectDep {
1361                        name: d.name,
1362                        dep_path: new_dep_path,
1363                        dep_type: d.dep_type,
1364                        specifier: d.specifier,
1365                    }
1366                })
1367                .collect();
1368            (path, rewritten)
1369        })
1370        .collect();
1371
1372    LockfileGraph {
1373        importers: new_importers,
1374        packages: new_packages,
1375        settings,
1376        overrides,
1377        package_extensions_checksum,
1378        pnpmfile_checksum,
1379        ignored_optional_dependencies,
1380        times,
1381        skipped_optional_dependencies,
1382        catalogs,
1383        bun_config_version,
1384        patched_dependencies,
1385        trusted_dependencies,
1386        runtimes,
1387        extra_fields,
1388        workspace_extra_fields,
1389    }
1390}
1391
1392/// Dedupe-peers post-pass: strip the `name@` prefix from every
1393/// parenthesized peer segment in every dep_path key and reference,
1394/// turning `react-dom@18.2.0(react@18.2.0)` into
1395/// `react-dom@18.2.0(18.2.0)`. Nested segments get the same treatment
1396/// so `a@1(b@2(c@3))` becomes `a@1(2(3))`.
1397///
1398/// Running this as a final post-pass (instead of inline during suffix
1399/// assembly in `visit_peer_context`) keeps cycle detection correct:
1400/// the detection path works against the full `name@version` form
1401/// throughout the fixed-point loop, and only the serialized output
1402/// gets the shorter form. A version-only inline approach would
1403/// false-positive on unrelated packages that coincidentally share a
1404/// version with the current package's canonical base.
1405///
1406/// Pure: no-op when `dedupe_peers` is off (caller gates the call);
1407/// otherwise rewrites every package key, every `LockedPackage.dep_path`
1408/// and `LockedPackage.dependencies` value, and every `importers[*]`
1409/// DirectDep `dep_path` through the same `apply_dedupe_peers_to_tail`
1410/// helper. Package bodies (integrity, metadata, etc.) are cloned
1411/// verbatim.
1412pub(crate) fn dedupe_peer_suffixes(graph: LockfileGraph) -> LockfileGraph {
1413    // Pass 1: compute the intended deduped key for each package and
1414    // tally how many distinct full-form keys map to it. Stripping
1415    // `name@` from suffix segments is lossy — two variants whose peer
1416    // *names* differ but whose peer *versions* coincide would collapse
1417    // onto the same deduped key (e.g. `consumer@1.0.0(foo@1.0.0)` and
1418    // `consumer@1.0.0(bar@1.0.0)` both → `consumer@1.0.0(1.0.0)`).
1419    // `dedupe_peer_variants` already merged the peer-equivalent
1420    // duplicates, so any remaining collision here represents genuinely
1421    // distinct variants — losing one would silently drop its
1422    // dependency wiring. We detect those collisions and keep both
1423    // sides in full form.
1424    let mut target_counts: BTreeMap<String, usize> = BTreeMap::new();
1425    let mut intended: BTreeMap<String, String> = BTreeMap::new();
1426    for key in graph.packages.keys() {
1427        let new_key = apply_dedupe_peers_to_key(key);
1428        *target_counts.entry(new_key.clone()).or_insert(0) += 1;
1429        intended.insert(key.clone(), new_key);
1430    }
1431    let rewrite: BTreeMap<String, String> = intended
1432        .into_iter()
1433        .map(|(old, new)| {
1434            if target_counts.get(&new).copied().unwrap_or(0) > 1 {
1435                tracing::warn!(
1436                    code = aube_codes::warnings::WARN_AUBE_PEER_DEDUPE_COLLISION,
1437                    "dedupe-peers: collision on {new} — keeping {old} in full form to avoid \
1438                     dropping a distinct peer-variant"
1439                );
1440                (old.clone(), old)
1441            } else {
1442                (old, new)
1443            }
1444        })
1445        .collect();
1446
1447    // Rewrite a `(child_name, tail)` reference by reconstructing the
1448    // target's full-form key, looking up its effective rewrite, and
1449    // stripping `child_name@` off the result to recover the tail.
1450    // Tails always follow their target package's rewrite decision,
1451    // so references stay consistent when a collision forces a target
1452    // back to full form.
1453    let rewrite_tail = |child_name: &str, tail: &str| -> String {
1454        let old_key = format!("{child_name}@{tail}");
1455        match rewrite.get(&old_key) {
1456            Some(new_key) => new_key
1457                .strip_prefix(&format!("{child_name}@"))
1458                .map(|s| s.to_string())
1459                .unwrap_or_else(|| tail.to_string()),
1460            None => apply_dedupe_peers_to_tail(tail),
1461        }
1462    };
1463
1464    let mut new_packages: BTreeMap<String, LockedPackage> = BTreeMap::new();
1465    for (old_key, pkg) in graph.packages {
1466        let new_key = rewrite
1467            .get(&old_key)
1468            .cloned()
1469            .unwrap_or_else(|| old_key.clone());
1470        let new_dependencies: BTreeMap<String, String> = pkg
1471            .dependencies
1472            .into_iter()
1473            .map(|(n, v)| {
1474                let new_v = rewrite_tail(&n, &v);
1475                (n, new_v)
1476            })
1477            .collect();
1478        let new_optional_dependencies: BTreeMap<String, String> = pkg
1479            .optional_dependencies
1480            .into_iter()
1481            .map(|(n, v)| {
1482                let new_v = rewrite_tail(&n, &v);
1483                (n, new_v)
1484            })
1485            .collect();
1486        new_packages.insert(
1487            new_key.clone(),
1488            LockedPackage {
1489                name: pkg.name,
1490                version: pkg.version,
1491                integrity: pkg.integrity,
1492                dependencies: new_dependencies,
1493                optional_dependencies: new_optional_dependencies,
1494                peer_dependencies: pkg.peer_dependencies,
1495                peer_dependencies_meta: pkg.peer_dependencies_meta,
1496                dep_path: new_key,
1497                local_source: pkg.local_source,
1498                os: pkg.os,
1499                cpu: pkg.cpu,
1500                libc: pkg.libc,
1501                bundled_dependencies: pkg.bundled_dependencies,
1502                optional: pkg.optional,
1503                transitive_peer_dependencies: pkg.transitive_peer_dependencies,
1504                tarball_url: pkg.tarball_url,
1505                registry_git_hosted: pkg.registry_git_hosted,
1506                alias_of: pkg.alias_of,
1507                yarn_checksum: pkg.yarn_checksum,
1508                engines: pkg.engines,
1509                bin: pkg.bin,
1510                declared_dependencies: pkg.declared_dependencies,
1511                license: pkg.license,
1512                funding_url: pkg.funding_url,
1513                extra_meta: pkg.extra_meta,
1514            },
1515        );
1516    }
1517
1518    let new_importers: BTreeMap<String, Vec<DirectDep>> = graph
1519        .importers
1520        .into_iter()
1521        .map(|(path, deps)| {
1522            let rewritten = deps
1523                .into_iter()
1524                .map(|d| {
1525                    let new_dep_path = rewrite
1526                        .get(&d.dep_path)
1527                        .cloned()
1528                        .unwrap_or_else(|| apply_dedupe_peers_to_key(&d.dep_path));
1529                    DirectDep {
1530                        name: d.name,
1531                        dep_path: new_dep_path,
1532                        dep_type: d.dep_type,
1533                        specifier: d.specifier,
1534                    }
1535                })
1536                .collect();
1537            (path, rewritten)
1538        })
1539        .collect();
1540
1541    LockfileGraph {
1542        importers: new_importers,
1543        packages: new_packages,
1544        settings: graph.settings,
1545        overrides: graph.overrides,
1546        package_extensions_checksum: graph.package_extensions_checksum,
1547        pnpmfile_checksum: graph.pnpmfile_checksum,
1548        ignored_optional_dependencies: graph.ignored_optional_dependencies,
1549        runtimes: graph.runtimes,
1550        times: graph.times,
1551        skipped_optional_dependencies: graph.skipped_optional_dependencies,
1552        catalogs: graph.catalogs,
1553        bun_config_version: graph.bun_config_version,
1554        patched_dependencies: graph.patched_dependencies,
1555        trusted_dependencies: graph.trusted_dependencies,
1556        extra_fields: graph.extra_fields,
1557        workspace_extra_fields: graph.workspace_extra_fields,
1558    }
1559}
1560
1561/// Strip `name@` from inside every parenthesized segment of a full
1562/// dep_path key (e.g. `react-dom@18.2.0(react@18.2.0)` →
1563/// `react-dom@18.2.0(18.2.0)`). The first `name@version` outside any
1564/// parens is preserved verbatim — that's the canonical head of the
1565/// dep_path and `dedupe-peers` only affects the peer suffix.
1566pub(crate) fn apply_dedupe_peers_to_key(key: &str) -> String {
1567    let mut parts = key.split('(');
1568    let Some(first) = parts.next() else {
1569        return key.to_string();
1570    };
1571    let mut out = String::with_capacity(key.len());
1572    out.push_str(first);
1573    for part in parts {
1574        out.push('(');
1575        // In a well-formed key, `part` looks like `name@version)` /
1576        // `name@version` / `version)` / ... We strip everything up to
1577        // and including the LAST `@` (scoped packages like
1578        // `@types/react@18.2.0` contain two `@`s; the separator is the
1579        // rightmost one). We only strip if that `@` comes before the
1580        // first `)` or `(` (i.e. the segment actually starts with
1581        // `name@`, not the outer parens closing with no name inside).
1582        if let Some(at_idx) = part.rfind('@') {
1583            let close_idx = part.find([')', '(']).unwrap_or(usize::MAX);
1584            if at_idx < close_idx {
1585                out.push_str(&part[at_idx + 1..]);
1586                continue;
1587            }
1588        }
1589        out.push_str(part);
1590    }
1591    out
1592}
1593
1594/// Same as [`apply_dedupe_peers_to_key`] but for dep-tail values
1595/// stored in `LockedPackage.dependencies` (e.g. `18.2.0(react@18.2.0)`
1596/// → `18.2.0(18.2.0)`). Tails differ from keys only by lacking the
1597/// leading `name@` prefix — both use the same parens-based suffix
1598/// shape, so the algorithm is identical.
1599fn apply_dedupe_peers_to_tail(tail: &str) -> String {
1600    apply_dedupe_peers_to_key(tail)
1601}
1602
1603#[allow(clippy::too_many_arguments)]
1604fn visit_peer_context<'g>(
1605    input_dep_path: &str,
1606    graph: &'g LockfileGraph,
1607    name_index: &FxHashMap<&'g str, Vec<&'g LockedPackage>>,
1608    ancestor_scope: &FxHashMap<String, PeerProvider>,
1609    root_scope: &FxHashMap<String, PeerProvider>,
1610    out_packages: &mut BTreeMap<String, LockedPackage>,
1611    visiting: &mut FxHashSet<String>,
1612    options: &PeerContextOptions,
1613) -> Option<String> {
1614    let pkg = graph.packages.get(input_dep_path)?;
1615
1616    // The input key may already carry a peer suffix (fixed-point loop
1617    // Pass 2+). Drop it before we build a new one — otherwise we'd
1618    // append the new suffix on top of the old and grow unboundedly
1619    // across iterations (classic mutual-peer-cycle blow-up).
1620    //
1621    // Both suffix forms are parenthesized — the normal nested
1622    // `(name@version)(…)` and the capped `(<short-hash>)` that
1623    // `effective_peer_suffix` emits past `peersSuffixMaxLength` — so
1624    // splitting on the first `(` strips either one. Otherwise each
1625    // pass would re-hash the already-hashed key and grow it (covered
1626    // by the `peer_suffix_is_hashed_when_exceeding_cap` unit test).
1627    let canonical_base = canonical_tail(input_dep_path).to_string();
1628
1629    // Compute peer context: walk declared peers, resolve from ancestors
1630    // (nearest wins — the scope is rebuilt as we recurse) or from the
1631    // package's own dependency map as the auto-install fallback. Both
1632    // sides may produce nested tails on the second and later iterations
1633    // of the fixed-point loop.
1634    // Resolution source priority for each declared peer:
1635    //   1. Ancestor scope — if the ancestor's version actually
1636    //      satisfies the declared peer range. Different subtrees
1637    //      naturally see different ancestors (lib-a in subtree-A
1638    //      and lib-b in subtree-B keep their own peer pins), so
1639    //      preferring the closest ancestor here doesn't conflate
1640    //      cross-subtree variants.
1641    //   2. The current package's own `pkg.dependencies` entry — the
1642    //      BFS peer-walk enqueued this peer with the declared range,
1643    //      so whatever got picked there is guaranteed to satisfy.
1644    //      Captures the case where a single subtree holds two
1645    //      consumers with conflicting peer ranges (lib-a@^17 next to
1646    //      a parent that pins react@18): the BFS auto-installs the
1647    //      satisfying version into lib-a's own deps, which beats the
1648    //      ancestor's incompatible version.
1649    //   3. Ancestor scope — even when the version doesn't satisfy
1650    //      the declared range. This mirrors what Node's module
1651    //      resolution would surface (`require('peer')` from the
1652    //      package would walk up node_modules and find the parent's
1653    //      version). pnpm and bun do the same and emit an unmet-peer
1654    //      warning rather than picking a more-distant matching
1655    //      version. `detect_unmet_peers` flags the mismatch after
1656    //      the pass.
1657    //   4. The current package's own `pkg.dependencies` entry,
1658    //      ignoring range satisfaction — symmetric to (3) for the
1659    //      BFS-installed case.
1660    //   5. Workspace root scope (compatible) — `resolve-peers-from-
1661    //      workspace-root` fallback for monorepos that pin shared
1662    //      peers at the root.
1663    //   6. A graph-wide scan: any package whose name matches and
1664    //      whose version satisfies the declared range. Last resort
1665    //      for nested-context callers when nothing closer has it.
1666    //   7. Workspace root scope, ignoring range satisfaction.
1667    //
1668    // If nothing in the graph holds a version of this peer at all,
1669    // it's left out of the context entirely — `detect_unmet_peers`
1670    // will surface it as a warning after the pass.
1671    //
1672    // Only peers the package actually *declares* in `peerDependencies`
1673    // build a dep_path suffix here. A name present solely in
1674    // `peerDependenciesMeta` (a meta-only optional peer — the way
1675    // `follow-redirects` declares `debug`, for instance) is deliberately
1676    // NOT folded in: pnpm treats such a peer as resolvable but then
1677    // collapses the binding back out via `dedupe-peer-dependents`
1678    // whenever a peer-free path exists, so the realistic lockfile leaves
1679    // the whole chain bare even when a distant ancestor carries that peer
1680    // as a plain dependency. Eagerly binding the meta-only peer from that
1681    // ancestor scope produced `(peer@ver)`-suffixed variants that aube's
1682    // dedupe pass (which only collapses *declared*-peer variants) never
1683    // merged, so the same subtree hashed differently per install scope
1684    // (whole-workspace vs single-member), splitting a shared
1685    // global-virtual-store singleton in two and surfacing at runtime as a
1686    // duplicate-instance "Cannot find module". Matching pnpm's *deduped*
1687    // output — bare — keeps the singleton intact.
1688    let mut peer_context: Vec<(String, PeerProvider)> = Vec::new();
1689    for (peer_name, declared_range) in &pkg.peer_dependencies {
1690        let satisfies_declared = |provider: &PeerProvider| -> bool {
1691            // The tail may carry a nested peer suffix on fixed-point
1692            // iterations 2+; strip it before checking the semver.
1693            let canonical = canonical_tail(&provider.context_tail);
1694            version_satisfies(canonical, declared_range)
1695        };
1696
1697        let from_ancestor = ancestor_scope
1698            .get(peer_name)
1699            .filter(|provider| satisfies_declared(provider))
1700            .cloned();
1701        let from_ancestor_incompatible = ancestor_scope.get(peer_name).cloned();
1702
1703        let from_pkg_deps = pkg
1704            .dependencies
1705            .get(peer_name)
1706            .map(|tail| peer_provider(graph, peer_name, tail))
1707            .filter(|provider| satisfies_declared(provider));
1708        let from_pkg_deps_incompatible = pkg
1709            .dependencies
1710            .get(peer_name)
1711            .map(|tail| peer_provider(graph, peer_name, tail));
1712
1713        // `resolve-peers-from-workspace-root`: fall back to the root
1714        // importer's direct deps before the graph-wide scan. Common in
1715        // monorepos where the workspace root pins shared peers (e.g.
1716        // `react`) that leaf packages peer on without declaring them
1717        // in their own subtree. Skipped when the setting is off —
1718        // matches pnpm's `resolve-peers-from-workspace-root=false`.
1719        let from_root = if options.resolve_from_workspace_root {
1720            root_scope
1721                .get(peer_name)
1722                .filter(|provider| satisfies_declared(provider))
1723                .cloned()
1724        } else {
1725            None
1726        };
1727        let from_root_incompatible = if options.resolve_from_workspace_root {
1728            root_scope.get(peer_name).cloned()
1729        } else {
1730            None
1731        };
1732
1733        // Return the full dep_path TAIL (the part after `name@`), not
1734        // just `p.version`. On fixed-point iteration 2+, the input
1735        // graph's keys are contextualized — e.g. `react-dom` lives at
1736        // `react-dom@18.2.0(react@18.2.0)`. Downstream code
1737        // reconstructs the child lookup key with
1738        // `format!("{child_name}@{tail}")` and needs the tail to
1739        // match whatever the graph has keyed it under, otherwise the
1740        // lookup returns None and the peer gets silently dropped
1741        // from `new_dependencies`. The semver check is against the
1742        // package's canonical `version` field, not the tail, because
1743        // the tail may carry a peer suffix that isn't valid semver.
1744        let from_graph_scan = || {
1745            name_index
1746                .get(peer_name.as_str())
1747                .into_iter()
1748                .flat_map(|bucket| bucket.iter().copied())
1749                .filter(|p| version_satisfies(&p.version, declared_range))
1750                .filter_map(|p| {
1751                    let tail = p
1752                        .dep_path
1753                        .strip_prefix(&format!("{}@", p.name))
1754                        .map(|s| s.to_string())
1755                        .unwrap_or_else(|| p.version.clone());
1756                    node_semver::Version::parse(&p.version).ok().map(|ver| {
1757                        let provider = peer_provider(graph, peer_name, &tail);
1758                        (ver, provider)
1759                    })
1760                })
1761                .max_by(|a, b| a.0.cmp(&b.0))
1762                .map(|(_, provider)| provider)
1763        };
1764
1765        // pnpm resolves an *optional* peer (one flagged
1766        // `peerDependenciesMeta.optional`) only from the resolution path it
1767        // is actually on — the nearest ancestor, the package's own
1768        // auto-installed deps, or the workspace root — and otherwise leaves
1769        // it unresolved so it surfaces under `transitivePeerDependencies`.
1770        // It never reaches for a range-incompatible version or scans the
1771        // whole graph for an unrelated copy. Mirroring that is what lets
1772        // `typescript` (an optional peer the root provides) take a dep-path
1773        // suffix while debug's optional `supports-color` (which nothing on
1774        // the path provides) bubbles up instead of binding to a cousin.
1775        let is_optional = pkg
1776            .peer_dependencies_meta
1777            .get(peer_name)
1778            .is_some_and(|m| m.optional);
1779        let resolved = if is_optional {
1780            from_ancestor.or(from_pkg_deps).or(from_root)
1781        } else {
1782            from_ancestor
1783                .or(from_pkg_deps)
1784                .or(from_ancestor_incompatible)
1785                .or(from_pkg_deps_incompatible)
1786                .or(from_root)
1787                .or_else(from_graph_scan)
1788                .or(from_root_incompatible)
1789        };
1790        if let Some(provider) = resolved {
1791            peer_context.push((peer_name.clone(), provider));
1792        }
1793    }
1794    peer_context.sort_by(|a, b| a.0.cmp(&b.0));
1795
1796    // For the SUFFIX we build a cycle-broken copy: any peer value that
1797    // nests a reference back to the current package's canonical base
1798    // gets stripped to its plain version. Without this, mutual peer
1799    // cycles (a peers on b, b peers on a) grow the suffix one level
1800    // per iteration of the fixed-point loop and never converge.
1801    //
1802    // The non-cycle paths are untouched, so a regular nested chain
1803    // like `(react-dom@18.2.0(react@18.2.0))` still serializes fully.
1804    // We deliberately keep the full nested tails in `peer_context` for
1805    // downstream scope propagation and child lookups — suffix cycle-
1806    // breaking is cosmetic and should not change what packages exist
1807    // or which snapshot entries reference each other.
1808    //
1809    // Cycle detection is always done against the full `name@version`
1810    // canonical base — even when `dedupe-peers=true` is on, because
1811    // the version-only form is ambiguous (two unrelated packages at
1812    // the same version would false-positive). `dedupe-peers` is
1813    // applied as a post-pass over the final graph in
1814    // `dedupe_peer_suffixes` after cycle detection is done.
1815    let suffix: String = peer_context
1816        .iter()
1817        .map(|(n, provider)| {
1818            let cycles_back = contains_canonical_back_ref(&provider.context_tail, &canonical_base);
1819            let display_v = if cycles_back {
1820                canonical_tail(&provider.context_tail).to_string()
1821            } else {
1822                provider.context_tail.clone()
1823            };
1824            format!("({n}@{display_v})")
1825        })
1826        .collect();
1827    // pnpm's `peersSuffixMaxLength`: when the suffix body exceeds the
1828    // cap, `effective_peer_suffix` replaces the whole suffix with a
1829    // parenthesized short hash `(<hash>)` so the lockfile key stays
1830    // bounded and byte-compatible with pnpm's `createPeerDepGraphHash`.
1831    let effective_suffix = effective_peer_suffix(&suffix, options.peers_suffix_max_length);
1832    let contextualized = format!("{canonical_base}{effective_suffix}");
1833
1834    if out_packages.contains_key(&contextualized) || visiting.contains(&contextualized) {
1835        return Some(contextualized);
1836    }
1837    visiting.insert(contextualized.clone());
1838
1839    // Build the scope for P's children. This is ancestor_scope, overlaid
1840    // with P's own dependencies and its resolved peer map. Children see
1841    // their grandparents too — this mirrors pnpm's all-the-way-up peer
1842    // walk.
1843    //
1844    // We deliberately do NOT strip any existing peer-context suffix
1845    // off the tails we put into the scope. On the first pass the
1846    // values are plain (BFS output has no suffixes), so preserving
1847    // them is a no-op; on subsequent passes (see the fixed-point loop
1848    // in `apply_peer_contexts`) the input graph already carries
1849    // contextualized tails, and keeping them in scope is exactly how
1850    // nested peer suffixes propagate down to consumers — a package
1851    // that peers on `react-dom` and reaches it through a parent whose
1852    // `react-dom` entry is already `18.2.0(react@18.2.0)` will see
1853    // that nested tail in its own scope, and its own suffix will
1854    // serialize as `(react-dom@18.2.0(react@18.2.0))`. That's the
1855    // nested form pnpm writes.
1856    let mut child_scope = ancestor_scope.clone();
1857    for (name, tail) in &pkg.dependencies {
1858        child_scope.insert(name.clone(), peer_provider(graph, name, tail));
1859    }
1860    for (name, provider) in &peer_context {
1861        child_scope.insert(name.clone(), provider.clone());
1862    }
1863
1864    // Recurse into each child, rewriting its dependency map entry to
1865    // point at the contextualized dep_path's tail. A child whose visit
1866    // fails (orphaned / missing) keeps its own tail.
1867    //
1868    // For declared peer names, the peer context (filled from the
1869    // ancestor scope) is authoritative — we override whatever the BFS
1870    // peer walk auto-installed. Otherwise the snapshot suffix and the
1871    // actual wired `dependencies[peer]` could disagree, which made the
1872    // sibling symlink target inconsistent with the peer-context claim.
1873    // When the ancestor's version doesn't satisfy the declared range,
1874    // `detect_unmet_peers` will flag it as a warning after the pass.
1875    let peer_context_versions: FxHashMap<String, PeerProvider> =
1876        peer_context.iter().cloned().collect();
1877
1878    let mut new_dependencies: BTreeMap<String, String> = BTreeMap::new();
1879    let mut visited_dep_names: FxHashSet<String> = FxHashSet::default();
1880
1881    for (child_name, child_version_tail) in &pkg.dependencies {
1882        // If this child is a declared peer, its tail comes from the
1883        // peer context (which may be nested). Otherwise we use the
1884        // tail we already have — also possibly nested on a 2nd pass.
1885        let lookup_tail = match peer_context_versions.get(child_name) {
1886            Some(provider) => provider.target_tail.clone(),
1887            None => child_version_tail.clone(),
1888        };
1889        let child_canonical_dep_path = format!("{child_name}@{lookup_tail}");
1890        let child_new = visit_peer_context(
1891            &child_canonical_dep_path,
1892            graph,
1893            name_index,
1894            &child_scope,
1895            root_scope,
1896            out_packages,
1897            visiting,
1898            options,
1899        );
1900        let new_tail = match child_new {
1901            Some(new_dep_path) => new_dep_path
1902                .strip_prefix(&format!("{child_name}@"))
1903                .map(|s| s.to_string())
1904                .unwrap_or_else(|| lookup_tail.clone()),
1905            None => lookup_tail.clone(),
1906        };
1907        new_dependencies.insert(child_name.clone(), new_tail);
1908        visited_dep_names.insert(child_name.clone());
1909    }
1910
1911    // Peers that were satisfied purely from the ancestor scope may not
1912    // have been in `pkg.dependencies` at all (no auto-install needed).
1913    // Wire them as deps now so the linker creates the sibling symlink
1914    // and the lockfile snapshot records them.
1915    for (peer_name, provider) in &peer_context {
1916        if visited_dep_names.contains(peer_name) {
1917            continue;
1918        }
1919        let child_canonical_dep_path = format!("{peer_name}@{}", provider.target_tail);
1920        let child_new = visit_peer_context(
1921            &child_canonical_dep_path,
1922            graph,
1923            name_index,
1924            &child_scope,
1925            root_scope,
1926            out_packages,
1927            visiting,
1928            options,
1929        );
1930        if let Some(new_dep_path) = child_new {
1931            let new_tail = new_dep_path
1932                .strip_prefix(&format!("{peer_name}@"))
1933                .map(|s| s.to_string())
1934                .unwrap_or_else(|| provider.target_tail.clone());
1935            new_dependencies.insert(peer_name.clone(), new_tail);
1936        }
1937    }
1938
1939    visiting.remove(&contextualized);
1940    let new_optional_dependencies: BTreeMap<String, String> = pkg
1941        .optional_dependencies
1942        .keys()
1943        .filter_map(|name| {
1944            new_dependencies
1945                .get(name)
1946                .map(|tail| (name.clone(), tail.clone()))
1947        })
1948        .collect();
1949
1950    out_packages.insert(
1951        contextualized.clone(),
1952        LockedPackage {
1953            name: pkg.name.clone(),
1954            version: pkg.version.clone(),
1955            integrity: pkg.integrity.clone(),
1956            dependencies: new_dependencies,
1957            optional_dependencies: new_optional_dependencies,
1958            peer_dependencies: pkg.peer_dependencies.clone(),
1959            peer_dependencies_meta: pkg.peer_dependencies_meta.clone(),
1960            dep_path: contextualized.clone(),
1961            local_source: pkg.local_source.clone(),
1962            os: pkg.os.clone(),
1963            cpu: pkg.cpu.clone(),
1964            libc: pkg.libc.clone(),
1965            bundled_dependencies: pkg.bundled_dependencies.clone(),
1966            optional: pkg.optional,
1967            transitive_peer_dependencies: pkg.transitive_peer_dependencies.clone(),
1968            tarball_url: pkg.tarball_url.clone(),
1969            registry_git_hosted: pkg.registry_git_hosted,
1970            alias_of: pkg.alias_of.clone(),
1971            yarn_checksum: pkg.yarn_checksum.clone(),
1972            engines: pkg.engines.clone(),
1973            bin: pkg.bin.clone(),
1974            declared_dependencies: pkg.declared_dependencies.clone(),
1975            license: pkg.license.clone(),
1976            funding_url: pkg.funding_url.clone(),
1977            extra_meta: pkg.extra_meta.clone(),
1978        },
1979    );
1980    Some(contextualized)
1981}
1982
1983#[cfg(test)]
1984mod tests {
1985    use super::*;
1986    use aube_lockfile::{DepType, DirectDep, PeerDepMeta};
1987
1988    fn locked(name: &str, deps: &[(&str, &str)]) -> LockedPackage {
1989        LockedPackage {
1990            name: name.to_string(),
1991            version: "1.0.0".to_string(),
1992            dep_path: format!("{name}@1.0.0"),
1993            dependencies: deps
1994                .iter()
1995                .map(|(n, v)| ((*n).to_string(), (*v).to_string()))
1996                .collect(),
1997            ..Default::default()
1998        }
1999    }
2000
2001    /// `root -> app -> {plugin, sibling}` and `sibling -> theme`. `theme`
2002    /// is only ever a *cousin* of `plugin` (never an ancestor, the root,
2003    /// or one of plugin's own deps), so the single way to reach it from
2004    /// plugin's peer is the graph-wide scan.
2005    fn graph_with_cousin_peer() -> LockfileGraph {
2006        let mut g = LockfileGraph::default();
2007        g.importers.insert(
2008            ".".to_string(),
2009            vec![DirectDep {
2010                name: "app".to_string(),
2011                dep_path: "app@1.0.0".to_string(),
2012                dep_type: DepType::Production,
2013                specifier: Some("1.0.0".to_string()),
2014            }],
2015        );
2016        for p in [
2017            locked("app", &[("plugin", "1.0.0"), ("sibling", "1.0.0")]),
2018            locked("plugin", &[]),
2019            locked("sibling", &[("theme", "1.0.0")]),
2020            locked("theme", &[]),
2021        ] {
2022            g.packages.insert(p.dep_path.clone(), p);
2023        }
2024        g
2025    }
2026
2027    #[test]
2028    fn optional_peer_is_not_bound_via_graph_scan() {
2029        let mut g = graph_with_cousin_peer();
2030        let plugin = g.packages.get_mut("plugin@1.0.0").expect("plugin present");
2031        plugin
2032            .peer_dependencies
2033            .insert("theme".to_string(), "*".to_string());
2034        plugin
2035            .peer_dependencies_meta
2036            .insert("theme".to_string(), PeerDepMeta { optional: true });
2037
2038        let out = apply_peer_contexts(g, &PeerContextOptions::default()).expect("peer pass");
2039
2040        assert!(
2041            out.packages.contains_key("plugin@1.0.0"),
2042            "plugin keeps bare key"
2043        );
2044        assert!(
2045            !out.packages.contains_key("plugin@1.0.0(theme@1.0.0)"),
2046            "an optional peer reachable only via the graph scan must stay \
2047             unresolved so it surfaces under transitivePeerDependencies"
2048        );
2049    }
2050
2051    #[test]
2052    fn required_peer_still_binds_via_graph_scan() {
2053        // Same shape, but `theme` is a *required* peer (no meta entry):
2054        // the graph-wide scan still binds it, proving the narrowing above
2055        // is specific to optional peers and not a regression.
2056        let mut g = graph_with_cousin_peer();
2057        let plugin = g.packages.get_mut("plugin@1.0.0").expect("plugin present");
2058        plugin
2059            .peer_dependencies
2060            .insert("theme".to_string(), "*".to_string());
2061
2062        let out = apply_peer_contexts(g, &PeerContextOptions::default()).expect("peer pass");
2063
2064        assert!(
2065            out.packages.contains_key("plugin@1.0.0(theme@1.0.0)"),
2066            "a required peer should still resolve through the graph-wide scan"
2067        );
2068    }
2069
2070    #[test]
2071    fn linked_workspace_root_peer_uses_manifest_version_and_local_target() {
2072        let mut g = LockfileGraph::default();
2073        g.importers.insert(
2074            ".".to_string(),
2075            vec![DirectDep {
2076                name: "theme".to_string(),
2077                dep_path: "theme@link+0123456789abcdef".to_string(),
2078                dep_type: DepType::Production,
2079                specifier: Some("link:theme".to_string()),
2080            }],
2081        );
2082        g.importers.insert(
2083            "packages/app".to_string(),
2084            vec![DirectDep {
2085                name: "plugin".to_string(),
2086                dep_path: "plugin@1.0.0".to_string(),
2087                dep_type: DepType::Production,
2088                specifier: Some("1.0.0".to_string()),
2089            }],
2090        );
2091        g.packages.insert(
2092            "theme@link+0123456789abcdef".to_string(),
2093            LockedPackage {
2094                name: "theme".to_string(),
2095                version: "4.10.0".to_string(),
2096                dep_path: "theme@link+0123456789abcdef".to_string(),
2097                local_source: Some(LocalSource::Link("theme".into())),
2098                ..Default::default()
2099            },
2100        );
2101        let mut plugin = locked("plugin", &[]);
2102        plugin
2103            .peer_dependencies
2104            .insert("theme".to_string(), "^4.0.0".to_string());
2105        plugin
2106            .peer_dependencies_meta
2107            .insert("theme".to_string(), PeerDepMeta { optional: true });
2108        g.packages.insert(plugin.dep_path.clone(), plugin);
2109
2110        let out = apply_peer_contexts(g, &PeerContextOptions::default()).expect("peer pass");
2111
2112        let plugin = out
2113            .packages
2114            .get("plugin@1.0.0(theme@4.10.0)")
2115            .expect("linked root dependency should satisfy the peer at its manifest version");
2116        assert_eq!(
2117            plugin.dependencies.get("theme").map(String::as_str),
2118            Some("link+0123456789abcdef"),
2119            "the peer suffix uses the manifest version while the linker keeps the local target"
2120        );
2121    }
2122}