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