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