@vltpkg/graph 1.0.6 → 1.0.8

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 (38) hide show
  1. package/dist/actual/load.js +1 -0
  2. package/dist/dependencies.d.ts +16 -0
  3. package/dist/dependencies.js +24 -0
  4. package/dist/fixup-added-names.js +4 -2
  5. package/dist/graph.d.ts +41 -4
  6. package/dist/graph.js +145 -25
  7. package/dist/ideal/append-nodes.d.ts +2 -2
  8. package/dist/ideal/append-nodes.js +226 -60
  9. package/dist/ideal/build-ideal-from-starting-graph.js +28 -0
  10. package/dist/ideal/canonicalize-peer-ids.d.ts +44 -0
  11. package/dist/ideal/canonicalize-peer-ids.js +421 -0
  12. package/dist/ideal/get-importer-specs.d.ts +5 -1
  13. package/dist/ideal/get-importer-specs.js +51 -24
  14. package/dist/ideal/move-peer-store-dirs.d.ts +7 -0
  15. package/dist/ideal/move-peer-store-dirs.js +59 -0
  16. package/dist/ideal/peers.d.ts +16 -5
  17. package/dist/ideal/peers.js +187 -31
  18. package/dist/ideal/refresh-ideal-graph.d.ts +11 -2
  19. package/dist/ideal/refresh-ideal-graph.js +62 -4
  20. package/dist/ideal/remove-satisfied-specs.d.ts +11 -2
  21. package/dist/ideal/remove-satisfied-specs.js +18 -1
  22. package/dist/ideal/types.d.ts +20 -0
  23. package/dist/install.js +22 -3
  24. package/dist/lockfile/format-options-change.d.ts +5 -0
  25. package/dist/lockfile/format-options-change.js +20 -0
  26. package/dist/lockfile/load-nodes.js +3 -1
  27. package/dist/lockfile/load.d.ts +7 -1
  28. package/dist/lockfile/load.js +63 -7
  29. package/dist/lockfile/types.d.ts +17 -0
  30. package/dist/modifiers.d.ts +10 -0
  31. package/dist/modifiers.js +45 -9
  32. package/dist/node.d.ts +12 -0
  33. package/dist/node.js +42 -0
  34. package/dist/reify/index.js +30 -18
  35. package/dist/reify/update-importers-package-json.js +12 -2
  36. package/dist/transfer-data/load.js +3 -3
  37. package/dist/uninstall.js +2 -0
  38. package/package.json +21 -21
@@ -3,7 +3,7 @@
3
3
  import { intersects } from '@vltpkg/semver';
4
4
  import { satisfies } from '@vltpkg/satisfies';
5
5
  import { Spec } from '@vltpkg/spec';
6
- import { getDependencies, shorten } from "../dependencies.js";
6
+ import { getDependencies, shorten, shouldInstallDepType, } from "../dependencies.js";
7
7
  import { compareByType, getOrderedDependencies } from "./sorting.js";
8
8
  import { longDependencyTypes } from '@vltpkg/types';
9
9
  /**
@@ -29,15 +29,40 @@ const parseSpec = (name, bareSpec, fromNode, graph) => Spec.parse(name, bareSpec
29
29
  ...graph.mainImporter.options,
30
30
  registry: fromNode.registry ?? graph.mainImporter.options.registry,
31
31
  });
32
+ /**
33
+ * Take private ownership of an entry's `specs` before writing to it: a
34
+ * fork shares the base's map until either side writes. Any other holder
35
+ * keeps `sharedSpecs` and clones on its own first write, so no refcount
36
+ * is needed.
37
+ */
38
+ const ownSpecs = (entry) => {
39
+ if (entry.sharedSpecs) {
40
+ entry.specs = new Map(entry.specs);
41
+ entry.sharedSpecs = false;
42
+ }
43
+ return entry.specs;
44
+ };
45
+ /**
46
+ * Bump the base's revision: an entry was added, or an entry's target
47
+ * moved. Invalidates every fork key taken from it.
48
+ */
49
+ const bumpRev = (peerContext) => {
50
+ peerContext.rev = (peerContext.rev ?? 0) + 1;
51
+ };
32
52
  /**
33
53
  * Generate a unique cache key for a peer context fork operation.
34
54
  *
35
- * Format: `{baseIndex}::{sortedEntrySignatures}`
55
+ * Format: `{baseIndex}::{sortedEntrySignatures}::r{baseRev}`
36
56
  * - `baseIndex`: The parent context's index (0 for initial context)
37
57
  * - Entry signature: `{name}|{type}|{targetId}|{spec}` sorted alphabetically
58
+ * - `baseRev`: the base's revision, bumped on every mapping change
38
59
  *
39
60
  * This enables caching identical fork operations to avoid creating duplicate
40
61
  * peer contexts when the same entries would be added to the same base context.
62
+ * Forks snapshot the base's targets, so the base index alone is not enough:
63
+ * once a base target moves, an identical request must get a fresh fork
64
+ * instead of the snapshot taken before the move. The revision stands in for
65
+ * serialising the whole inherited mapping, which was O(context) per fork.
41
66
  */
42
67
  const getForkKey = (peerContext, entries) => {
43
68
  const base = peerContext.index ?? 0;
@@ -45,7 +70,7 @@ const getForkKey = (peerContext, entries) => {
45
70
  .map(e => `${e.spec.final.name}|${e.type}|${e.target?.id ?? '∅'}|${e.spec}`)
46
71
  .sort()
47
72
  .join(';');
48
- return `${base}::${sig}`;
73
+ return `${base}::${sig}::r${peerContext.rev ?? 0}`;
49
74
  };
50
75
  /**
51
76
  * Check if parent declares a dep for peerName that the context target doesn't satisfy.
@@ -65,8 +90,12 @@ const shouldIgnoreContextMismatch = (peerName, contextTarget, fromNode, graph) =
65
90
  /* c8 ignore next - edge case: fromNode always has manifest in practice */
66
91
  if (!parentManifest)
67
92
  return false;
68
- // Search all dependency types for a declaration of peerName
93
+ // Search all dependency types for a declaration of peerName. Only types
94
+ // the parent actually installs count: a registry parent's devDependencies
95
+ // are never placed, so they do not mean it resolves its own copy.
69
96
  for (const depType of longDependencyTypes) {
97
+ if (!shouldInstallDepType(fromNode, depType))
98
+ continue;
70
99
  const declared = parentManifest[depType]?.[peerName];
71
100
  if (!declared)
72
101
  continue;
@@ -100,6 +129,67 @@ const buildIncompatibleResult = (target, peerSpec, type, fromNode, graph) => {
100
129
  }
101
130
  return undefined;
102
131
  };
132
+ /**
133
+ * Which node does `fromNode` (or its placement context) offer for
134
+ * `peerName`? Checked in the order `resolvePeerDeps` will use: the
135
+ * parent's own edge, then a dependency the parent declares but has not
136
+ * placed yet, then the context entry. Only targets satisfying `peerSpec`
137
+ * count.
138
+ */
139
+ const findProvidedPeer = (peerName, peerSpec, fromNode, peerContext, parseOpts, satisfiesNodeSpec, pending) => {
140
+ const sibling = fromNode.edgesOut.get(peerName);
141
+ if (sibling?.to && satisfiesNodeSpec(sibling.to, peerSpec)) {
142
+ return { target: sibling.to, spec: peerSpec, type: sibling.type };
143
+ }
144
+ // what the parent is about to place at this level: it already reflects
145
+ // explicit adds and removes, transient deps and modifier swaps, none of
146
+ // which reach the manifest before reify. when given it is
147
+ // authoritative; the manifest is only consulted without it
148
+ let declared;
149
+ const pendingDep = pending?.get(peerName);
150
+ if (pendingDep) {
151
+ declared = { spec: pendingDep.spec, type: pendingDep.type };
152
+ }
153
+ else if (!pending && fromNode.manifest) {
154
+ const manifest = fromNode.manifest;
155
+ for (const depType of longDependencyTypes) {
156
+ if (!shouldInstallDepType(fromNode, depType))
157
+ continue;
158
+ const bare = manifest[depType]?.[peerName];
159
+ if (!bare)
160
+ continue;
161
+ const type = shorten(depType, peerName, manifest);
162
+ // the parent's own optional peer may never resolve; a required one
163
+ // is placed or resolved, so it does become the parent's edge
164
+ if (type === 'peerOptional')
165
+ continue;
166
+ declared = { spec: Spec.parse(peerName, bare, parseOpts), type };
167
+ break;
168
+ }
169
+ }
170
+ if (declared) {
171
+ // what the parent installs is decided by its own placement (existing
172
+ // edge, then lock, then findResolution), so no node is predicted
173
+ // here: a target-less fork entry lets the parent's live edge resolve
174
+ // the peer at the end of the level. disjoint ranges cannot both be
175
+ // satisfied, so there is nothing to fork for
176
+ const pf = declared.spec.final;
177
+ const sf = peerSpec.final;
178
+ if (pf.type === 'registry' &&
179
+ sf.type === 'registry' &&
180
+ pf.range &&
181
+ sf.range &&
182
+ !intersects(pf.range, sf.range)) {
183
+ return undefined;
184
+ }
185
+ return { spec: declared.spec, type: declared.type };
186
+ }
187
+ const entry = peerContext.get(peerName);
188
+ if (entry?.target && satisfiesNodeSpec(entry.target, peerSpec)) {
189
+ return { target: entry.target, spec: peerSpec, type: entry.type };
190
+ }
191
+ return undefined;
192
+ };
103
193
  /**
104
194
  * Check if an existing node's peer edges would still resolve to the same
105
195
  * targets from a new parent's context. Returns incompatible info if any
@@ -118,7 +208,7 @@ const buildIncompatibleResult = (target, peerSpec, type, fromNode, graph) => {
118
208
  * on the same package, and there's a graph node that would satisfy it but
119
209
  * differs from what the existing node expects.
120
210
  */
121
- export const checkPeerEdgesCompatible = (existingNode, fromNode, peerContext, graph) => {
211
+ export const checkPeerEdgesCompatible = (existingNode, fromNode, peerContext, graph, pending) => {
122
212
  const peerDeps = existingNode.manifest?.peerDependencies;
123
213
  // No peer deps = always compatible
124
214
  if (!peerDeps || Object.keys(peerDeps).length === 0) {
@@ -163,9 +253,27 @@ export const checkPeerEdgesCompatible = (existingNode, fromNode, peerContext, gr
163
253
  if (existingEdge === undefined) {
164
254
  return { compatible: false };
165
255
  }
166
- // Dangling peer edge (edge exists but unresolved) - skip, nothing to conflict with
167
- if (!existingEdge.to)
256
+ // Dangling peer edge (edge exists but unresolved). An optional one
257
+ // means the copy was placed where nothing provided the peer: a parent
258
+ // that does provide it cannot share that copy, it has to fork so the
259
+ // peer gets linked. A dangling required peer is skipped as before.
260
+ if (!existingEdge.to) {
261
+ if (existingEdge.type === 'peerOptional') {
262
+ const provided = findProvidedPeer(peerName, Spec.parse(peerName, peerBareSpec, parseOpts), fromNode, peerContext, parseOpts, satisfiesNodeSpec, pending);
263
+ if (provided) {
264
+ return {
265
+ compatible: false,
266
+ forkEntry: {
267
+ spec: provided.spec,
268
+ target: provided.target,
269
+ type: provided.type,
270
+ },
271
+ keepVersion: true,
272
+ };
273
+ }
274
+ }
168
275
  continue;
276
+ }
169
277
  const peerSpec = Spec.parse(peerName, peerBareSpec, parseOpts);
170
278
  // CHECK 1: Does peer context have a different target for this peer?
171
279
  const contextEntry = peerContext.get(peerName);
@@ -208,6 +316,8 @@ export const checkPeerEdgesCompatible = (existingNode, fromNode, peerContext, gr
208
316
  let declaredType;
209
317
  if (manifest) {
210
318
  for (const depType of longDependencyTypes) {
319
+ if (!shouldInstallDepType(fromNode, depType))
320
+ continue;
211
321
  const deps = manifest[depType];
212
322
  if (deps &&
213
323
  typeof deps === 'object' &&
@@ -231,6 +341,8 @@ export const checkPeerEdgesCompatible = (existingNode, fromNode, peerContext, gr
231
341
  const candidates = graph.nodesByName.get(peerName);
232
342
  if (candidates) {
233
343
  for (const candidateNode of candidates) {
344
+ if (candidateNode.detached)
345
+ continue;
234
346
  if (candidateNode.id !== existingEdge.to.id &&
235
347
  satisfiesNodeSpec(candidateNode, parentSpec) &&
236
348
  satisfiesNodeSpec(candidateNode, peerSpec)) {
@@ -254,10 +366,9 @@ export const checkPeerEdgesCompatible = (existingNode, fromNode, peerContext, gr
254
366
  * Retrieve a unique hash value for a given peer context set.
255
367
  */
256
368
  export const retrievePeerContextHash = (peerContext) => {
257
- // skips creating the initial peer context ref
258
- if (!peerContext?.index)
369
+ if (!peerContext)
259
370
  return undefined;
260
- return `peer.${peerContext.index}`;
371
+ return `peer.${peerContext.index ?? 0}`;
261
372
  };
262
373
  /**
263
374
  * Generate a dedupe key for a spec stored in a peer context entry's
@@ -338,12 +449,19 @@ export const checkEntriesToPeerContext = (peerContext, entries) => {
338
449
  * peer context set. Extra info such as a target or dependent nodes is
339
450
  * optional.
340
451
  *
452
+ * When an entry gains a target that satisfies every spec collected so far,
453
+ * dependents of that entry are re-pointed at it only if their current target
454
+ * no longer satisfies one of those specs, so two equally valid resolutions
455
+ * never swap back and forth across rebuilds.
456
+ *
341
457
  * Returns true if forking is needed, false otherwise.
342
458
  */
343
459
  export const addEntriesToPeerContext = (peerContext, entries, fromNode, monorepo) => {
344
460
  // pre check for conflicts before processing
345
461
  if (checkEntriesToPeerContext(peerContext, entries))
346
462
  return true;
463
+ /** Does `node` satisfy every spec collected in `entry`? */
464
+ const satisfiesEntrySpecs = (node, entry, from) => [...entry.specs.values()].every(s => satisfies(node.id, s, from.location, from.projectRoot, monorepo));
347
465
  for (const { dependent, spec, target, type } of entries) {
348
466
  const name = target?.name ?? spec.final.name;
349
467
  let entry = peerContext.get(name);
@@ -357,6 +475,7 @@ export const addEntriesToPeerContext = (peerContext, entries, fromNode, monorepo
357
475
  contextDependents: new Set(),
358
476
  };
359
477
  peerContext.set(name, entry);
478
+ bumpRev(peerContext);
360
479
  if (dependent)
361
480
  entry.contextDependents.add(dependent);
362
481
  continue;
@@ -364,27 +483,41 @@ export const addEntriesToPeerContext = (peerContext, entries, fromNode, monorepo
364
483
  // check for sibling dep conflicts
365
484
  if (incompatibleSpecs(spec.final, entry))
366
485
  return true;
486
+ // collect the incoming spec before anything reads the entry: it
487
+ // constrains both the new target and any dependent kept on an older one
488
+ const specKey = peerSpecKey(spec);
489
+ if (!entry.specs.has(specKey))
490
+ ownSpecs(entry).set(specKey, spec);
367
491
  // update target if compatible with all specs
368
- if (target &&
369
- [...entry.specs.values()].every(s => satisfies(target.id, s, fromNode.location, fromNode.projectRoot, monorepo))) {
492
+ if (target && satisfiesEntrySpecs(target, entry, fromNode)) {
493
+ // two peer copies of the same version are the same resolution here:
494
+ // moving a dependent between them would only change its peer set
370
495
  if (target.id !== entry.target?.id &&
371
496
  target.version !== entry.target?.version) {
372
- // update dependents to point to new target
497
+ // re-point only dependents whose target this context has outgrown;
498
+ // one that still satisfies every spec is as valid as the new target,
499
+ // so a rebuild never swaps between two valid resolutions
373
500
  for (const dep of entry.contextDependents) {
374
501
  const edge = dep.edgesOut.get(name);
375
- if (edge?.to && edge.to !== target) {
376
- edge.to.edgesIn.delete(edge);
377
- edge.to = target;
378
- target.edgesIn.add(edge);
379
- }
502
+ if (!edge?.to || edge.to === target)
503
+ continue;
504
+ if (satisfiesEntrySpecs(edge.to, entry, dep))
505
+ continue;
506
+ dep.graph.mutations++;
507
+ edge.to.edgesIn.delete(edge);
508
+ edge.to = target;
509
+ target.edgesIn.add(edge);
380
510
  }
381
511
  entry.target = target;
512
+ bumpRev(peerContext);
513
+ }
514
+ else if (!entry.target) {
515
+ // only reachable when the branch above was skipped because
516
+ // target.version is undefined
517
+ entry.target = target;
518
+ bumpRev(peerContext);
382
519
  }
383
- entry.target ??= target;
384
520
  }
385
- const specKey = peerSpecKey(spec);
386
- if (!entry.specs.has(specKey))
387
- entry.specs.set(specKey, spec);
388
521
  if (dependent)
389
522
  entry.contextDependents.add(dependent);
390
523
  }
@@ -405,16 +538,24 @@ export const forkPeerContext = (graph, peerContext, entries) => {
405
538
  // register it in the graph
406
539
  graph.peerContexts[nextPeerContext.index] = nextPeerContext;
407
540
  graph.peerContextForkCache.set(forkKey, nextPeerContext);
408
- // copy existing entries marking them as inactive, it's also important
409
- // to note that specs and contextDependents are new objects so that changes
410
- // to those in the new context do not affect the previous one
541
+ // copy existing entries marking them as inactive. specs are shared with
542
+ // the base until either side writes (both are flagged, so a base write
543
+ // after the fork cannot leak into it), but dependents are NOT inherited:
544
+ // they were placed in the parent context, so a target update in this
545
+ // fork must never re-point their edges.
546
+ // the target IS inherited: what a context resolves a name to must not
547
+ // depend on which fork a subtree happened to land in. it stays a
548
+ // snapshot (`getForkKey` invalidates it when the base moves) and is
549
+ // still guarded by `nodeSatisfiesSpec` at resolution time.
411
550
  for (const [name, entry] of peerContext.entries()) {
551
+ entry.sharedSpecs = true;
412
552
  nextPeerContext.set(name, {
413
553
  active: false,
414
- specs: new Map(entry.specs),
415
- target: undefined,
554
+ sharedSpecs: true,
555
+ specs: entry.specs,
556
+ target: entry.target,
416
557
  type: entry.type,
417
- contextDependents: new Set(entry.contextDependents),
558
+ contextDependents: new Set(),
418
559
  });
419
560
  }
420
561
  // add the new entries to this peer context set, marking them as active
@@ -422,9 +563,20 @@ export const forkPeerContext = (graph, peerContext, entries) => {
422
563
  for (const entry of entries) {
423
564
  const { dependent, spec, target, type } = entry;
424
565
  const name = target?.name /* c8 ignore next */ ?? spec.final.name;
566
+ // a fork entry pointing the name at the node the base already
567
+ // resolved it to is the same resolution, so keep the constraints the
568
+ // base collected. dropping them leaves only this entry's (often wide)
569
+ // peer range, and CHECK 1's leniency then reads that as "any copy
570
+ // whose peer satisfies the range is fine", sharing a copy that is
571
+ // linked to a different node
572
+ const inherited = peerContext.get(name);
573
+ const specs = inherited?.target && inherited.target === target ?
574
+ new Map(inherited.specs)
575
+ : new Map();
576
+ specs.set(peerSpecKey(spec), spec);
425
577
  const newEntry = {
426
578
  active: true,
427
- specs: new Map([[peerSpecKey(spec), spec]]),
579
+ specs,
428
580
  target,
429
581
  type,
430
582
  contextDependents: dependent ? new Set([dependent]) : new Set(),
@@ -598,12 +750,16 @@ export const endPeerPlacement = (peerContext, nextDeps, nextPeerDeps, graph, spe
598
750
  // These take priority because workspace's direct deps should win over
599
751
  // versions from other workspaces that may be in the peer context
600
752
  const siblingEntry = queuedEntries.find(e => (e.target?.name ?? e.spec.final.name) === name);
601
- const siblingTarget = siblingEntry?.target ?? fromNode.edgesOut.get(name)?.to;
753
+ // prefer the parent's live edge target over the queued snapshot:
754
+ // the snapshot is captured at placement time and may be stale if a
755
+ // peer context target update has since re-pointed the parent's edge
756
+ const siblingTarget = fromNode.edgesOut.get(name)?.to ?? siblingEntry?.target;
602
757
  if (siblingTarget &&
603
758
  nodeSatisfiesSpec(siblingTarget, spec, fromNode, graph)) {
604
759
  // Override existing edge if pointing elsewhere (sibling must win)
605
760
  const existingEdge = node.edgesOut.get(name);
606
761
  if (existingEdge?.to && existingEdge.to !== siblingTarget) {
762
+ graph.mutations++;
607
763
  existingEdge.to.edgesIn.delete(existingEdge);
608
764
  existingEdge.to = siblingTarget;
609
765
  siblingTarget.edgesIn.add(existingEdge);
@@ -628,7 +784,7 @@ export const endPeerPlacement = (peerContext, nextDeps, nextPeerDeps, graph, spe
628
784
  graph.addEdge(type, spec, node, entry.target);
629
785
  const finalKey = peerSpecKey(spec.final);
630
786
  if (!entry.specs.has(finalKey)) {
631
- entry.specs.set(finalKey, spec.final);
787
+ ownSpecs(entry).set(finalKey, spec.final);
632
788
  }
633
789
  continue;
634
790
  }
@@ -2,7 +2,7 @@ import type { PathScurry } from 'path-scurry';
2
2
  import type { PackageInfoClient } from '@vltpkg/package-info';
3
3
  import type { SpecOptions } from '@vltpkg/spec';
4
4
  import type { RollbackRemove } from '@vltpkg/rollback-remove';
5
- import type { BuildIdealAddOptions, BuildIdealFromGraphOptions, BuildIdealRemoveOptions, TransientAddMap, TransientRemoveMap } from './types.ts';
5
+ import type { BuildIdealAddOptions, BuildIdealFromGraphOptions, BuildIdealRemoveOptions, ExplicitAddMap, TransientAddMap, TransientRemoveMap } from './types.ts';
6
6
  import type { GraphModifier } from '../modifiers.ts';
7
7
  import type { Graph } from '../graph.ts';
8
8
  export type RefreshIdealGraphOptions = BuildIdealAddOptions & BuildIdealRemoveOptions & BuildIdealFromGraphOptions & SpecOptions & {
@@ -26,6 +26,11 @@ export type RefreshIdealGraphOptions = BuildIdealAddOptions & BuildIdealRemoveOp
26
26
  * A {@link RollbackRemove} instance to handle extraction rollbacks
27
27
  */
28
28
  remover: RollbackRemove;
29
+ /**
30
+ * Dependency names that came from an explicit user request, keyed by
31
+ * the id of the node receiving them.
32
+ */
33
+ explicit?: ExplicitAddMap;
29
34
  /**
30
35
  * Dependencies to be added to non-importer nodes when they are placed.
31
36
  * Used for nested folder dependencies that are not importers.
@@ -36,8 +41,12 @@ export type RefreshIdealGraphOptions = BuildIdealAddOptions & BuildIdealRemoveOp
36
41
  * Used for nested folder dependencies that are not importers.
37
42
  */
38
43
  transientRemove?: TransientRemoveMap;
44
+ /**
45
+ * Only the lockfile is being written, so nothing is extracted.
46
+ */
47
+ lockfileOnly?: boolean;
39
48
  };
40
49
  /**
41
50
  * Rebuilds the provided ideal graph.
42
51
  */
43
- export declare const refreshIdealGraph: ({ add, graph, modifiers, packageInfo, scurry, actual, remove, remover, transientAdd, transientRemove, ...specOptions }: RefreshIdealGraphOptions) => Promise<void>;
52
+ export declare const refreshIdealGraph: ({ add, graph, modifiers, packageInfo, scurry, actual, explicit, remove, remover, transientAdd, transientRemove, lockfileOnly, ...specOptions }: RefreshIdealGraphOptions) => Promise<void>;
@@ -1,5 +1,6 @@
1
1
  import { appendNodes } from "./append-nodes.js";
2
2
  import { compareByHasPeerDeps, getNodeOrderedDependencies, } from "./sorting.js";
3
+ import { baseDepID } from '@vltpkg/dep-id';
3
4
  /**
4
5
  * Returns an ordered list of importer nodes.
5
6
  */
@@ -18,12 +19,25 @@ const getOrderedImporters = (graph) => {
18
19
  /**
19
20
  * Rebuilds the provided ideal graph.
20
21
  */
21
- export const refreshIdealGraph = async ({ add, graph, modifiers, packageInfo, scurry, actual, remove, remover, transientAdd, transientRemove, ...specOptions }) => {
22
+ export const refreshIdealGraph = async ({ add, graph, modifiers, packageInfo, scurry, actual, explicit, remove, remover, transientAdd, transientRemove, lockfileOnly, ...specOptions }) => {
22
23
  const seen = new Set();
23
- const extractPromises = [];
24
+ // early extraction only warms the store for the reify that follows;
25
+ // a lockfile-only run has none, and the next install re-extracts
26
+ // everything anyway since it never wrote a hidden lockfile
27
+ const extractPromises = lockfileOnly ? undefined : [];
24
28
  const seenExtracted = new Set();
25
29
  // gets an ordered list of importers to ensure deterministic processing
26
30
  const orderedImporters = getOrderedImporters(graph);
31
+ // importer edge spec text as the starting graph had it. a rebuild that
32
+ // only changes the text (`vlt i foo@1.2.3` over a `foo@^1.2.3` edge)
33
+ // produces no node diff, so reify has to be told the lockfile on disk
34
+ // no longer describes the graph
35
+ const specTexts = new Map();
36
+ for (const importer of graph.importers) {
37
+ for (const [name, edge] of importer.edgesOut) {
38
+ specTexts.set(`${importer.id}\0${name}`, edge.spec.bareSpec);
39
+ }
40
+ }
27
41
  const depsPerImporter = new Map();
28
42
  for (const importer of orderedImporters) {
29
43
  // gets an ordered list of dependencies for this importer
@@ -35,7 +49,41 @@ export const refreshIdealGraph = async ({ add, graph, modifiers, packageInfo, sc
35
49
  if (add.modifiedDependencies ||
36
50
  remove.modifiedDependencies ||
37
51
  graph.optionsChanged) {
52
+ const locked = new Map();
53
+ const ambiguous = new Set();
54
+ const record = (key, id) => {
55
+ if (ambiguous.has(key))
56
+ return;
57
+ const prev = locked.get(key);
58
+ if (prev === undefined) {
59
+ locked.set(key, id);
60
+ }
61
+ else if (prev !== id) {
62
+ // two peer copies of the same base resolve the name differently;
63
+ // a base-keyed lookup would be a guess, so drop the key
64
+ locked.delete(key);
65
+ ambiguous.add(key);
66
+ }
67
+ };
68
+ for (const edge of graph.edges) {
69
+ if (!edge.to)
70
+ continue;
71
+ record(`${edge.from.id}\0${edge.spec.name}`, edge.to.id);
72
+ // peer-fork rebuilds mint provisional `peer.N` parent ids that miss
73
+ // the canonical `peer.<hash>` keys captured here, so also key by
74
+ // the base id when that is unambiguous across peer copies
75
+ const base = baseDepID(edge.from.id);
76
+ if (base !== edge.from.id) {
77
+ record(`${base}\0${edge.spec.name}`, edge.to.id);
78
+ }
79
+ }
80
+ graph.lockedResolutions = locked;
38
81
  graph.resetEdges();
82
+ // the options block on disk no longer matches the config this graph
83
+ // was built with, so the lockfile has to be written even when the
84
+ // rebuild lands on the same nodes and edges
85
+ if (graph.optionsChanged)
86
+ graph.lockfileStale = true;
39
87
  }
40
88
  // iterates on the list of dependencies per importer updating
41
89
  // the graph using metadata fetch from the registry manifest files
@@ -51,14 +99,24 @@ export const refreshIdealGraph = async ({ add, graph, modifiers, packageInfo, sc
51
99
  const modifierRefs = modifiers?.tryDependencies(importer, deps);
52
100
  // Add new nodes for packages defined in the dependencies list fetching
53
101
  // metadata from the registry manifests and updating the graph
54
- await appendNodes(packageInfo, graph, importer, deps, scurry, specOptions, seen, addedDeps, modifiers, modifierRefs, extractPromises, actual, seenExtracted, remover, transientAdd, transientRemove);
102
+ await appendNodes(packageInfo, graph, importer, deps, scurry, specOptions, seen, addedDeps, modifiers, modifierRefs, extractPromises, actual, seenExtracted, remover, transientAdd, transientRemove, explicit);
103
+ }
104
+ // locked resolutions only apply to the rebuild that captured them
105
+ graph.lockedResolutions = undefined;
106
+ for (const importer of graph.importers) {
107
+ for (const [name, edge] of importer.edgesOut) {
108
+ if (specTexts.get(`${importer.id}\0${name}`) !==
109
+ edge.spec.bareSpec) {
110
+ graph.lockfileStale = true;
111
+ }
112
+ }
55
113
  }
56
114
  // set default node locations, if possible
57
115
  for (const node of graph.nodes.values()) {
58
116
  node.setDefaultLocation();
59
117
  }
60
118
  // Wait for all extraction promises to complete
61
- if (extractPromises.length > 0) {
119
+ if (extractPromises?.length) {
62
120
  await Promise.all(extractPromises);
63
121
  }
64
122
  };
@@ -1,7 +1,16 @@
1
+ import type { Edge } from '../edge.ts';
2
+ import type { Dependency } from '../dependencies.ts';
1
3
  import type { BuildIdealAddOptions, BuildIdealFromGraphOptions } from './types.ts';
2
- export type RemoveSatisfiedSpecsOptions = BuildIdealAddOptions & BuildIdealFromGraphOptions;
4
+ import type { GraphModifier } from '../modifiers.ts';
5
+ export type RemoveSatisfiedSpecsOptions = BuildIdealAddOptions & BuildIdealFromGraphOptions & {
6
+ modifiers?: GraphModifier;
7
+ };
3
8
  /**
4
9
  * Traverse the objects defined in `add` and removes any references to specs
5
10
  * that are already satisfied by the contents of the actual `graph`.
11
+ *
12
+ * Returns the satisfied edges whose spec text or dependency type differs
13
+ * from the one they were pruned against, e.g. a lockfile edge reading
14
+ * `peer latest` where `package.json` reads `devDependencies: ^1.2.3`.
6
15
  */
7
- export declare const removeSatisfiedSpecs: ({ add, graph, }: RemoveSatisfiedSpecsOptions) => void;
16
+ export declare const removeSatisfiedSpecs: ({ add, graph, modifiers, }: RemoveSatisfiedSpecsOptions) => Map<Edge, Dependency>;
@@ -3,8 +3,13 @@ import { satisfies } from '@vltpkg/satisfies';
3
3
  /**
4
4
  * Traverse the objects defined in `add` and removes any references to specs
5
5
  * that are already satisfied by the contents of the actual `graph`.
6
+ *
7
+ * Returns the satisfied edges whose spec text or dependency type differs
8
+ * from the one they were pruned against, e.g. a lockfile edge reading
9
+ * `peer latest` where `package.json` reads `devDependencies: ^1.2.3`.
6
10
  */
7
- export const removeSatisfiedSpecs = ({ add, graph, }) => {
11
+ export const removeSatisfiedSpecs = ({ add, graph, modifiers, }) => {
12
+ const staleSpecs = new Map();
8
13
  for (const [depID, dependencies] of add.entries()) {
9
14
  const importer = graph.nodes.get(depID);
10
15
  if (!importer) {
@@ -29,6 +34,17 @@ export const removeSatisfiedSpecs = ({ add, graph, }) => {
29
34
  // If the current graph edge is already valid, then we remove that
30
35
  // dependency item from the list of items to be added to the graph
31
36
  if (satisfies(edge.to?.id, dependency.spec, edge.from.location, graph.projectRoot, graph.monorepo)) {
37
+ // a governed edge carries the modifier value by construction:
38
+ // healing it to the manifest text would only be undone by the
39
+ // rebuild that re-applies the override. `implicit` is not a real
40
+ // edge type, it means "keep what the edge already is", so it
41
+ // never makes an edge stale
42
+ if ((edge.spec.bareSpec !== dependency.spec.bareSpec ||
43
+ (dependency.type !== 'implicit' &&
44
+ edge.type !== dependency.type)) &&
45
+ !modifiers?.targetsImporterEdge(importer, dependency.spec)) {
46
+ staleSpecs.set(edge, dependency);
47
+ }
32
48
  dependencies.delete(name);
33
49
  }
34
50
  }
@@ -39,4 +55,5 @@ export const removeSatisfiedSpecs = ({ add, graph, }) => {
39
55
  add.delete(depID);
40
56
  }
41
57
  }
58
+ return staleSpecs;
42
59
  };
@@ -1,3 +1,4 @@
1
+ import type { DepID } from '@vltpkg/dep-id';
1
2
  import type { PackageInfoClient } from '@vltpkg/package-info';
2
3
  import type { Spec } from '@vltpkg/spec';
3
4
  import type { DependencySaveType } from '@vltpkg/types';
@@ -18,6 +19,13 @@ export type TransientAddMap = Omit<AddImportersDependenciesMap, 'modifiedDepende
18
19
  * When these nodes are processed, their dependencies in this map are excluded.
19
20
  */
20
21
  export type TransientRemoveMap = Omit<RemoveImportersDependenciesMap, 'modifiedDependencies'>;
22
+ /**
23
+ * Dependency names the user asked for on the command line, keyed by the
24
+ * {@link DepID} of the node receiving them. Snapshotted before
25
+ * manifest-derived deltas are merged into `add`, so it is the only
26
+ * reliable signal that a spec came from an explicit request.
27
+ */
28
+ export type ExplicitAddMap = Map<DepID, Set<string>>;
21
29
  export type BuildIdealAddOptions = {
22
30
  /**
23
31
  * A {@link AddImportersDependenciesMap} in which keys are {@link DepID}
@@ -87,6 +95,11 @@ export type PeerContextEntry = {
87
95
  * instead of piling up per dependent.
88
96
  */
89
97
  specs: Map<string, Spec>;
98
+ /**
99
+ * `specs` is shared with the entry this one was forked from or into.
100
+ * Write through `ownSpecs()` so the other side keeps its snapshot.
101
+ */
102
+ sharedSpecs?: boolean;
90
103
  /** The target Node that satisfies all specs for this peer context entry */
91
104
  target: Node | undefined;
92
105
  /** The type of dependency this entry represents */
@@ -108,4 +121,11 @@ export type PeerContextEntryInput = {
108
121
  */
109
122
  export type PeerContext = Map<string, PeerContextEntry> & {
110
123
  index?: number;
124
+ /**
125
+ * Bumped whenever an entry is added or an entry's target changes, i.e.
126
+ * whenever the name -> target mapping a fork snapshots moves. Part of
127
+ * the fork cache key so a base that moved cannot hand out the fork
128
+ * taken before the move.
129
+ */
130
+ rev?: number;
111
131
  };