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