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