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