@vltpkg/graph 1.0.0-rc.13 → 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 (72) hide show
  1. package/README.md +97 -22
  2. package/dist/actual/load.d.ts.map +1 -1
  3. package/dist/actual/load.js +3 -2
  4. package/dist/actual/load.js.map +1 -1
  5. package/dist/diff.d.ts +2 -0
  6. package/dist/diff.d.ts.map +1 -1
  7. package/dist/diff.js +5 -1
  8. package/dist/diff.js.map +1 -1
  9. package/dist/fixup-added-names.d.ts +4 -1
  10. package/dist/fixup-added-names.d.ts.map +1 -1
  11. package/dist/fixup-added-names.js +16 -0
  12. package/dist/fixup-added-names.js.map +1 -1
  13. package/dist/graph.d.ts +7 -0
  14. package/dist/graph.d.ts.map +1 -1
  15. package/dist/graph.js +17 -2
  16. package/dist/graph.js.map +1 -1
  17. package/dist/ideal/append-nodes.d.ts +14 -2
  18. package/dist/ideal/append-nodes.d.ts.map +1 -1
  19. package/dist/ideal/append-nodes.js +188 -57
  20. package/dist/ideal/append-nodes.js.map +1 -1
  21. package/dist/ideal/build.d.ts.map +1 -1
  22. package/dist/ideal/build.js +11 -1
  23. package/dist/ideal/build.js.map +1 -1
  24. package/dist/ideal/peers.d.ts +90 -6
  25. package/dist/ideal/peers.d.ts.map +1 -1
  26. package/dist/ideal/peers.js +387 -131
  27. package/dist/ideal/peers.js.map +1 -1
  28. package/dist/ideal/refresh-ideal-graph.d.ts +0 -4
  29. package/dist/ideal/refresh-ideal-graph.d.ts.map +1 -1
  30. package/dist/ideal/refresh-ideal-graph.js +8 -24
  31. package/dist/ideal/refresh-ideal-graph.js.map +1 -1
  32. package/dist/ideal/sorting.d.ts +46 -0
  33. package/dist/ideal/sorting.d.ts.map +1 -0
  34. package/dist/ideal/sorting.js +71 -0
  35. package/dist/ideal/sorting.js.map +1 -0
  36. package/dist/ideal/types.d.ts +2 -6
  37. package/dist/ideal/types.d.ts.map +1 -1
  38. package/dist/ideal/types.js.map +1 -1
  39. package/dist/install.d.ts.map +1 -1
  40. package/dist/install.js +12 -0
  41. package/dist/install.js.map +1 -1
  42. package/dist/lockfile/load.d.ts.map +1 -1
  43. package/dist/lockfile/load.js +21 -0
  44. package/dist/lockfile/load.js.map +1 -1
  45. package/dist/lockfile/save.d.ts.map +1 -1
  46. package/dist/lockfile/save.js +2 -2
  47. package/dist/lockfile/save.js.map +1 -1
  48. package/dist/lockfile/types.d.ts +7 -0
  49. package/dist/lockfile/types.d.ts.map +1 -1
  50. package/dist/lockfile/types.js +6 -0
  51. package/dist/lockfile/types.js.map +1 -1
  52. package/dist/node.d.ts +2 -0
  53. package/dist/node.d.ts.map +1 -1
  54. package/dist/node.js.map +1 -1
  55. package/dist/reify/extract-node.d.ts.map +1 -1
  56. package/dist/reify/extract-node.js.map +1 -1
  57. package/dist/reify/index.d.ts +1 -0
  58. package/dist/reify/index.d.ts.map +1 -1
  59. package/dist/reify/index.js +2 -1
  60. package/dist/reify/index.js.map +1 -1
  61. package/dist/update.d.ts.map +1 -1
  62. package/dist/update.js +1 -0
  63. package/dist/update.js.map +1 -1
  64. package/dist/visualization/mermaid-output.d.ts +2 -1
  65. package/dist/visualization/mermaid-output.d.ts.map +1 -1
  66. package/dist/visualization/mermaid-output.js +28 -16
  67. package/dist/visualization/mermaid-output.js.map +1 -1
  68. package/package.json +22 -22
  69. package/dist/ideal/get-ordered-dependencies.d.ts +0 -10
  70. package/dist/ideal/get-ordered-dependencies.d.ts.map +0 -1
  71. package/dist/ideal/get-ordered-dependencies.js +0 -42
  72. package/dist/ideal/get-ordered-dependencies.js.map +0 -1
@@ -2,8 +2,197 @@
2
2
  // during the ideal graph building process.
3
3
  import { intersects } from '@vltpkg/semver';
4
4
  import { satisfies } from '@vltpkg/satisfies';
5
- import { getDependencies } from "../dependencies.js";
6
- import { getOrderedDependencies } from "./get-ordered-dependencies.js";
5
+ import { Spec } from '@vltpkg/spec';
6
+ import { getDependencies, shorten } from "../dependencies.js";
7
+ import { compareByType, getOrderedDependencies } from "./sorting.js";
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
+ };
103
+ /**
104
+ * Check if an existing node's peer edges would still resolve to the same
105
+ * targets from a new parent's context. Returns incompatible info if any
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.
120
+ */
121
+ export const checkPeerEdgesCompatible = (existingNode, fromNode, peerContext, graph) => {
122
+ const peerDeps = existingNode.manifest?.peerDependencies;
123
+ // No peer deps = always compatible
124
+ if (!peerDeps || Object.keys(peerDeps).length === 0) {
125
+ return { compatible: true };
126
+ }
127
+ for (const [peerName, peerBareSpec] of Object.entries(peerDeps)) {
128
+ const existingEdge = existingNode.edgesOut.get(peerName);
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?
142
+ const contextEntry = peerContext.get(peerName);
143
+ if (contextEntry?.target &&
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;
149
+ }
150
+ // CHECK 2: Does parent already have an edge to a different version?
151
+ const siblingEdge = fromNode.edgesOut.get(peerName);
152
+ if (siblingEdge?.to && siblingEdge.to.id !== existingEdge.to.id) {
153
+ const result = buildIncompatibleResult(siblingEdge.to, peerSpec, siblingEdge.type, fromNode, graph);
154
+ if (result)
155
+ return result;
156
+ }
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) {
163
+ for (const depType of longDependencyTypes) {
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
+ };
190
+ }
191
+ }
192
+ }
193
+ }
194
+ return { compatible: true };
195
+ };
7
196
  /**
8
197
  * Retrieve a unique hash value for a given peer context set.
9
198
  */
@@ -11,25 +200,34 @@ export const retrievePeerContextHash = (peerContext) => {
11
200
  // skips creating the initial peer context ref
12
201
  if (!peerContext?.index)
13
202
  return undefined;
14
- return `ṗ:${peerContext.index}`;
203
+ return `peer.${peerContext.index}`;
15
204
  };
16
205
  /**
17
206
  * Checks if a given spec is compatible with the specs already
18
207
  * assigned to a peer context entry.
19
208
  *
20
- * 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.
21
219
  */
22
220
  export const incompatibleSpecs = (spec, entry) => {
23
221
  if (entry.specs.size > 0) {
24
- for (const s of entry.specs) {
222
+ for (const s_ of entry.specs) {
223
+ const s = s_.final;
25
224
  if (
26
- // only able to check range intersections for registry types
225
+ // Registry types: check semver range intersection
27
226
  (spec.type === 'registry' &&
28
227
  (!spec.range ||
29
228
  !s.range ||
30
229
  !intersects(spec.range, s.range))) ||
31
- // also support types other than registry in case
32
- // they use the very same bareSpec value
230
+ // Non-registry types: require exact bareSpec match
33
231
  (spec.type !== 'registry' && spec.bareSpec !== s.bareSpec)) {
34
232
  return true;
35
233
  }
@@ -41,15 +239,7 @@ export const incompatibleSpecs = (spec, entry) => {
41
239
  * Sort peer context entry inputs for deterministic processing.
42
240
  * Orders: non-peer dependencies first, then peer dependencies, alphabetically by name.
43
241
  */
44
- export const getOrderedPeerContextEntries = (entries) => [...entries].sort((a, b) => {
45
- const aIsPeer = a.type === 'peer' || a.type === 'peerOptional' ? 1 : 0;
46
- const bIsPeer = b.type === 'peer' || b.type === 'peerOptional' ? 1 : 0;
47
- if (aIsPeer !== bIsPeer)
48
- return aIsPeer - bIsPeer;
49
- const aName = a.target?.name ?? a.spec.name;
50
- const bName = b.target?.name ?? b.spec.name;
51
- return aName.localeCompare(bName, 'en');
52
- });
242
+ export const getOrderedPeerContextEntries = (entries) => [...entries].sort(compareByType);
53
243
  /*
54
244
  * Checks if there are any conflicting versions for a given dependency
55
245
  * to be added to a peer context set which will require forking.
@@ -65,7 +255,7 @@ export const checkEntriesToPeerContext = (peerContext, entries) => {
65
255
  if (!entry?.active)
66
256
  continue;
67
257
  // validate if the provided spec is compatible with existing specs
68
- if (incompatibleSpecs(spec, entry)) {
258
+ if (incompatibleSpecs(spec.final, entry)) {
69
259
  return true;
70
260
  }
71
261
  }
@@ -80,17 +270,13 @@ export const checkEntriesToPeerContext = (peerContext, entries) => {
80
270
  * Returns true if forking is needed, false otherwise.
81
271
  */
82
272
  export const addEntriesToPeerContext = (peerContext, entries, fromNode, monorepo) => {
83
- // pre check to see if any of the new entries to be added to the
84
- // provided peer context set conflicts with existing ones
85
- // if that's already the case we can skip processing them and
86
- // will return that a fork is needed right away
273
+ // pre check for conflicts before processing
87
274
  if (checkEntriesToPeerContext(peerContext, entries))
88
275
  return true;
89
- // iterate on every entry to be added to the peer context set
90
276
  for (const { dependent, spec, target, type } of entries) {
91
277
  const name = target?.name ?? spec.final.name;
92
- // if there's no existing entry, create one
93
278
  let entry = peerContext.get(name);
279
+ // create new entry if none exists
94
280
  if (!entry) {
95
281
  entry = {
96
282
  active: true,
@@ -104,39 +290,27 @@ export const addEntriesToPeerContext = (peerContext, entries, fromNode, monorepo
104
290
  entry.contextDependents.add(dependent);
105
291
  continue;
106
292
  }
107
- // perform an extra check that confirms the new spec does not
108
- // conflicts with existing specs in this entry, this handles the
109
- // case of adding sibling deps that conflicts with one another
110
- if (incompatibleSpecs(spec, entry))
293
+ // check for sibling dep conflicts
294
+ if (incompatibleSpecs(spec.final, entry))
111
295
  return true;
296
+ // update target if compatible with all specs
112
297
  if (target &&
113
298
  [...entry.specs].every(s => satisfies(target.id, s, fromNode.location, fromNode.projectRoot, monorepo))) {
114
299
  if (target.id !== entry.target?.id &&
115
300
  target.version !== entry.target?.version) {
116
- // Check if the existing target also satisfies the new spec.
117
- // If it does, we should keep the existing target rather than
118
- // switching to the new one. This preserves pinned versions
119
- // and prevents unnecessary target changes.
120
- const existingTargetSatisfiesNewSpec = entry.target &&
121
- satisfies(entry.target.id, spec, fromNode.location, fromNode.projectRoot, monorepo);
122
- if (!existingTargetSatisfiesNewSpec) {
123
- // Only update all dependents to point to the new target if
124
- // the existing target doesn't satisfy the new spec
125
- for (const dependents of entry.contextDependents) {
126
- const edge = dependents.edgesOut.get(name);
127
- if (edge?.to && edge.to !== target) {
128
- edge.to.edgesIn.delete(edge);
129
- edge.to = target;
130
- target.edgesIn.add(edge);
131
- }
301
+ // update dependents to point to new target
302
+ for (const dep of entry.contextDependents) {
303
+ const edge = dep.edgesOut.get(name);
304
+ if (edge?.to && edge.to !== target) {
305
+ edge.to.edgesIn.delete(edge);
306
+ edge.to = target;
307
+ target.edgesIn.add(edge);
132
308
  }
133
- entry.target = target;
134
309
  }
310
+ entry.target = target;
135
311
  }
136
- // otherwise sets the value in case it was nullish
137
312
  entry.target ??= target;
138
313
  }
139
- // update specs and dependents values
140
314
  entry.specs.add(spec);
141
315
  if (dependent)
142
316
  entry.contextDependents.add(dependent);
@@ -147,24 +321,25 @@ export const addEntriesToPeerContext = (peerContext, entries, fromNode, monorepo
147
321
  * Create and returns a forked copy of a given peer context set.
148
322
  */
149
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
+ }
150
329
  // create a new peer context set
151
330
  const nextPeerContext = new Map();
152
331
  nextPeerContext.index = graph.nextPeerContextIndex();
153
332
  // register it in the graph
154
333
  graph.peerContexts[nextPeerContext.index] = nextPeerContext;
334
+ graph.peerContextForkCache.set(forkKey, nextPeerContext);
155
335
  // copy existing entries marking them as inactive, it's also important
156
336
  // to note that specs and contextDependents are new objects so that changes
157
- // to those in the new context do not affect the previous one.
158
- // IMPORTANT: We preserve the target from the parent context so that packages
159
- // in the forked context can still resolve peer deps to the same version as
160
- // the parent context. This fixes an issue where forked contexts would lose
161
- // track of already-resolved peer dependencies (like a pinned typescript version)
162
- // and resolve to different versions.
337
+ // to those in the new context do not affect the previous one
163
338
  for (const [name, entry] of peerContext.entries()) {
164
339
  nextPeerContext.set(name, {
165
340
  active: false,
166
341
  specs: new Set(entry.specs),
167
- target: entry.target,
342
+ target: undefined,
168
343
  type: entry.type,
169
344
  contextDependents: new Set(entry.contextDependents),
170
345
  });
@@ -174,17 +349,10 @@ export const forkPeerContext = (graph, peerContext, entries) => {
174
349
  for (const entry of entries) {
175
350
  const { dependent, spec, target, type } = entry;
176
351
  const name = target?.name /* c8 ignore next */ ?? spec.final.name;
177
- // IMPORTANT: If the new entry has no target but the parent context had one,
178
- // preserve the parent's target. This ensures that when a fork happens due to
179
- // spec "incompatibility" (e.g., pinned version vs range), we don't lose the
180
- // already-resolved target. The satisfies check in resolvePeerDeps will later
181
- // verify if the preserved target actually satisfies the new spec.
182
- const existingEntry = nextPeerContext.get(name);
183
- const preservedTarget = !target && existingEntry?.target ? existingEntry.target : target;
184
352
  const newEntry = {
185
353
  active: true,
186
354
  specs: new Set([spec]),
187
- target: preservedTarget,
355
+ target,
188
356
  type,
189
357
  contextDependents: dependent ? new Set([dependent]) : new Set(),
190
358
  };
@@ -192,6 +360,51 @@ export const forkPeerContext = (graph, peerContext, entries) => {
192
360
  }
193
361
  return nextPeerContext;
194
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
+ };
195
408
  /**
196
409
  * Starts the peer dependency placement process
197
410
  * for a given node being processed and placed.
@@ -238,14 +451,28 @@ export const startPeerPlacement = (peerContext, manifest, fromNode, options) =>
238
451
  * Ends the peer dependency placement process, returning the functions that
239
452
  * are going to be used to update the peer context set, forking when needed
240
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.
241
461
  */
242
462
  export const endPeerPlacement = (peerContext, nextDeps, nextPeerDeps, graph, spec, fromNode, node, type, queuedEntries) => ({
243
463
  /**
244
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).
245
474
  */
246
475
  putEntries: () => {
247
- // keep track of whether we need to fork the current peer context set
248
- let needsToForkPeerContext = false;
249
476
  // add queued entries from this node parents along
250
477
  // with a self-ref to the current peer context set
251
478
  const prevEntries = [
@@ -256,8 +483,6 @@ export const endPeerPlacement = (peerContext, nextDeps, nextPeerDeps, graph, spe
256
483
  type,
257
484
  },
258
485
  ];
259
- addEntriesToPeerContext(peerContext, prevEntries, fromNode, graph.monorepo);
260
- // add this node's direct dependencies next
261
486
  const nextEntries = [
262
487
  ...nextDeps.map(dep => ({ ...dep, dependent: node })),
263
488
  ...[...nextPeerDeps.values()].map(dep => ({
@@ -265,67 +490,85 @@ export const endPeerPlacement = (peerContext, nextDeps, nextPeerDeps, graph, spe
265
490
  dependent: node,
266
491
  })),
267
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);
268
502
  if (nextEntries.length > 0) {
269
- needsToForkPeerContext = addEntriesToPeerContext(peerContext, nextEntries, node, graph.monorepo);
503
+ addEntriesToPeerContext(peerContext, nextEntries, node, graph.monorepo);
270
504
  }
271
- // returns all entries that need to be added to a forked
272
- // context or undefined if the current context was updated directly
273
- return needsToForkPeerContext ? nextEntries : undefined;
505
+ return undefined;
274
506
  },
275
507
  /**
276
508
  * Try to resolve peer dependencies using already seen target
277
509
  * values from the current peer context set.
278
- * @param {PeerContext} currentContext The current peer context (may be forked from original)
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)
279
516
  */
280
- resolvePeerDeps: (currentContext) => {
281
- // iterate on the set of peer dependencies of the current node
282
- // and try to resolve them from the existing peer context set,
283
- // when possible, add them as edges in the graph right away, if not,
284
- // then we move them back to the `nextDeps` list for processing
285
- // along with the rest of the regular dependencies
517
+ resolvePeerDeps: () => {
286
518
  for (const nextDep of nextPeerDeps.values()) {
287
519
  const { spec, type } = nextDep;
288
- if (type === 'peer' || type === 'peerOptional') {
289
- // FIRST: Check if there's a sibling dependency from the parent
290
- // that specifies this same package. Sibling deps take priority
291
- // because they represent the workspace's direct dependency,
292
- // which should be preferred over versions from other workspaces
293
- // that may have been added to the peer context earlier.
294
- const siblingEntry = queuedEntries.find(e => (e.target?.name ?? e.spec.final.name) === spec.final.name);
295
- if (siblingEntry?.target &&
296
- !node.edgesOut.has(spec.final.name) &&
297
- satisfies(siblingEntry.target.id, spec, fromNode.location, fromNode.projectRoot, graph.monorepo)) {
298
- // The sibling's resolved target satisfies the peer spec,
299
- // use it directly - this prioritizes the workspace's own
300
- // direct dependency over versions from other workspaces
301
- graph.addEdge(type, spec, node, siblingEntry.target);
302
- continue;
303
- }
304
- // THEN: Try to retrieve an entry for that peer dep from
305
- // the current peer context set (which may have been forked)
306
- const entry = currentContext.get(spec.final.name);
307
- if (!node.edgesOut.has(spec.final.name) &&
308
- entry?.target &&
309
- satisfies(entry.target.id, spec, fromNode.location, fromNode.projectRoot, graph.monorepo)) {
310
- // entry satisfied, create edge in the graph
311
- graph.addEdge(type, spec, node, entry.target);
312
- entry.specs.add(spec.final);
313
- }
314
- else if (type === 'peerOptional') {
315
- // skip unsatisfied peerOptional dependencies,
316
- // just create a dangling edge
317
- graph.addEdge(type, spec, node);
318
- }
319
- else if (siblingEntry &&
320
- siblingEntry.spec.bareSpec !== spec.bareSpec) {
321
- // Sibling has a more specific spec for this package,
322
- // use it when resolving to ensure we get the right version
323
- 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);
324
537
  }
325
- else {
326
- // could not satisfy from peer context or sibling, add to next deps
327
- nextDeps.push(nextDep);
538
+ else if (!existingEdge) {
539
+ graph.addEdge(type, spec, node, siblingTarget);
328
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);
329
572
  }
330
573
  }
331
574
  },
@@ -336,47 +579,60 @@ export const endPeerPlacement = (peerContext, nextDeps, nextPeerDeps, graph, spe
336
579
  * dependencies and track peer dependencies in their appropriate peer context
337
580
  * sets, forking as needed and resolving peer dependencies using suitable
338
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.
339
599
  */
340
600
  export const postPlacementPeerCheck = (graph, sortedLevelResults) => {
341
- // Update peer contexts in a sorted manner after processing all nodes
342
- // at a given level to ensure deterministic behavior when it comes to
343
- // forking new peer contexts
344
601
  for (const childDepsToProcess of sortedLevelResults) {
345
- // Sort childDepsToProcess deterministically by node.id
602
+ // Sort by node.id for deterministic processing order
346
603
  const sortedChildDeps = [...childDepsToProcess].sort((a, b) => a.node.id.localeCompare(b.node.id, 'en'));
604
+ // PHASE 1: Collect which children need forked contexts
347
605
  const needsForking = new Map();
348
- // first iterate on all child deps, adding entries to the current
349
- // context and collect the information on which ones need forking
350
606
  for (const childDep of sortedChildDeps) {
351
607
  const needsFork = childDep.updateContext.putEntries();
352
608
  if (needsFork) {
353
609
  needsForking.set(childDep, needsFork);
354
610
  }
355
611
  }
356
- // Sort needsForking entries before iterating (Map iteration order = insertion order)
612
+ // Sort forking entries for deterministic fork order
357
613
  const sortedNeedsForkingEntries = [
358
614
  ...needsForking.entries(),
359
615
  ].sort(([a], [b]) => a.node.id.localeCompare(b.node.id, 'en'));
360
- // then iterate again, forking contexts as needed but also try to
361
- // 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
362
618
  let prevContext;
363
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
364
622
  if (prevContext &&
365
623
  !checkEntriesToPeerContext(prevContext, nextEntries)) {
366
- // the context of the previous sibling can be reused
367
624
  addEntriesToPeerContext(prevContext, nextEntries, childDep.node, graph.monorepo);
368
625
  childDep.peerContext = prevContext;
369
626
  continue;
370
627
  }
628
+ // Can't reuse - create a new forked context
371
629
  childDep.peerContext = forkPeerContext(graph, childDep.peerContext, nextEntries);
372
630
  prevContext = childDep.peerContext;
373
631
  }
374
- // try to resolve peer dependencies now that
375
- // the context is fully set up
632
+ // PHASE 3: Resolve peer deps with finalized contexts
376
633
  for (const childDep of sortedChildDeps) {
377
- // Pass the current peerContext (which may have been forked)
378
- // so resolvePeerDeps uses the correct context with preserved targets
379
- childDep.updateContext.resolvePeerDeps(childDep.peerContext);
634
+ childDep.updateContext.resolvePeerDeps();
635
+ // Re-order deps for deterministic next-level processing
380
636
  childDep.deps = getOrderedDependencies(childDep.deps);
381
637
  }
382
638
  }