@vltpkg/graph 1.0.7 → 1.0.9

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
@@ -0,0 +1,421 @@
1
+ import { joinDepIDTuple, joinExtra, splitDepID } from '@vltpkg/dep-id';
2
+ import { createHash } from 'node:crypto';
3
+ import { copyPackageMetadata } from "../node.js";
4
+ const NUL = '\0';
5
+ const NL = '\n';
6
+ const MISSING = 'MISSING';
7
+ const PEER_PREFIX = 'peer.';
8
+ /**
9
+ * Any change to this digest, to `serializeNodeEnv`, to `unitEntry` or to
10
+ * the suffix format changes every peer id in the lockfile, and the ideal
11
+ * builder no longer re-hashes a graph it did not otherwise touch. Bump
12
+ * `LOCKFILE_VERSION` alongside it so old lockfiles hard-fail instead of
13
+ * lingering with stale ids.
14
+ */
15
+ export const peerEnvDigest = (input, length = 16) => createHash('sha256').update(input).digest('hex').slice(0, length);
16
+ /**
17
+ * `peer.<ordinal>` as minted by retrievePeerContextHash() during a
18
+ * rebuild, or written by lockfiles from before the hash format.
19
+ * Canonical suffixes are 16 or 32 hex chars, optionally followed by
20
+ * `.<role>` or `.x`; an all-digit hash is possible, hence the length
21
+ * bound rather than a plain `\d+`.
22
+ */
23
+ export const isProvisionalPeerSuffix = (suffix) => /^peer\.\d{1,15}$/.test(suffix);
24
+ /**
25
+ * True when any node still carries a provisional id, so the graph needs
26
+ * a canonicalization pass even if this build did not write to it.
27
+ */
28
+ export const hasProvisionalPeerIds = (graph) => {
29
+ for (const node of graph.nodes.values()) {
30
+ if (node.peerSetHash &&
31
+ isProvisionalPeerSuffix(node.peerSetHash)) {
32
+ return true;
33
+ }
34
+ }
35
+ return false;
36
+ };
37
+ export const byteCompare = (a, b) => a < b ? -1
38
+ : a > b ? 1
39
+ : 0;
40
+ const canCarryPeerSuffix = (node) => {
41
+ if (node.importer)
42
+ return false;
43
+ const type = splitDepID(node.id)[0];
44
+ return type !== 'file' && type !== 'workspace';
45
+ };
46
+ const hasPeerDependencies = (node, graph) => {
47
+ const mani = node.manifest ?? graph.manifests.get(node.id);
48
+ return !!(mani?.peerDependencies &&
49
+ Object.keys(mani.peerDependencies).length > 0);
50
+ };
51
+ const hasPeerOutEdge = (node) => {
52
+ for (const edge of node.edgesOut.values()) {
53
+ if (edge.type === 'peer' || edge.type === 'peerOptional') {
54
+ return true;
55
+ }
56
+ }
57
+ return false;
58
+ };
59
+ export const isPeerScoped = (node, graph) => {
60
+ if (!canCarryPeerSuffix(node))
61
+ return false;
62
+ return (!!node.peerSetHash ||
63
+ hasPeerDependencies(node, graph) ||
64
+ hasPeerOutEdge(node));
65
+ };
66
+ /**
67
+ * The dependency environment of a node: its out edges as
68
+ * `name NUL type NUL ref`, byte-sorted and NL-joined. Only how a target
69
+ * is referenced varies, so callers pass that policy in; the field set,
70
+ * delimiters, missing-target marker and ordering stay one definition.
71
+ */
72
+ const serializeEdges = (node, ref) => {
73
+ const entries = [];
74
+ for (const edge of node.edgesOut.values()) {
75
+ entries.push(`${edge.name}${NUL}${edge.type}${NUL}${edge.to ? ref(edge.to) : MISSING}`);
76
+ }
77
+ entries.sort(byteCompare);
78
+ return entries.join(NL);
79
+ };
80
+ export const serializeNodeEnv = (node, opts = {}) => {
81
+ const { resolvedIds, intraSccIndex } = opts;
82
+ return serializeEdges(node, to => {
83
+ const idx = intraSccIndex?.get(to);
84
+ return idx === undefined ?
85
+ (resolvedIds?.get(to) ?? to.id)
86
+ : `#${idx}`;
87
+ });
88
+ };
89
+ const withPeerSuffix = (node, peerSetHash) => {
90
+ const extra = joinExtra({
91
+ modifier: node.modifier,
92
+ peerSetHash,
93
+ });
94
+ const parsed = splitDepID(node.id);
95
+ switch (parsed[0]) {
96
+ case 'registry':
97
+ return joinDepIDTuple(['registry', parsed[1], parsed[2], extra]);
98
+ case 'git':
99
+ return joinDepIDTuple(['git', parsed[1], parsed[2], extra]);
100
+ case 'remote':
101
+ return joinDepIDTuple(['remote', parsed[1], extra]);
102
+ /* c8 ignore next 2 */
103
+ default:
104
+ return node.id;
105
+ }
106
+ };
107
+ /**
108
+ * The node's identity minus only its peer component. `baseDepID()` also
109
+ * drops the modifier, and intra-SCC targets serialize positionally, so
110
+ * with a bare base a cycle member's modifier would leave no trace in the
111
+ * cycle's identity and two cycles differing only by it would merge.
112
+ */
113
+ const peerlessDepID = (node) => withPeerSuffix(node, '');
114
+ /**
115
+ * One member's contribution to an SCC's identity. A singleton and a
116
+ * one-class SCC must produce the same string, or collapsing a cycle
117
+ * renames the survivor on the next pass.
118
+ */
119
+ const unitEntry = (node, opts) => `${peerlessDepID(node)}${NL}${serializeNodeEnv(node, opts)}`;
120
+ const tarjan = (nodes, adj) => {
121
+ let index = 0;
122
+ const indices = new Map();
123
+ const lowlink = new Map();
124
+ const onStack = new Set();
125
+ const stack = [];
126
+ const sccs = [];
127
+ const strongconnect = (v) => {
128
+ indices.set(v, index);
129
+ lowlink.set(v, index);
130
+ index++;
131
+ stack.push(v);
132
+ onStack.add(v);
133
+ /* c8 ignore next */
134
+ const neighbors = adj.get(v) ?? [];
135
+ for (const w of neighbors) {
136
+ if (!indices.has(w)) {
137
+ strongconnect(w);
138
+ const lv = lowlink.get(v);
139
+ const lw = lowlink.get(w);
140
+ if (lv !== undefined && lw !== undefined && lw < lv) {
141
+ lowlink.set(v, lw);
142
+ }
143
+ }
144
+ else if (onStack.has(w)) {
145
+ const lv = lowlink.get(v);
146
+ const iw = indices.get(w);
147
+ if (lv !== undefined && iw !== undefined && iw < lv) {
148
+ lowlink.set(v, iw);
149
+ }
150
+ }
151
+ }
152
+ if (lowlink.get(v) === indices.get(v)) {
153
+ const scc = [];
154
+ for (;;) {
155
+ const w = stack.pop();
156
+ /* c8 ignore next */
157
+ if (!w)
158
+ break;
159
+ onStack.delete(w);
160
+ scc.push(w);
161
+ if (w === v)
162
+ break;
163
+ }
164
+ sccs.push(scc);
165
+ }
166
+ };
167
+ for (const v of nodes) {
168
+ if (!indices.has(v))
169
+ strongconnect(v);
170
+ }
171
+ return sccs;
172
+ };
173
+ const hasSelfEdge = (node) => {
174
+ for (const edge of node.edgesOut.values()) {
175
+ if (edge.to === node)
176
+ return true;
177
+ }
178
+ return false;
179
+ };
180
+ const serializeWithColors = (node, sccSet, color, resolvedIds) => serializeEdges(node, to => sccSet.has(to) ?
181
+ /* c8 ignore next */
182
+ (color.get(to) ?? MISSING)
183
+ : (resolvedIds.get(to) ?? to.id));
184
+ const mapGet = (m, key) => {
185
+ const v = m.get(key);
186
+ /* c8 ignore next */
187
+ return v ?? '';
188
+ };
189
+ /**
190
+ * Weisfeiler–Lehman color refinement to a partition fixpoint, then a
191
+ * canonical member order. Colors are dense ranks so the loop can
192
+ * stabilize; positional `#idx` refs are assigned to color classes.
193
+ */
194
+ const refineSccOrder = (scc, resolvedIds) => {
195
+ const sccSet = new Set(scc);
196
+ const bases = [...new Set(scc.map(peerlessDepID))].sort(byteCompare);
197
+ const initRank = new Map(bases.map((b, i) => [b, String(i)]));
198
+ let color = new Map(scc.map(n => [n, mapGet(initRank, peerlessDepID(n))]));
199
+ // classic WL: each signature includes the node's own current color so
200
+ // refinement is monotone (the partition only ever gets finer), and the
201
+ // loop stops when the number of classes stops growing rather than when
202
+ // rank labels are literally identical, so it terminates in at most
203
+ // `scc.length` rounds even when labels permute on a stable partition
204
+ let classCount = new Set(color.values()).size;
205
+ for (;;) {
206
+ const rows = scc.map(n => ({
207
+ n,
208
+ key: `${peerlessDepID(n)}${NUL}${mapGet(color, n)}${NUL}${serializeWithColors(n, sccSet, color, resolvedIds)}`,
209
+ }));
210
+ const uniq = [...new Set(rows.map(r => r.key))].sort(byteCompare);
211
+ const rank = new Map(uniq.map((k, i) => [k, String(i)]));
212
+ color = new Map(rows.map(r => [r.n, mapGet(rank, r.key)]));
213
+ if (uniq.length === classCount)
214
+ break;
215
+ classCount = uniq.length;
216
+ }
217
+ const order = [...scc].sort((a, b) => byteCompare(peerlessDepID(a), peerlessDepID(b)) ||
218
+ byteCompare(mapGet(color, a), mapGet(color, b)) ||
219
+ byteCompare(a.id, b.id));
220
+ return { order, colors: color };
221
+ };
222
+ const colorClassKey = (node, colors) => `${peerlessDepID(node)}${NUL}${mapGet(colors, node)}`;
223
+ const mergeNode = (graph, winner, loser) => {
224
+ winner.dev &&= loser.dev;
225
+ winner.optional &&= loser.optional;
226
+ // the loser may be the copy carrying lockfile/registry metadata or the
227
+ // manifest; preserve anything the winner is missing before deletion
228
+ copyPackageMetadata(winner, loser);
229
+ if (!winner.manifest && loser.manifest) {
230
+ winner.manifest = loser.manifest;
231
+ graph.manifests.set(winner.id, loser.manifest);
232
+ }
233
+ for (const edge of loser.edgesIn) {
234
+ edge.to = winner;
235
+ winner.edgesIn.add(edge);
236
+ }
237
+ loser.edgesIn.clear();
238
+ for (const edge of loser.edgesOut.values()) {
239
+ graph.edges.delete(edge);
240
+ edge.to?.edgesIn.delete(edge);
241
+ }
242
+ loser.edgesOut.clear();
243
+ const nbn = graph.nodesByName.get(loser.name);
244
+ if (nbn) {
245
+ nbn.delete(loser);
246
+ if (nbn.size === 0)
247
+ graph.nodesByName.delete(loser.name);
248
+ }
249
+ // the resolution caches are not read again before applyAssignments()
250
+ // clears both of them, so there is nothing to invalidate per loser
251
+ };
252
+ const applyAssignments = (graph, keep, losers) => {
253
+ const newKeys = new Map();
254
+ for (const a of keep)
255
+ newKeys.set(a.newId, a.node);
256
+ for (const a of keep) {
257
+ if (a.node.id !== a.newId && !newKeys.has(a.node.id)) {
258
+ graph.nodes.delete(a.node.id);
259
+ graph.manifests.delete(a.node.id);
260
+ }
261
+ }
262
+ for (const loser of losers.keys()) {
263
+ graph.nodes.delete(loser.id);
264
+ graph.manifests.delete(loser.id);
265
+ }
266
+ const moves = [];
267
+ for (const a of keep) {
268
+ if (a.node.id !== a.newId) {
269
+ const from = a.node.id;
270
+ a.node.setPeerIdentity(a.newId, a.suffix);
271
+ if (a.node.extracted) {
272
+ moves.push({ node: a.node, from, to: a.newId });
273
+ }
274
+ }
275
+ else {
276
+ a.node.peerSetHash = a.suffix;
277
+ }
278
+ }
279
+ for (const a of keep) {
280
+ graph.nodes.set(a.newId, a.node);
281
+ if (a.node.manifest) {
282
+ graph.manifests.set(a.newId, a.node.manifest);
283
+ }
284
+ }
285
+ for (const [loser, winner] of losers) {
286
+ if (loser.extracted) {
287
+ moves.push({ node: loser, from: loser.id });
288
+ }
289
+ mergeNode(graph, winner, loser);
290
+ }
291
+ const names = new Set();
292
+ for (const a of keep)
293
+ names.add(a.node.name);
294
+ for (const loser of losers.keys())
295
+ names.add(loser.name);
296
+ for (const name of names) {
297
+ const nbn = graph.nodesByName.get(name);
298
+ if (!nbn)
299
+ continue;
300
+ graph.nodesByName.set(name, new Set([...nbn].sort((a, b) => a.id.localeCompare(b.id, 'en'))));
301
+ }
302
+ graph.resolutions.clear();
303
+ graph.resolutionsReverse.clear();
304
+ if (losers.size > 0)
305
+ graph.gc();
306
+ graph.sortNodes();
307
+ return moves;
308
+ };
309
+ /**
310
+ * Replace provisional / legacy `peer.<N>` DepID suffixes with a content
311
+ * hash of each node's resolved out-edge set. Called after `graph.gc()`.
312
+ */
313
+ export const canonicalizePeerIds = (graph, options = {}) => {
314
+ const digest = options.digest ?? peerEnvDigest;
315
+ const scoped = [...graph.nodes.values()]
316
+ .filter(n => isPeerScoped(n, graph))
317
+ .sort((a, b) => byteCompare(a.id, b.id));
318
+ if (!scoped.length)
319
+ return [];
320
+ const scopedSet = new Set(scoped);
321
+ const adj = new Map();
322
+ for (const node of scoped) {
323
+ const next = [];
324
+ const seen = new Set();
325
+ for (const edge of node.edgesOut.values()) {
326
+ const to = edge.to;
327
+ if (!to || !scopedSet.has(to) || seen.has(to))
328
+ continue;
329
+ seen.add(to);
330
+ next.push(to);
331
+ }
332
+ next.sort((a, b) => byteCompare(a.id, b.id));
333
+ adj.set(node, next);
334
+ }
335
+ const resolvedIds = new Map();
336
+ const keep = [];
337
+ const byNewId = new Map();
338
+ const losers = new Map();
339
+ const takeSuffix = (node, ser, hex, roleIdx, roleCount) => {
340
+ const makeSuffix = (h) => roleCount > 1 ?
341
+ `${PEER_PREFIX}${h}.${roleIdx}`
342
+ : `${PEER_PREFIX}${h}`;
343
+ let suffix = makeSuffix(hex);
344
+ let newId = withPeerSuffix(node, suffix);
345
+ let occupant = byNewId.get(newId);
346
+ if (occupant && occupant.ser !== ser) {
347
+ suffix = makeSuffix(digest(ser, 32));
348
+ newId = withPeerSuffix(node, suffix);
349
+ occupant = byNewId.get(newId);
350
+ /* c8 ignore start */
351
+ if (occupant && occupant.ser !== ser) {
352
+ suffix = `${suffix}.x`;
353
+ newId = withPeerSuffix(node, suffix);
354
+ occupant = undefined;
355
+ }
356
+ /* c8 ignore stop */
357
+ }
358
+ if (occupant?.ser === ser) {
359
+ losers.set(node, occupant.node);
360
+ resolvedIds.set(node, occupant.newId);
361
+ return;
362
+ }
363
+ const assigned = { node, ser, suffix, newId };
364
+ byNewId.set(newId, assigned);
365
+ keep.push(assigned);
366
+ resolvedIds.set(node, newId);
367
+ };
368
+ for (const scc of tarjan(scoped, adj)) {
369
+ if (scc.length === 1 && scc[0]) {
370
+ const node = scc[0];
371
+ const intraSccIndex = hasSelfEdge(node) ? new Map([[node, 0]]) : undefined;
372
+ const ser = unitEntry(node, { resolvedIds, intraSccIndex });
373
+ takeSuffix(node, ser, digest(ser, 16), 0, 1);
374
+ continue;
375
+ }
376
+ const { order, colors } = refineSccOrder(scc, resolvedIds);
377
+ const seenClass = new Set();
378
+ const reps = [];
379
+ for (const n of order) {
380
+ const key = colorClassKey(n, colors);
381
+ if (seenClass.has(key))
382
+ continue;
383
+ seenClass.add(key);
384
+ reps.push(n);
385
+ }
386
+ const classIdx = new Map();
387
+ for (const [i, n] of reps.entries()) {
388
+ classIdx.set(colorClassKey(n, colors), i);
389
+ }
390
+ const intraSccIndex = new Map();
391
+ for (const n of order) {
392
+ intraSccIndex.set(n,
393
+ /* c8 ignore next */
394
+ classIdx.get(colorClassKey(n, colors)) ?? 0);
395
+ }
396
+ const unit = reps
397
+ .map(n => unitEntry(n, { resolvedIds, intraSccIndex }))
398
+ .join(NL);
399
+ const hex = digest(unit, 16);
400
+ const roleIdx = new Map();
401
+ const rolesPerBase = new Map();
402
+ for (const n of reps) {
403
+ const base = peerlessDepID(n);
404
+ const key = colorClassKey(n, colors);
405
+ if (!roleIdx.has(key)) {
406
+ const next = rolesPerBase.get(base) ?? 0;
407
+ roleIdx.set(key, next);
408
+ rolesPerBase.set(base, next + 1);
409
+ }
410
+ }
411
+ for (const n of order) {
412
+ const key = colorClassKey(n, colors);
413
+ /* c8 ignore next */
414
+ const idx = roleIdx.get(key) ?? 0;
415
+ /* c8 ignore next */
416
+ const count = rolesPerBase.get(peerlessDepID(n)) ?? 1;
417
+ takeSuffix(n, unit, hex, idx, count);
418
+ }
419
+ }
420
+ return applyAssignments(graph, keep, losers);
421
+ };
@@ -1,11 +1,14 @@
1
- import type { AddImportersDependenciesMap, RemoveImportersDependenciesMap } from '../dependencies.ts';
1
+ import type { AddImportersDependenciesMap, Dependency, RemoveImportersDependenciesMap } from '../dependencies.ts';
2
2
  import type { BuildIdealAddOptions, BuildIdealFromGraphOptions, BuildIdealRemoveOptions, TransientAddMap, TransientRemoveMap } from './types.ts';
3
+ import type { Edge } from '../edge.ts';
3
4
  import type { SpecOptions } from '@vltpkg/spec';
4
5
  import type { PackageJson } from '@vltpkg/package-json';
5
6
  import type { PathScurry } from 'path-scurry';
7
+ import type { GraphModifier } from '../modifiers.ts';
6
8
  export type GetImporterSpecsOptions = BuildIdealAddOptions & BuildIdealFromGraphOptions & BuildIdealRemoveOptions & SpecOptions & {
7
9
  scurry: PathScurry;
8
10
  packageJson: PackageJson;
11
+ modifiers?: GraphModifier;
9
12
  };
10
13
  /**
11
14
  * Given a {@link Graph} and a list of {@link Dependency}, merges the
@@ -15,6 +18,7 @@ export type GetImporterSpecsOptions = BuildIdealAddOptions & BuildIdealFromGraph
15
18
  export declare const getImporterSpecs: (options: GetImporterSpecsOptions) => {
16
19
  add: AddImportersDependenciesMap;
17
20
  remove: RemoveImportersDependenciesMap;
21
+ staleSpecs: Map<Edge, Dependency>;
18
22
  transientAdd: TransientAddMap;
19
23
  transientRemove: TransientRemoveMap;
20
24
  };
@@ -40,33 +40,49 @@ export const getImporterSpecs = (options) => {
40
40
  removeResult.modifiedDependencies = true;
41
41
  }
42
42
  }
43
- // if a dependency is listed in the manifest but not in the graph,
44
- // add that dependency to the list of dependencies to be added
43
+ // which declaration each name is placed with, same rule as
44
+ // getRawDependencies: the last type listing it wins, except that a
45
+ // peer entry for a name already listed elsewhere only constrains
46
+ // the dependency, it is not its own dependency to place
47
+ const declared = new Map();
45
48
  for (const depType of longDependencyTypes) {
46
49
  const deps = Object.entries(importer.manifest?.[depType] ?? {});
47
50
  for (const [depName, depSpec] of deps) {
48
- const edge = importer.edgesOut.get(depName);
49
- // skip if the edge exists and already uses the same spec
50
- if (edge?.to && depSpec === edge.spec.bareSpec)
51
+ if (depType === 'peerDependencies' && declared.has(depName)) {
52
+ continue;
53
+ }
54
+ declared.set(depName, [depType, depSpec]);
55
+ }
56
+ }
57
+ // if a dependency is listed in the manifest but not in the graph,
58
+ // add that dependency to the list of dependencies to be added
59
+ for (const [depName, [depType, depSpec]] of declared) {
60
+ const edge = importer.edgesOut.get(depName);
61
+ // skip if the edge exists and already uses the same spec and
62
+ // type. dangling (MISSING) targets still need to be queued so
63
+ // the ideal rebuild runs resetEdges() and places in traversal
64
+ // order. checked before parsing: the text parsed fine when the
65
+ // edge was loaded, and the workspace check below only fires on
66
+ // !edge?.to
67
+ const type = shorten(depType, depName, importer.manifest);
68
+ if (edge?.to &&
69
+ edge.spec.bareSpec === depSpec &&
70
+ edge.type === type) {
71
+ continue;
72
+ }
73
+ const spec = Spec.parse(depName, depSpec, options);
74
+ // if a workspace dep references a workspace that no longer exists
75
+ // (folder was removed), mark it for removal instead of trying to
76
+ // resolve it — the workspace can't be satisfied
77
+ if (spec.type === 'workspace' && !edge?.to) {
78
+ const wsExists = [...graph.importers].some(n => n.name === depName);
79
+ if (!wsExists) {
80
+ removeDeps.add(depName);
81
+ removeResult.modifiedDependencies = true;
51
82
  continue;
52
- const spec = Spec.parse(depName, depSpec, options);
53
- // if a workspace dep references a workspace that no longer exists
54
- // (folder was removed), mark it for removal instead of trying to
55
- // resolve it — the workspace can't be satisfied
56
- if (spec.type === 'workspace' && !edge?.to) {
57
- const wsExists = [...graph.importers].some(n => n.name === depName);
58
- if (!wsExists) {
59
- removeDeps.add(depName);
60
- removeResult.modifiedDependencies = true;
61
- continue;
62
- }
63
83
  }
64
- const dependency = asDependency({
65
- spec,
66
- type: shorten(depType, depName, importer.manifest),
67
- });
68
- addDeps.set(depName, dependency);
69
84
  }
85
+ addDeps.set(depName, asDependency({ spec, type }));
70
86
  }
71
87
  addResult.set(importer.id, addDeps);
72
88
  removeResult.set(importer.id, removeDeps);
@@ -107,8 +123,9 @@ export const getImporterSpecs = (options) => {
107
123
  const deps = Object.entries(manifest[depType] ?? {});
108
124
  for (const [depName, depSpec] of deps) {
109
125
  const edge = node.edgesOut.get(depName);
110
- // skip if the edge exists and already uses the same spec
111
- if (edge?.to && depSpec === edge.spec.bareSpec)
126
+ // skip if the edge exists and already uses the same spec.
127
+ // dangling targets still need to be queued for the ideal rebuild.
128
+ if (edge?.to && edge.spec.bareSpec === depSpec)
112
129
  continue;
113
130
  // add the dependency to the addDeps map
114
131
  const dependency = asDependency({
@@ -171,10 +188,19 @@ export const getImporterSpecs = (options) => {
171
188
  }
172
189
  }
173
190
  // removes already satisfied dependencies from the dependencies list
174
- removeSatisfiedSpecs({
191
+ const staleSpecs = removeSatisfiedSpecs({
175
192
  add: addResult,
176
193
  graph,
194
+ modifiers: options.modifiers,
177
195
  });
196
+ // a name the caller asked for is left to appendNodes and reify's
197
+ // package.json update: healing it here would put the request text in
198
+ // the lockfile without ever touching package.json
199
+ for (const edge of staleSpecs.keys()) {
200
+ if (add.get(edge.from.id)?.has(edge.name)) {
201
+ staleSpecs.delete(edge);
202
+ }
203
+ }
178
204
  // set the modifiedDependencies flag if any
179
205
  // of the importers have modified dependencies
180
206
  for (const addDeps of addResult.values()) {
@@ -186,6 +212,7 @@ export const getImporterSpecs = (options) => {
186
212
  return {
187
213
  add: addResult,
188
214
  remove: removeResult,
215
+ staleSpecs,
189
216
  transientAdd,
190
217
  transientRemove,
191
218
  };
@@ -0,0 +1,7 @@
1
+ import type { PathScurry } from 'path-scurry';
2
+ import type { RollbackRemove } from '@vltpkg/rollback-remove';
3
+ import type { PeerStoreMove } from './canonicalize-peer-ids.ts';
4
+ export declare const movePeerStoreDirs: (moves: PeerStoreMove[], { scurry, remover, }: {
5
+ scurry: PathScurry;
6
+ remover: RollbackRemove;
7
+ }) => Promise<void>;
@@ -0,0 +1,59 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { rename } from 'node:fs/promises';
3
+ export const movePeerStoreDirs = async (moves, { scurry, remover, }) => {
4
+ if (!moves.length)
5
+ return;
6
+ const store = scurry.resolve('node_modules/.vlt');
7
+ const key = String(Math.random()).substring(2);
8
+ // phase 1: vacate every source before any destination is written.
9
+ // a canonical id can equal another node's previous id, so a
10
+ // destination may still hold a directory that a sibling move is about
11
+ // to relocate; parking sources on unique hidden paths first keeps the
12
+ // moves from racing each other
13
+ const staged = await Promise.all(moves.map(async ({ node, from, to }) => {
14
+ const src = scurry.resolve(store, from);
15
+ if (!to) {
16
+ // nothing links to a merged-away copy any more, so one that
17
+ // cannot be discarded is a stale dir, not a broken install
18
+ try {
19
+ await remover.rm(src);
20
+ }
21
+ catch { }
22
+ return;
23
+ }
24
+ const tmp = scurry.resolve(store, `.VLT.MOVE.${key}.${to}`);
25
+ try {
26
+ await rename(src, tmp);
27
+ }
28
+ catch {
29
+ node.extracted = false;
30
+ return;
31
+ }
32
+ return { node, tmp, dest: scurry.resolve(store, to) };
33
+ }));
34
+ // phase 2: settle each parked directory on its canonical id
35
+ await Promise.all(staged.map(async (s) => {
36
+ if (!s)
37
+ return;
38
+ const { node, tmp, dest } = s;
39
+ try {
40
+ // frees the path and registers it for rollback; also the EPERM
41
+ // mitigation Windows needs before renaming a directory
42
+ await remover.rm(dest);
43
+ await rename(tmp, dest);
44
+ }
45
+ catch {
46
+ node.extracted = false;
47
+ // the node is re-extracted on its canonical id, so the parked
48
+ // copy would only linger as a `.VLT.MOVE` orphan in the store
49
+ try {
50
+ await remover.rm(tmp);
51
+ }
52
+ catch { }
53
+ return;
54
+ }
55
+ if (!existsSync(node.resolvedLocation(scurry))) {
56
+ node.extracted = false;
57
+ }
58
+ }));
59
+ };
@@ -13,10 +13,16 @@ import type { Node } from '../node.ts';
13
13
  */
14
14
  type PeerEdgeCompatResult = {
15
15
  compatible: boolean;
16
- /** When incompatible, entry to add to forked context (target always present) */
17
- forkEntry?: PeerContextEntryInput & {
18
- target: Node;
19
- };
16
+ /**
17
+ * When incompatible, entry to add to forked context. The target is
18
+ * absent only when the parent declares the peer but has not placed it
19
+ * yet: the fork still has to be minted so the re-placed copy gets a
20
+ * distinct id, and the parent's own edge resolves the peer at the end
21
+ * of the level.
22
+ */
23
+ forkEntry?: PeerContextEntryInput;
24
+ /** the existing version is fine, only an optional peer link differs */
25
+ keepVersion?: boolean;
20
26
  };
21
27
  /**
22
28
  * Check if an existing node's peer edges would still resolve to the same
@@ -36,7 +42,7 @@ type PeerEdgeCompatResult = {
36
42
  * on the same package, and there's a graph node that would satisfy it but
37
43
  * differs from what the existing node expects.
38
44
  */
39
- export declare const checkPeerEdgesCompatible: (existingNode: Node, fromNode: Node, peerContext: PeerContext, graph: Graph) => PeerEdgeCompatResult;
45
+ export declare const checkPeerEdgesCompatible: (existingNode: Node, fromNode: Node, peerContext: PeerContext, graph: Graph, pending?: Map<string, Dependency>) => PeerEdgeCompatResult;
40
46
  /**
41
47
  * Retrieve a unique hash value for a given peer context set.
42
48
  */
@@ -80,6 +86,11 @@ export declare const checkEntriesToPeerContext: (peerContext: PeerContext, entri
80
86
  * peer context set. Extra info such as a target or dependent nodes is
81
87
  * optional.
82
88
  *
89
+ * When an entry gains a target that satisfies every spec collected so far,
90
+ * dependents of that entry are re-pointed at it only if their current target
91
+ * no longer satisfies one of those specs, so two equally valid resolutions
92
+ * never swap back and forth across rebuilds.
93
+ *
83
94
  * Returns true if forking is needed, false otherwise.
84
95
  */
85
96
  export declare const addEntriesToPeerContext: (peerContext: PeerContext, entries: PeerContextEntryInput[], fromNode: Node, monorepo?: Monorepo) => boolean;