@vltpkg/graph 1.0.0-rc.14 → 1.0.0-rc.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/dist/actual/load.d.ts.map +1 -1
  2. package/dist/actual/load.js +3 -2
  3. package/dist/actual/load.js.map +1 -1
  4. package/dist/fixup-added-names.d.ts +4 -1
  5. package/dist/fixup-added-names.d.ts.map +1 -1
  6. package/dist/fixup-added-names.js +16 -0
  7. package/dist/fixup-added-names.js.map +1 -1
  8. package/dist/graph.d.ts +7 -0
  9. package/dist/graph.d.ts.map +1 -1
  10. package/dist/graph.js +7 -0
  11. package/dist/graph.js.map +1 -1
  12. package/dist/ideal/append-nodes.d.ts +14 -2
  13. package/dist/ideal/append-nodes.d.ts.map +1 -1
  14. package/dist/ideal/append-nodes.js +179 -63
  15. package/dist/ideal/append-nodes.js.map +1 -1
  16. package/dist/ideal/build.d.ts.map +1 -1
  17. package/dist/ideal/build.js +11 -1
  18. package/dist/ideal/build.js.map +1 -1
  19. package/dist/ideal/peers.d.ts +70 -7
  20. package/dist/ideal/peers.d.ts.map +1 -1
  21. package/dist/ideal/peers.js +350 -170
  22. package/dist/ideal/peers.js.map +1 -1
  23. package/dist/ideal/refresh-ideal-graph.d.ts +0 -4
  24. package/dist/ideal/refresh-ideal-graph.d.ts.map +1 -1
  25. package/dist/ideal/refresh-ideal-graph.js +2 -24
  26. package/dist/ideal/refresh-ideal-graph.js.map +1 -1
  27. package/dist/ideal/sorting.d.ts +46 -0
  28. package/dist/ideal/sorting.d.ts.map +1 -0
  29. package/dist/ideal/sorting.js +71 -0
  30. package/dist/ideal/sorting.js.map +1 -0
  31. package/dist/ideal/types.d.ts +1 -5
  32. package/dist/ideal/types.d.ts.map +1 -1
  33. package/dist/ideal/types.js.map +1 -1
  34. package/dist/install.d.ts.map +1 -1
  35. package/dist/install.js +12 -0
  36. package/dist/install.js.map +1 -1
  37. package/dist/lockfile/load.d.ts.map +1 -1
  38. package/dist/lockfile/load.js +21 -0
  39. package/dist/lockfile/load.js.map +1 -1
  40. package/dist/lockfile/save.d.ts.map +1 -1
  41. package/dist/lockfile/save.js +2 -2
  42. package/dist/lockfile/save.js.map +1 -1
  43. package/dist/lockfile/types.d.ts +6 -0
  44. package/dist/lockfile/types.d.ts.map +1 -1
  45. package/dist/lockfile/types.js +6 -0
  46. package/dist/lockfile/types.js.map +1 -1
  47. package/dist/reify/index.d.ts +1 -0
  48. package/dist/reify/index.d.ts.map +1 -1
  49. package/dist/reify/index.js +2 -1
  50. package/dist/reify/index.js.map +1 -1
  51. package/dist/update.d.ts.map +1 -1
  52. package/dist/update.js +1 -0
  53. package/dist/update.js.map +1 -1
  54. package/package.json +22 -22
  55. package/dist/ideal/get-ordered-dependencies.d.ts +0 -10
  56. package/dist/ideal/get-ordered-dependencies.d.ts.map +0 -1
  57. package/dist/ideal/get-ordered-dependencies.js +0 -42
  58. package/dist/ideal/get-ordered-dependencies.js.map +0 -1
@@ -4,97 +4,189 @@ import { intersects } from '@vltpkg/semver';
4
4
  import { satisfies } from '@vltpkg/satisfies';
5
5
  import { Spec } from '@vltpkg/spec';
6
6
  import { getDependencies, shorten } from "../dependencies.js";
7
- import { getOrderedDependencies } from "./get-ordered-dependencies.js";
7
+ import { compareByType, getOrderedDependencies } from "./sorting.js";
8
8
  import { longDependencyTypes } from '@vltpkg/types';
9
+ /**
10
+ * Check if a node satisfies a spec within a given context.
11
+ *
12
+ * Wraps the common `satisfies()` call pattern used throughout peer dependency
13
+ * resolution. The satisfaction check requires:
14
+ * - `node.id`: The DepID of the candidate node
15
+ * - `spec`: The spec to satisfy (e.g., `^18.0.0`)
16
+ * - `fromNode.location`: Where the dependency is declared (affects file: specs)
17
+ * - `projectRoot`: For resolving workspace specs
18
+ * - `monorepo`: For workspace-aware resolution
19
+ */
20
+ const nodeSatisfiesSpec = (node, spec, fromNode, graph) => satisfies(node.id, spec, fromNode.location, fromNode.projectRoot, graph.monorepo);
21
+ /**
22
+ * Parse a spec with registry options from a parent node context.
23
+ *
24
+ * Inherits registry configuration from `graph.mainImporter.options` to ensure
25
+ * consistent scope-registry and custom registry mappings. The `fromNode.registry`
26
+ * override allows scoped packages to use their configured registry.
27
+ */
28
+ const parseSpec = (name, bareSpec, fromNode, graph) => Spec.parse(name, bareSpec, {
29
+ ...graph.mainImporter.options,
30
+ registry: fromNode.registry,
31
+ });
32
+ /**
33
+ * Generate a unique cache key for a peer context fork operation.
34
+ *
35
+ * Format: `{baseIndex}::{sortedEntrySignatures}`
36
+ * - `baseIndex`: The parent context's index (0 for initial context)
37
+ * - Entry signature: `{name}|{type}|{targetId}|{spec}` sorted alphabetically
38
+ *
39
+ * This enables caching identical fork operations to avoid creating duplicate
40
+ * peer contexts when the same entries would be added to the same base context.
41
+ */
42
+ const getForkKey = (peerContext, entries) => {
43
+ const base = peerContext.index ?? 0;
44
+ const sig = entries
45
+ .map(e => `${e.spec.final.name}|${e.type}|${e.target?.id ?? '∅'}|${e.spec}`)
46
+ .sort()
47
+ .join(';');
48
+ return `${base}::${sig}`;
49
+ };
50
+ /**
51
+ * Check if parent declares a dep for peerName that the context target doesn't satisfy.
52
+ * If so, the context entry isn't applicable - return true to ignore the mismatch.
53
+ *
54
+ * This prevents cross-importer peer context leakage. Example scenario:
55
+ * - Root importer has `react@^18` in peer context
56
+ * - Workspace A declares `react@^19` as a dependency
57
+ * - When checking compatibility for Workspace A's deps, the `react@^18` context
58
+ * entry shouldn't force a fork because Workspace A will resolve its own react
59
+ *
60
+ * The logic: if parent declares peerName and the context target doesn't satisfy
61
+ * parent's declared spec, the context entry won't be used anyway, so ignore it.
62
+ */
63
+ const shouldIgnoreContextMismatch = (peerName, contextTarget, fromNode, graph) => {
64
+ const parentManifest = fromNode.manifest;
65
+ /* c8 ignore next - edge case: fromNode always has manifest in practice */
66
+ if (!parentManifest)
67
+ return false;
68
+ // Search all dependency types for a declaration of peerName
69
+ for (const depType of longDependencyTypes) {
70
+ const declared = parentManifest[depType]?.[peerName];
71
+ if (!declared)
72
+ continue;
73
+ // Parent declares this package - check if context target satisfies it
74
+ const parentSpec = parseSpec(peerName, declared, fromNode, graph);
75
+ // If context target doesn't satisfy parent's spec, ignore the mismatch
76
+ // because parent will resolve its own version anyway
77
+ return !nodeSatisfiesSpec(contextTarget, parentSpec, fromNode, graph);
78
+ }
79
+ return false;
80
+ };
81
+ /**
82
+ * Build incompatible result if target satisfies the peer spec.
83
+ *
84
+ * Returns an incompatible result only when the target node actually satisfies
85
+ * the peer spec. This matters because:
86
+ * - If target satisfies the spec, it's a valid alternative that conflicts with
87
+ * the existing node's peer edge target
88
+ * - If target doesn't satisfy the spec, it's not a valid peer resolution, so
89
+ * there's no conflict to report
90
+ *
91
+ * The returned `forkEntry` contains the conflicting spec and target, which will
92
+ * be used to create a forked peer context with the alternative resolution.
93
+ */
94
+ const buildIncompatibleResult = (target, peerSpec, type, fromNode, graph) => {
95
+ if (nodeSatisfiesSpec(target, peerSpec, fromNode, graph)) {
96
+ return {
97
+ compatible: false,
98
+ forkEntry: { spec: peerSpec, target, type },
99
+ };
100
+ }
101
+ return undefined;
102
+ };
9
103
  /**
10
104
  * Check if an existing node's peer edges would still resolve to the same
11
105
  * targets from a new parent's context. Returns incompatible info if any
12
106
  * peer would resolve differently, meaning the node should NOT be reused.
107
+ *
108
+ * This is crucial for avoiding incorrect node reuse that would break peer
109
+ * dependency contracts. Three sources of conflict are checked:
110
+ *
111
+ * 1. **Peer context entries**: The global peer context may have resolved a
112
+ * different version of a peer dependency than what the existing node expects.
113
+ *
114
+ * 2. **Already-placed siblings**: The parent node may already have an edge to
115
+ * a different version of the peer dependency.
116
+ *
117
+ * 3. **Not-yet-placed siblings**: The parent's manifest declares a dependency
118
+ * on the same package, and there's a graph node that would satisfy it but
119
+ * differs from what the existing node expects.
13
120
  */
14
121
  export const checkPeerEdgesCompatible = (existingNode, fromNode, peerContext, graph) => {
15
- // No peer deps means always compatible
16
- if (!existingNode.manifest?.peerDependencies ||
17
- Object.keys(existingNode.manifest.peerDependencies).length === 0)
122
+ const peerDeps = existingNode.manifest?.peerDependencies;
123
+ // No peer deps = always compatible
124
+ if (!peerDeps || Object.keys(peerDeps).length === 0) {
18
125
  return { compatible: true };
19
- const peerDeps = existingNode.manifest.peerDependencies;
20
- for (const [peerName, peerSpec] of Object.entries(peerDeps)) {
126
+ }
127
+ for (const [peerName, peerBareSpec] of Object.entries(peerDeps)) {
21
128
  const existingEdge = existingNode.edgesOut.get(peerName);
22
- if (!existingEdge?.to)
23
- continue; // dangling peer, skip
24
- // Check the peer context for what this parent's context would provide
129
+ // CHECK 0: Reject reuse if peer edge doesn't exist yet (node unprocessed).
130
+ // Cannot verify compatibility since peer resolution depends on original
131
+ // placement context, which may differ from current parent's context.
132
+ // Note: Dangling edges (edge exists, no target) are handled separately below.
133
+ // This conservative check prevents incorrect reuse when placement order varies.
134
+ if (existingEdge === undefined) {
135
+ return { compatible: false };
136
+ }
137
+ // Dangling peer edge (edge exists but unresolved) - skip, nothing to conflict with
138
+ if (!existingEdge.to)
139
+ continue;
140
+ const peerSpec = parseSpec(peerName, peerBareSpec, fromNode, graph);
141
+ // CHECK 1: Does peer context have a different target for this peer?
25
142
  const contextEntry = peerContext.get(peerName);
26
- // If context has a different target for this peer, not compatible
27
143
  if (contextEntry?.target &&
28
- contextEntry.target.id !== existingEdge.to.id) {
29
- // Verify the context target would actually satisfy the peer spec
30
- const spec = Spec.parse(peerName, peerSpec, {
31
- ...graph.mainImporter.options,
32
- registry: fromNode.registry,
33
- });
34
- if (satisfies(contextEntry.target.id, spec, fromNode.location, fromNode.projectRoot, graph.monorepo)) {
35
- return {
36
- compatible: false,
37
- forkEntry: {
38
- spec,
39
- target: contextEntry.target,
40
- type: contextEntry.type,
41
- },
42
- };
43
- }
144
+ contextEntry.target.id !== existingEdge.to.id &&
145
+ !shouldIgnoreContextMismatch(peerName, contextEntry.target, fromNode, graph)) {
146
+ const result = buildIncompatibleResult(contextEntry.target, peerSpec, contextEntry.type, fromNode, graph);
147
+ if (result)
148
+ return result;
44
149
  }
45
- // Also check parent's already-placed siblings
150
+ // CHECK 2: Does parent already have an edge to a different version?
46
151
  const siblingEdge = fromNode.edgesOut.get(peerName);
47
152
  if (siblingEdge?.to && siblingEdge.to.id !== existingEdge.to.id) {
48
- const spec = Spec.parse(peerName, peerSpec, {
49
- ...graph.mainImporter.options,
50
- registry: fromNode.registry,
51
- });
52
- if (satisfies(siblingEdge.to.id, spec, fromNode.location, fromNode.projectRoot, graph.monorepo)) {
53
- return {
54
- compatible: false,
55
- forkEntry: {
56
- spec,
57
- target: siblingEdge.to,
58
- type: siblingEdge.type,
59
- },
60
- };
61
- }
153
+ const result = buildIncompatibleResult(siblingEdge.to, peerSpec, siblingEdge.type, fromNode, graph);
154
+ if (result)
155
+ return result;
62
156
  }
63
- // Check parent's manifest for not-yet-placed siblings
64
- // This handles the case where sibling hasn't been placed yet but will be
65
- const parentManifest = fromNode.manifest;
66
- if (parentManifest) {
157
+ // CHECK 3: Does parent's manifest declare this peer, with a different
158
+ // satisfying node already in the graph?
159
+ const manifest = fromNode.manifest;
160
+ let declared;
161
+ let declaredType;
162
+ if (manifest) {
67
163
  for (const depType of longDependencyTypes) {
68
- const depRecord = parentManifest[depType];
69
- if (depRecord?.[peerName]) {
70
- // Parent declares this peer as a dependency
71
- // Check if there's an existing graph node that would satisfy it differently
72
- const parentSpec = Spec.parse(peerName, depRecord[peerName], {
73
- ...graph.mainImporter.options,
74
- registry: fromNode.registry,
75
- });
76
- // Look for a node in the graph that satisfies parent's spec but differs from existing edge
77
- for (const candidateNode of graph.nodes.values()) {
78
- if (candidateNode.name === peerName &&
79
- candidateNode.id !== existingEdge.to.id &&
80
- satisfies(candidateNode.id, parentSpec, fromNode.location, fromNode.projectRoot, graph.monorepo)) {
81
- // Also verify this candidate satisfies the peer spec
82
- const peerSpecParsed = Spec.parse(peerName, peerSpec, {
83
- ...graph.mainImporter.options,
84
- registry: fromNode.registry,
85
- });
86
- if (satisfies(candidateNode.id, peerSpecParsed, fromNode.location, fromNode.projectRoot, graph.monorepo)) {
87
- return {
88
- compatible: false,
89
- forkEntry: {
90
- spec: peerSpecParsed,
91
- target: candidateNode,
92
- type: shorten(depType),
93
- },
94
- };
95
- }
96
- }
97
- }
164
+ const deps = manifest[depType];
165
+ if (deps &&
166
+ typeof deps === 'object' &&
167
+ !Array.isArray(deps) &&
168
+ peerName in deps) {
169
+ declared = deps[peerName];
170
+ declaredType = depType;
171
+ break;
172
+ }
173
+ }
174
+ }
175
+ if (declared && declaredType) {
176
+ const parentSpec = parseSpec(peerName, declared, fromNode, graph);
177
+ for (const candidateNode of graph.nodes.values()) {
178
+ if (candidateNode.name === peerName &&
179
+ candidateNode.id !== existingEdge.to.id &&
180
+ nodeSatisfiesSpec(candidateNode, parentSpec, fromNode, graph) &&
181
+ nodeSatisfiesSpec(candidateNode, peerSpec, fromNode, graph)) {
182
+ return {
183
+ compatible: false,
184
+ forkEntry: {
185
+ spec: peerSpec,
186
+ target: candidateNode,
187
+ type: shorten(declaredType),
188
+ },
189
+ };
98
190
  }
99
191
  }
100
192
  }
@@ -108,26 +200,34 @@ export const retrievePeerContextHash = (peerContext) => {
108
200
  // skips creating the initial peer context ref
109
201
  if (!peerContext?.index)
110
202
  return undefined;
111
- return `ṗ:${peerContext.index}`;
203
+ return `peer.${peerContext.index}`;
112
204
  };
113
205
  /**
114
206
  * Checks if a given spec is compatible with the specs already
115
207
  * assigned to a peer context entry.
116
208
  *
117
- * Returns true if compatible, false otherwise.
209
+ * Returns true if INCOMPATIBLE, false if compatible.
210
+ *
211
+ * Compatibility rules:
212
+ * - **Registry specs**: Uses semver range intersection. `^18.0.0` and `^18.2.0`
213
+ * intersect (compatible), but `^18.0.0` and `^19.0.0` don't (incompatible).
214
+ * - **Non-registry specs** (git, file, etc.): Requires exact bareSpec match.
215
+ * `github:foo/bar#v1` only matches itself.
216
+ *
217
+ * This is used to determine when peer context forking is needed - if specs
218
+ * are incompatible, a new peer context must be created.
118
219
  */
119
220
  export const incompatibleSpecs = (spec, entry) => {
120
221
  if (entry.specs.size > 0) {
121
222
  for (const s_ of entry.specs) {
122
223
  const s = s_.final;
123
224
  if (
124
- // only able to check range intersections for registry types
225
+ // Registry types: check semver range intersection
125
226
  (spec.type === 'registry' &&
126
227
  (!spec.range ||
127
228
  !s.range ||
128
229
  !intersects(spec.range, s.range))) ||
129
- // also support types other than registry in case
130
- // they use the very same bareSpec value
230
+ // Non-registry types: require exact bareSpec match
131
231
  (spec.type !== 'registry' && spec.bareSpec !== s.bareSpec)) {
132
232
  return true;
133
233
  }
@@ -139,15 +239,7 @@ export const incompatibleSpecs = (spec, entry) => {
139
239
  * Sort peer context entry inputs for deterministic processing.
140
240
  * Orders: non-peer dependencies first, then peer dependencies, alphabetically by name.
141
241
  */
142
- export const getOrderedPeerContextEntries = (entries) => [...entries].sort((a, b) => {
143
- const aIsPeer = a.type === 'peer' || a.type === 'peerOptional' ? 1 : 0;
144
- const bIsPeer = b.type === 'peer' || b.type === 'peerOptional' ? 1 : 0;
145
- if (aIsPeer !== bIsPeer)
146
- return aIsPeer - bIsPeer;
147
- const aName = a.target?.name ?? a.spec.name;
148
- const bName = b.target?.name ?? b.spec.name;
149
- return aName.localeCompare(bName, 'en');
150
- });
242
+ export const getOrderedPeerContextEntries = (entries) => [...entries].sort(compareByType);
151
243
  /*
152
244
  * Checks if there are any conflicting versions for a given dependency
153
245
  * to be added to a peer context set which will require forking.
@@ -178,17 +270,13 @@ export const checkEntriesToPeerContext = (peerContext, entries) => {
178
270
  * Returns true if forking is needed, false otherwise.
179
271
  */
180
272
  export const addEntriesToPeerContext = (peerContext, entries, fromNode, monorepo) => {
181
- // pre check to see if any of the new entries to be added to the
182
- // provided peer context set conflicts with existing ones
183
- // if that's already the case we can skip processing them and
184
- // will return that a fork is needed right away
273
+ // pre check for conflicts before processing
185
274
  if (checkEntriesToPeerContext(peerContext, entries))
186
275
  return true;
187
- // iterate on every entry to be added to the peer context set
188
276
  for (const { dependent, spec, target, type } of entries) {
189
277
  const name = target?.name ?? spec.final.name;
190
- // if there's no existing entry, create one
191
278
  let entry = peerContext.get(name);
279
+ // create new entry if none exists
192
280
  if (!entry) {
193
281
  entry = {
194
282
  active: true,
@@ -202,20 +290,17 @@ export const addEntriesToPeerContext = (peerContext, entries, fromNode, monorepo
202
290
  entry.contextDependents.add(dependent);
203
291
  continue;
204
292
  }
205
- // perform an extra check that confirms the new spec does not
206
- // conflicts with existing specs in this entry, this handles the
207
- // case of adding sibling deps that conflicts with one another
208
- if (incompatibleSpecs(spec.final, entry)) {
293
+ // check for sibling dep conflicts
294
+ if (incompatibleSpecs(spec.final, entry))
209
295
  return true;
210
- }
296
+ // update target if compatible with all specs
211
297
  if (target &&
212
298
  [...entry.specs].every(s => satisfies(target.id, s, fromNode.location, fromNode.projectRoot, monorepo))) {
213
299
  if (target.id !== entry.target?.id &&
214
300
  target.version !== entry.target?.version) {
215
- // we have a compatible entry that has a new, compatible target
216
- // so we need to update all dependents to point to the new target
217
- for (const dependents of entry.contextDependents) {
218
- const edge = dependents.edgesOut.get(name);
301
+ // update dependents to point to new target
302
+ for (const dep of entry.contextDependents) {
303
+ const edge = dep.edgesOut.get(name);
219
304
  if (edge?.to && edge.to !== target) {
220
305
  edge.to.edgesIn.delete(edge);
221
306
  edge.to = target;
@@ -224,10 +309,8 @@ export const addEntriesToPeerContext = (peerContext, entries, fromNode, monorepo
224
309
  }
225
310
  entry.target = target;
226
311
  }
227
- // otherwise sets the value in case it was nullish
228
312
  entry.target ??= target;
229
313
  }
230
- // update specs and dependents values
231
314
  entry.specs.add(spec);
232
315
  if (dependent)
233
316
  entry.contextDependents.add(dependent);
@@ -238,11 +321,17 @@ export const addEntriesToPeerContext = (peerContext, entries, fromNode, monorepo
238
321
  * Create and returns a forked copy of a given peer context set.
239
322
  */
240
323
  export const forkPeerContext = (graph, peerContext, entries) => {
324
+ const forkKey = getForkKey(peerContext, entries);
325
+ const cached = graph.peerContextForkCache.get(forkKey);
326
+ if (cached) {
327
+ return cached;
328
+ }
241
329
  // create a new peer context set
242
330
  const nextPeerContext = new Map();
243
331
  nextPeerContext.index = graph.nextPeerContextIndex();
244
332
  // register it in the graph
245
333
  graph.peerContexts[nextPeerContext.index] = nextPeerContext;
334
+ graph.peerContextForkCache.set(forkKey, nextPeerContext);
246
335
  // copy existing entries marking them as inactive, it's also important
247
336
  // to note that specs and contextDependents are new objects so that changes
248
337
  // to those in the new context do not affect the previous one
@@ -271,6 +360,51 @@ export const forkPeerContext = (graph, peerContext, entries) => {
271
360
  }
272
361
  return nextPeerContext;
273
362
  };
363
+ /**
364
+ * Find a peer from queued entries' peer edge closure using BFS.
365
+ *
366
+ * This handles peer dependency cycles like `@isaacs/peer-dep-cycle-a/b/c` where:
367
+ * - A depends on B (peer)
368
+ * - B depends on C (peer)
369
+ * - C depends on A (peer)
370
+ *
371
+ * The BFS explores:
372
+ * 1. Start nodes: All resolved targets from `queuedEntries` (sibling deps)
373
+ * 2. For each node, check if it has an edge to `name` that satisfies `peerSpec`
374
+ * 3. If not found, follow peer edges to explore their peer edges (up to depth 3)
375
+ *
376
+ * Prefers "local" providers (found via sibling's peer edges) over global context.
377
+ */
378
+ const findFromPeerClosure = (name, peerSpec, queuedEntries, fromNode, graph) => {
379
+ // Start BFS from all resolved sibling targets
380
+ const start = queuedEntries
381
+ .map(e => e.target)
382
+ .filter((n) => !!n);
383
+ const seen = new Set();
384
+ const q = start.map(n => ({
385
+ n,
386
+ depth: 0,
387
+ }));
388
+ while (q.length) {
389
+ const cur = q.shift();
390
+ if (!cur || seen.has(cur.n.id))
391
+ continue;
392
+ seen.add(cur.n.id);
393
+ // Check if this node has an edge to the peer we're looking for
394
+ const edge = cur.n.edgesOut.get(name);
395
+ if (edge?.to &&
396
+ nodeSatisfiesSpec(edge.to, peerSpec, fromNode, graph)) {
397
+ return edge.to;
398
+ }
399
+ // Follow peer edges only (not regular deps) to stay in peer closure
400
+ for (const e of cur.n.edgesOut.values()) {
401
+ if ((e.type === 'peer' || e.type === 'peerOptional') && e.to) {
402
+ q.push({ n: e.to, depth: cur.depth + 1 });
403
+ }
404
+ }
405
+ }
406
+ return undefined;
407
+ };
274
408
  /**
275
409
  * Starts the peer dependency placement process
276
410
  * for a given node being processed and placed.
@@ -317,14 +451,28 @@ export const startPeerPlacement = (peerContext, manifest, fromNode, options) =>
317
451
  * Ends the peer dependency placement process, returning the functions that
318
452
  * are going to be used to update the peer context set, forking when needed
319
453
  * and resolving peer dependencies if possible.
454
+ *
455
+ * Returns two deferred functions:
456
+ * - `putEntries()`: Adds entries to peer context; returns fork entries if conflict
457
+ * - `resolvePeerDeps()`: Resolves peer deps from context/siblings or adds to nextDeps
458
+ *
459
+ * These are deferred (not executed immediately) so that all siblings at a level
460
+ * can be processed before peer context updates, enabling context reuse optimization.
320
461
  */
321
462
  export const endPeerPlacement = (peerContext, nextDeps, nextPeerDeps, graph, spec, fromNode, node, type, queuedEntries) => ({
322
463
  /**
323
464
  * Add the new entries to the current peer context set.
465
+ *
466
+ * Two sets of entries are checked:
467
+ * - `prevEntries`: Parent's queued entries + self-reference
468
+ * - `nextEntries`: This node's deps + peer deps (with node as dependent)
469
+ *
470
+ * If either conflicts with the current context, returns ALL entries to be
471
+ * added to a forked context (prevEntries last for priority).
472
+ *
473
+ * Returns `undefined` if no fork needed (entries added directly to context).
324
474
  */
325
475
  putEntries: () => {
326
- // keep track of whether we need to fork the current peer context set
327
- let needsToForkPeerContext = false;
328
476
  // add queued entries from this node parents along
329
477
  // with a self-ref to the current peer context set
330
478
  const prevEntries = [
@@ -335,8 +483,6 @@ export const endPeerPlacement = (peerContext, nextDeps, nextPeerDeps, graph, spe
335
483
  type,
336
484
  },
337
485
  ];
338
- addEntriesToPeerContext(peerContext, prevEntries, fromNode, graph.monorepo);
339
- // add this node's direct dependencies next
340
486
  const nextEntries = [
341
487
  ...nextDeps.map(dep => ({ ...dep, dependent: node })),
342
488
  ...[...nextPeerDeps.values()].map(dep => ({
@@ -344,66 +490,85 @@ export const endPeerPlacement = (peerContext, nextDeps, nextPeerDeps, graph, spe
344
490
  dependent: node,
345
491
  })),
346
492
  ];
493
+ const conflictPrev = checkEntriesToPeerContext(peerContext, prevEntries);
494
+ const conflictNext = nextEntries.length > 0 &&
495
+ checkEntriesToPeerContext(peerContext, nextEntries);
496
+ if (conflictPrev || conflictNext) {
497
+ // returns all entries that need to be added to a forked context
498
+ // giving priority to parent entries (prevEntries) by placing them last
499
+ return [...nextEntries, ...prevEntries];
500
+ }
501
+ addEntriesToPeerContext(peerContext, prevEntries, fromNode, graph.monorepo);
347
502
  if (nextEntries.length > 0) {
348
- needsToForkPeerContext = addEntriesToPeerContext(peerContext, nextEntries, node, graph.monorepo);
503
+ addEntriesToPeerContext(peerContext, nextEntries, node, graph.monorepo);
349
504
  }
350
- // returns all entries that need to be added to a forked
351
- // context or undefined if the current context was updated directly
352
- return needsToForkPeerContext ? nextEntries : undefined;
505
+ return undefined;
353
506
  },
354
507
  /**
355
508
  * Try to resolve peer dependencies using already seen target
356
509
  * values from the current peer context set.
510
+ *
511
+ * Resolution priority (highest to lowest):
512
+ * 1. Sibling deps from parent (workspace direct deps take priority)
513
+ * 2. Peer-edge closure of sibling targets (handles peer cycles)
514
+ * 3. Global peer context set entries
515
+ * 4. Add to nextDeps for normal resolution (or create dangling edge for optional)
357
516
  */
358
517
  resolvePeerDeps: () => {
359
- // iterate on the set of peer dependencies of the current node
360
- // and try to resolve them from the existing peer context set,
361
- // when possible, add them as edges in the graph right away, if not,
362
- // then we move them back to the `nextDeps` list for processing
363
- // along with the rest of the regular dependencies
364
518
  for (const nextDep of nextPeerDeps.values()) {
365
519
  const { spec, type } = nextDep;
366
- if (type === 'peer' || type === 'peerOptional') {
367
- // FIRST: Check if there's a sibling dependency from the parent
368
- // that specifies this same package. Sibling deps take priority
369
- // because they represent the workspace's direct dependency,
370
- // which should be preferred over versions from other workspaces
371
- // that may have been added to the peer context earlier.
372
- const siblingEntry = queuedEntries.find(e => (e.target?.name ?? e.spec.final.name) === spec.final.name);
373
- if (siblingEntry?.target &&
374
- !node.edgesOut.has(spec.final.name) &&
375
- satisfies(siblingEntry.target.id, spec, fromNode.location, fromNode.projectRoot, graph.monorepo)) {
376
- // The sibling's resolved target satisfies the peer spec,
377
- // use it directly - this prioritizes the workspace's own
378
- // direct dependency over versions from other workspaces
379
- graph.addEdge(type, spec, node, siblingEntry.target);
380
- continue;
381
- }
382
- // THEN: Try to retrieve an entry for that peer dep from
383
- // the current peer context set
384
- const entry = peerContext.get(spec.final.name);
385
- if (!node.edgesOut.has(spec.final.name) &&
386
- entry?.target &&
387
- satisfies(entry.target.id, spec, fromNode.location, fromNode.projectRoot, graph.monorepo)) {
388
- // entry satisfied, create edge in the graph
389
- graph.addEdge(type, spec, node, entry.target);
390
- entry.specs.add(spec.final);
391
- }
392
- else if (type === 'peerOptional') {
393
- // skip unsatisfied peerOptional dependencies,
394
- // just create a dangling edge
395
- graph.addEdge(type, spec, node);
396
- }
397
- else if (siblingEntry &&
398
- siblingEntry.spec.bareSpec !== spec.bareSpec) {
399
- // Sibling has a more specific spec for this package,
400
- // use it when resolving to ensure we get the right version
401
- nextDeps.push({ ...nextDep, spec: siblingEntry.spec });
520
+ /* c8 ignore next - only peer types reach here by design */
521
+ if (type !== 'peer' && type !== 'peerOptional')
522
+ continue;
523
+ const name = spec.final.name;
524
+ // PRIORITY 1: Sibling deps from parent
525
+ // These take priority because workspace's direct deps should win over
526
+ // versions from other workspaces that may be in the peer context
527
+ const siblingEntry = queuedEntries.find(e => (e.target?.name ?? e.spec.final.name) === name);
528
+ const siblingTarget = siblingEntry?.target ?? fromNode.edgesOut.get(name)?.to;
529
+ if (siblingTarget &&
530
+ nodeSatisfiesSpec(siblingTarget, spec, fromNode, graph)) {
531
+ // Override existing edge if pointing elsewhere (sibling must win)
532
+ const existingEdge = node.edgesOut.get(name);
533
+ if (existingEdge?.to && existingEdge.to !== siblingTarget) {
534
+ existingEdge.to.edgesIn.delete(existingEdge);
535
+ existingEdge.to = siblingTarget;
536
+ siblingTarget.edgesIn.add(existingEdge);
402
537
  }
403
- else {
404
- // could not satisfy from peer context or sibling, add to next deps
405
- nextDeps.push(nextDep);
538
+ else if (!existingEdge) {
539
+ graph.addEdge(type, spec, node, siblingTarget);
406
540
  }
541
+ continue;
542
+ }
543
+ // PRIORITY 2: Peer-edge closure of sibling targets
544
+ // Handles cycles like A->B(peer)->C(peer)->A(peer)
545
+ const localPeer = findFromPeerClosure(name, spec, queuedEntries, fromNode, graph);
546
+ if (localPeer && !node.edgesOut.has(name)) {
547
+ graph.addEdge(type, spec, node, localPeer);
548
+ continue;
549
+ }
550
+ // PRIORITY 3: Global peer context set
551
+ const entry = peerContext.get(name);
552
+ if (!node.edgesOut.has(name) &&
553
+ entry?.target &&
554
+ nodeSatisfiesSpec(entry.target, spec, fromNode, graph)) {
555
+ graph.addEdge(type, spec, node, entry.target);
556
+ entry.specs.add(spec.final);
557
+ continue;
558
+ }
559
+ // PRIORITY 4: Fallback - add to nextDeps or create dangling edge
560
+ if (type === 'peerOptional') {
561
+ // Optional peers that can't be resolved get a dangling edge
562
+ graph.addEdge(type, spec, node);
563
+ }
564
+ else if (siblingEntry &&
565
+ siblingEntry.spec.bareSpec !== spec.bareSpec) {
566
+ // Sibling has a more specific spec - use it for resolution
567
+ nextDeps.push({ ...nextDep, spec: siblingEntry.spec });
568
+ }
569
+ else {
570
+ // Add to next deps for normal resolution in upcoming levels
571
+ nextDeps.push(nextDep);
407
572
  }
408
573
  }
409
574
  },
@@ -414,45 +579,60 @@ export const endPeerPlacement = (peerContext, nextDeps, nextPeerDeps, graph, spe
414
579
  * dependencies and track peer dependencies in their appropriate peer context
415
580
  * sets, forking as needed and resolving peer dependencies using suitable
416
581
  * nodes already present in the graph if possible.
582
+ *
583
+ * This is the core peer context management algorithm, executed after each
584
+ * BFS level. It runs in three phases:
585
+ *
586
+ * **Phase 1: Collect fork requirements**
587
+ * Call `putEntries()` on each child dep to add entries to peer context.
588
+ * Collect which children need forked contexts (due to conflicts).
589
+ *
590
+ * **Phase 2: Fork or reuse contexts**
591
+ * For children needing forks, try to reuse a sibling's forked context if
592
+ * compatible. This optimization reduces the number of peer contexts created.
593
+ *
594
+ * **Phase 3: Resolve peer deps**
595
+ * With contexts finalized, call `resolvePeerDeps()` to create edges for
596
+ * peers that can be satisfied from context/siblings, or add them to nextDeps.
597
+ *
598
+ * All operations are sorted by `node.id` for deterministic, reproducible builds.
417
599
  */
418
600
  export const postPlacementPeerCheck = (graph, sortedLevelResults) => {
419
- // Update peer contexts in a sorted manner after processing all nodes
420
- // at a given level to ensure deterministic behavior when it comes to
421
- // forking new peer contexts
422
601
  for (const childDepsToProcess of sortedLevelResults) {
423
- // Sort childDepsToProcess deterministically by node.id
602
+ // Sort by node.id for deterministic processing order
424
603
  const sortedChildDeps = [...childDepsToProcess].sort((a, b) => a.node.id.localeCompare(b.node.id, 'en'));
604
+ // PHASE 1: Collect which children need forked contexts
425
605
  const needsForking = new Map();
426
- // first iterate on all child deps, adding entries to the current
427
- // context and collect the information on which ones need forking
428
606
  for (const childDep of sortedChildDeps) {
429
607
  const needsFork = childDep.updateContext.putEntries();
430
608
  if (needsFork) {
431
609
  needsForking.set(childDep, needsFork);
432
610
  }
433
611
  }
434
- // Sort needsForking entries before iterating (Map iteration order = insertion order)
612
+ // Sort forking entries for deterministic fork order
435
613
  const sortedNeedsForkingEntries = [
436
614
  ...needsForking.entries(),
437
615
  ].sort(([a], [b]) => a.node.id.localeCompare(b.node.id, 'en'));
438
- // then iterate again, forking contexts as needed but also try to
439
- // reuse the context of the previous sibling if possible
616
+ // PHASE 2: Fork or reuse sibling contexts
617
+ // Track previous context for potential reuse by next sibling
440
618
  let prevContext;
441
619
  for (const [childDep, nextEntries] of sortedNeedsForkingEntries) {
620
+ // Optimization: try to reuse previous sibling's forked context
621
+ // if its entries are compatible with this child's entries
442
622
  if (prevContext &&
443
623
  !checkEntriesToPeerContext(prevContext, nextEntries)) {
444
- // the context of the previous sibling can be reused
445
624
  addEntriesToPeerContext(prevContext, nextEntries, childDep.node, graph.monorepo);
446
625
  childDep.peerContext = prevContext;
447
626
  continue;
448
627
  }
628
+ // Can't reuse - create a new forked context
449
629
  childDep.peerContext = forkPeerContext(graph, childDep.peerContext, nextEntries);
450
630
  prevContext = childDep.peerContext;
451
631
  }
452
- // try to resolve peer dependencies now that
453
- // the context is fully set up
632
+ // PHASE 3: Resolve peer deps with finalized contexts
454
633
  for (const childDep of sortedChildDeps) {
455
634
  childDep.updateContext.resolvePeerDeps();
635
+ // Re-order deps for deterministic next-level processing
456
636
  childDep.deps = getOrderedDependencies(childDep.deps);
457
637
  }
458
638
  }