@vzn/vx 0.0.21 → 0.0.22

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 (123) hide show
  1. package/README.md +21 -18
  2. package/package.json +6 -6
  3. package/src/bin.ts +52 -3
  4. package/src/cache/archive.ts +170 -21
  5. package/src/cache/cache.ts +667 -288
  6. package/src/cache/chained-cache.ts +14 -2
  7. package/src/cache/config-evals.ts +29 -6
  8. package/src/cache/file-hashes.ts +63 -35
  9. package/src/cache/git-inputs.ts +398 -69
  10. package/src/cache/index.ts +8 -1
  11. package/src/cache/inputs.ts +403 -102
  12. package/src/cache/key-fold.ts +232 -0
  13. package/src/cache/layer.ts +81 -11
  14. package/src/cache/layered-cache.ts +199 -32
  15. package/src/cache/output-index.ts +109 -10
  16. package/src/cache/run-history.ts +8 -3
  17. package/src/cache/zstd.ts +53 -30
  18. package/src/cli/cache.ts +51 -21
  19. package/src/cli/format.ts +4 -15
  20. package/src/cli/help.ts +13 -3
  21. package/src/cli/index.ts +35 -9
  22. package/src/cli/info.ts +49 -7
  23. package/src/cli/init.ts +17 -5
  24. package/src/cli/last.ts +96 -21
  25. package/src/cli/lock.ts +34 -17
  26. package/src/cli/plan-format.ts +4 -4
  27. package/src/cli/plugin-commands.ts +20 -5
  28. package/src/cli/run.ts +55 -12
  29. package/src/cli/select.ts +23 -13
  30. package/src/cli/show.ts +9 -4
  31. package/src/cli/upgrade.ts +134 -14
  32. package/src/cli/watch.ts +611 -102
  33. package/src/cli/why.ts +8 -16
  34. package/src/cli/workspace-config.ts +21 -9
  35. package/src/config.ts +46 -9
  36. package/src/exec/executor.ts +51 -5
  37. package/src/exec/index.ts +10 -0
  38. package/src/exec/kill-tree.ts +281 -0
  39. package/src/exec/local-executor.ts +1 -1
  40. package/src/exec/runner.ts +327 -82
  41. package/src/exec/sandbox-binds.ts +34 -5
  42. package/src/exec/sandbox-paths.ts +22 -2
  43. package/src/exec/sandbox-runtime.ts +627 -102
  44. package/src/exec/sandbox-violations.ts +36 -23
  45. package/src/graph/dependency-spec.ts +14 -14
  46. package/src/graph/index.ts +12 -2
  47. package/src/graph/priorities.ts +130 -0
  48. package/src/graph/scheduler.ts +182 -134
  49. package/src/graph/task-graph.ts +552 -188
  50. package/src/index.ts +16 -2
  51. package/src/orchestrator/admission.ts +41 -17
  52. package/src/orchestrator/colors.ts +8 -3
  53. package/src/orchestrator/deferred-outputs.ts +22 -7
  54. package/src/orchestrator/doctor.ts +122 -14
  55. package/src/orchestrator/events.ts +71 -2
  56. package/src/orchestrator/excluded-keys.ts +155 -0
  57. package/src/orchestrator/execute-task.ts +331 -37
  58. package/src/orchestrator/failure-recap.ts +121 -0
  59. package/src/orchestrator/fingerprint-watch.ts +89 -0
  60. package/src/orchestrator/framed-output.ts +81 -14
  61. package/src/orchestrator/history.ts +6 -7
  62. package/src/orchestrator/hit-restore.ts +82 -18
  63. package/src/orchestrator/index.ts +7 -1
  64. package/src/orchestrator/keyed-projects.ts +89 -0
  65. package/src/orchestrator/local-shortcircuit.ts +83 -23
  66. package/src/orchestrator/lockfile-claim.ts +152 -32
  67. package/src/orchestrator/logger.ts +116 -20
  68. package/src/orchestrator/metrics.ts +156 -60
  69. package/src/orchestrator/miss-save.ts +106 -43
  70. package/src/orchestrator/options.ts +30 -4
  71. package/src/orchestrator/persistent.ts +41 -21
  72. package/src/orchestrator/placement.ts +55 -25
  73. package/src/orchestrator/plan.ts +24 -11
  74. package/src/orchestrator/plugin-host.ts +17 -23
  75. package/src/orchestrator/plugin.ts +31 -8
  76. package/src/orchestrator/prepare.ts +99 -13
  77. package/src/orchestrator/projects.ts +19 -8
  78. package/src/orchestrator/remote-prefetch.ts +21 -1
  79. package/src/orchestrator/run-artifacts.ts +9 -0
  80. package/src/orchestrator/run-context.ts +19 -4
  81. package/src/orchestrator/run-lock.ts +349 -0
  82. package/src/orchestrator/run-records.ts +6 -23
  83. package/src/orchestrator/run-report.ts +4 -2
  84. package/src/orchestrator/run.ts +264 -57
  85. package/src/orchestrator/sandbox-request.ts +388 -62
  86. package/src/orchestrator/save-lane.ts +4 -7
  87. package/src/orchestrator/shell-verdict.ts +117 -0
  88. package/src/orchestrator/signals.ts +86 -30
  89. package/src/orchestrator/stable-keys.ts +202 -10
  90. package/src/orchestrator/status-line.ts +33 -1
  91. package/src/orchestrator/summary.ts +49 -2
  92. package/src/orchestrator/task-hash.ts +87 -7
  93. package/src/orchestrator/task-log-buffer.ts +7 -7
  94. package/src/orchestrator/telemetry-host.ts +16 -7
  95. package/src/orchestrator/telemetry.ts +69 -28
  96. package/src/orchestrator/upstream.ts +91 -45
  97. package/src/util/bun-version.ts +43 -0
  98. package/src/util/errors.ts +86 -0
  99. package/src/util/hash.ts +10 -1
  100. package/src/util/index.ts +32 -3
  101. package/src/util/paths.ts +147 -5
  102. package/src/util/procfs.ts +20 -0
  103. package/src/util/settle.ts +4 -2
  104. package/src/util/size.ts +33 -0
  105. package/src/util/task-id.ts +20 -0
  106. package/src/util/timing.ts +11 -1
  107. package/src/util/which.ts +48 -0
  108. package/src/workspace/affected.ts +272 -64
  109. package/src/workspace/config-cache.ts +262 -32
  110. package/src/workspace/config-eval.ts +49 -12
  111. package/src/workspace/config-imports.ts +126 -5
  112. package/src/workspace/config-schema.ts +237 -41
  113. package/src/workspace/filter.ts +33 -18
  114. package/src/workspace/fingerprint.ts +29 -11
  115. package/src/workspace/index.ts +7 -0
  116. package/src/workspace/json-data.ts +90 -0
  117. package/src/workspace/load-reads.ts +32 -0
  118. package/src/workspace/lockfile.ts +33 -11
  119. package/src/workspace/migrate-scripts.ts +60 -7
  120. package/src/workspace/migration.ts +40 -1
  121. package/src/workspace/package-graph.ts +154 -16
  122. package/src/workspace/project-loader.ts +227 -77
  123. package/src/workspace/workspace.ts +173 -23
@@ -32,16 +32,19 @@
32
32
  // - Only STABLE-key, cacheable, local-read tasks are classified. A
33
33
  // stable miss stays in the normal (dep-gated) schedule; an unstable
34
34
  // task is never probed here.
35
- // - If ANY task in the graph declares `cache.outputs.workspaceFiles`,
36
- // no task is restore-tiered (the boundary-ignoring escape hatch
37
- // could let a task write where a restore touches — a blanket
38
- // conservative exclusion). Those tasks still get an exec-tier probe
39
- // reuse entry so there's no double work.
35
+ // - A task that declares `cache.outputs.workspaceFiles` (the
36
+ // boundary-ignoring escape hatch) can write where a restore touches,
37
+ // so every task whose project directory a workspace-output glob's
38
+ // static prefix REACHES stays out of the restore tier, and so does
39
+ // every transitive dependant of one (its up-front key folds a key
40
+ // that is preliminary). A glob with no literal prefix reaches every
41
+ // project, which is the old graph-wide rule. Excluded tasks still get
42
+ // an exec-tier probe reuse entry so there's no double work.
40
43
 
41
- import { span } from '../util/index.js'
44
+ import { normalizeGlob, relPosix, span, staticPrefix } from '../util/index.js'
42
45
  import type { CacheEntry, CacheLayer, GitFilesCache } from '../cache/index.js'
43
46
  import type { TaskNode } from '../graph/index.js'
44
- import { deriveStableKeys } from './stable-keys.js'
47
+ import { deriveStableKeys, workspaceInputsReach } from './stable-keys.js'
45
48
  import type { HashCache } from './task-hash.js'
46
49
 
47
50
  export interface ShortCircuitArgs {
@@ -104,22 +107,13 @@ export async function startLocalShortCircuit(args: ShortCircuitArgs): Promise<Sh
104
107
  } finally {
105
108
  endKeys()
106
109
  }
107
- const candidates = stableKeys.filter(({ node }) => node.config.cache !== undefined)
110
+ // Every stable key is a cacheable task's: deriveStableKeys pushes only
111
+ // `cacheEnabled && !unstable` (item 640 deleted a second filter here and
112
+ // nothing reddened).
113
+ const candidates = stableKeys
108
114
  if (candidates.length === 0) return EMPTY
109
115
 
110
- // Belt-and-suspenders for the boundary-ignoring escape hatch: a
111
- // root-anchored output can land anywhere, so if ANY task declares
112
- // workspace outputs a task could in principle write where a
113
- // restore-tier task restores. Disable the RESTORE tier graph-wide —
114
- // workspace outputs are rare bad-practice. (Probe reuse still applies,
115
- // so there's no double work; those tasks just stay dep-gated.)
116
- let anyWorkspaceOutputs = false
117
- for (const node of args.nodes.values()) {
118
- if ((node.config.cache?.outputs.workspaceFiles?.length ?? 0) > 0) {
119
- anyWorkspaceOutputs = true
120
- break
121
- }
122
- }
116
+ const keptOut = restoreTierExclusions(args.nodes, args.workspaceRoot)
123
117
 
124
118
  const preProbed = new Map<string, ProbedEntry>()
125
119
  const restoreTier = new Set<string>()
@@ -139,7 +133,7 @@ export async function startLocalShortCircuit(args: ShortCircuitArgs): Promise<Sh
139
133
  for (const { hash, node } of candidates) {
140
134
  const hit = hits.get(hash) ?? null
141
135
  preProbed.set(node.id, { hash, hit })
142
- if (hit !== null && !anyWorkspaceOutputs) restoreTier.add(node.id)
136
+ if (hit !== null && !keptOut.has(node.id)) restoreTier.add(node.id)
143
137
  }
144
138
  return { preProbed, restoreTier }
145
139
  } catch {
@@ -156,7 +150,7 @@ export async function startLocalShortCircuit(args: ShortCircuitArgs): Promise<Sh
156
150
  const command = node.config.exec?.command ?? ''
157
151
  const hit = await args.cache.get(hash, { taskId: node.id, command })
158
152
  preProbed.set(node.id, { hash, hit })
159
- if (hit !== null && !anyWorkspaceOutputs) restoreTier.add(node.id)
153
+ if (hit !== null && !keptOut.has(node.id)) restoreTier.add(node.id)
160
154
  } catch {
161
155
  // Leave this task out of preProbed → it probes lazily in
162
156
  // execute(), exactly as today.
@@ -167,3 +161,69 @@ export async function startLocalShortCircuit(args: ShortCircuitArgs): Promise<Sh
167
161
 
168
162
  return { preProbed, restoreTier }
169
163
  }
164
+
165
+ /**
166
+ * The tasks a workspace-output declaration keeps out of the restore tier.
167
+ *
168
+ * A root-anchored output can land in any project's directory, edge or no
169
+ * edge (`tests/local-shortcircuit.test.ts` § "a workspace-output writer"
170
+ * rows, item 425), so the question is not who depends on the writer but
171
+ * WHERE it can write: each declared glob's static prefix is tested for
172
+ * reach against every project directory, and against every task's own
173
+ * `workspaceFiles` inputs, with the ancestor-or-equal relation
174
+ * `workspaceInputsReach` already uses. A prefix that is the root reaches
175
+ * everything — the graph-wide rule this replaced (item 584). Exclusion then
176
+ * follows the edges DOWN: a dependant's up-front key folds an excluded
177
+ * task's key, which is preliminary, so it cannot restore early either.
178
+ * Cost: one pass over the nodes, no filesystem.
179
+ */
180
+ function restoreTierExclusions(nodes: Map<string, TaskNode>, workspaceRoot: string): Set<string> {
181
+ const prefixes: string[] = []
182
+ let everything = false
183
+ for (const node of nodes.values()) {
184
+ for (const raw of node.config.cache?.outputs.workspaceFiles ?? []) {
185
+ // No negation to skip: the schema refuses '!' in output globs
186
+ // (`validateWorkspaceGlobs`), so the `continue` that stood here
187
+ // guarded a shape no config can carry (item 640).
188
+ const glob = normalizeGlob(raw)
189
+ const prefix = staticPrefix(glob)
190
+ if (prefix === '.' || prefix === '' || prefix === '/') everything = true
191
+ else prefixes.push(prefix.replace(/^\.\//, ''))
192
+ }
193
+ }
194
+ const out = new Set<string>()
195
+ if (everything) {
196
+ for (const id of nodes.keys()) out.add(id)
197
+ return out
198
+ }
199
+ if (prefixes.length === 0) return out
200
+ const reaches = (dir: string): boolean =>
201
+ dir === '' ||
202
+ dir === '.' ||
203
+ prefixes.some((p) => p === dir || p.startsWith(`${dir}/`) || dir.startsWith(`${p}/`))
204
+ const dependants = new Map<string, string[]>()
205
+ for (const node of nodes.values()) {
206
+ const dir = relPosix(workspaceRoot, node.projectDir)
207
+ const wsInputs = node.config.cache?.inputs?.workspaceFiles ?? []
208
+ if (reaches(dir) || (wsInputs.length > 0 && workspaceInputsReach(wsInputs, prefixes))) {
209
+ out.add(node.id)
210
+ }
211
+ for (const dep of node.deps) {
212
+ const list = dependants.get(dep)
213
+ if (list === undefined) dependants.set(dep, [node.id])
214
+ else list.push(node.id)
215
+ }
216
+ }
217
+ // Up the dependant edges on an explicit stack: a chain is as deep as the
218
+ // graph, and a recursion per edge threw `RangeError` at the 50,000 the
219
+ // builder takes (item 737).
220
+ const stack = [...out]
221
+ while (stack.length > 0) {
222
+ for (const up of dependants.get(stack.pop()!) ?? []) {
223
+ if (out.has(up)) continue
224
+ out.add(up)
225
+ stack.push(up)
226
+ }
227
+ }
228
+ return out
229
+ }
@@ -15,7 +15,7 @@
15
15
 
16
16
  import path from 'node:path'
17
17
  import { rename, stat } from 'node:fs/promises'
18
- import { xxh3hex } from '../util/index.js'
18
+ import { xxh3, xxh3hex } from '../util/index.js'
19
19
  import type { TaskNode } from '../graph/index.js'
20
20
  import type {
21
21
  FingerprintChange,
@@ -33,7 +33,15 @@ export interface LockfileClaimOptions {
33
33
  * distinct content; must be deterministic and throw on a file it
34
34
  * cannot read (the plugin's name heads the error).
35
35
  */
36
- readonly digest: (text: string) => ReadonlyMap<string, string>
36
+ readonly digest: (text: string, files: ReadonlyMap<string, string>) => ReadonlyMap<string, string>
37
+ /**
38
+ * Root-relative files the lockfile names whose BYTES the install uses but
39
+ * the lockfile does not pin (bun.lock's `patchedDependencies` names a patch
40
+ * file and records no hash of it). Their content hashes ('' when absent)
41
+ * reach `digest` as `files` and join the memo's identity, so an edited
42
+ * patch moves the key (item 1014). Called only when the memo misses.
43
+ */
44
+ readonly extraFiles?: (text: string) => readonly string[]
37
45
  /** Folded into the memo's identity: bump when `digest` folds differently. */
38
46
  readonly version: number
39
47
  /**
@@ -64,9 +72,35 @@ interface Digests {
64
72
  interface Memo {
65
73
  version: number
66
74
  lock: string
75
+ /** `extraFiles` → content hash when the digests were computed. */
76
+ extras?: Record<string, string>
67
77
  importers: Record<string, string>
68
78
  }
69
79
 
80
+ /** A root-relative extra file's content hash, '' when absent; a path leaving the root is never read. */
81
+ async function hashExtra(workspaceRoot: string, rel: string): Promise<string> {
82
+ if (path.isAbsolute(rel) || rel.split(/[\\/]/).includes('..')) return 'outside-root'
83
+ try {
84
+ return xxh3hex(await Bun.file(path.join(workspaceRoot, rel)).bytes())
85
+ } catch {
86
+ return ''
87
+ }
88
+ }
89
+
90
+ async function statsOf(
91
+ workspaceRoot: string,
92
+ rels: readonly string[],
93
+ ): Promise<Array<{ size: number; mtimeMs: number }>> {
94
+ return Promise.all(
95
+ rels.map((rel) =>
96
+ stat(path.join(workspaceRoot, rel)).then(
97
+ (st) => ({ size: st.size, mtimeMs: st.mtimeMs }),
98
+ () => ({ size: -1, mtimeMs: -1 }),
99
+ ),
100
+ ),
101
+ )
102
+ }
103
+
70
104
  export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks {
71
105
  const { file, digest, version } = options
72
106
  const scope = options.scope ?? 'project'
@@ -76,7 +110,16 @@ export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks
76
110
  }
77
111
  // Per workspace root: what the last read saw. `size` + `mtimeMs` gate a
78
112
  // re-read; the content hash decides whether the digests are current.
79
- const seen = new Map<string, { size: number; mtimeMs: number; digests: Digests }>()
113
+ const seen = new Map<
114
+ string,
115
+ {
116
+ size: number
117
+ mtimeMs: number
118
+ extras: readonly string[]
119
+ extraStats: ReadonlyArray<{ size: number; mtimeMs: number }>
120
+ digests: Digests
121
+ }
122
+ >()
80
123
  // Per run: every `key` call of one run gets the same context object.
81
124
  const perRun = new WeakMap<object, Promise<Digests>>()
82
125
 
@@ -87,27 +130,74 @@ export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks
87
130
  st = await stat(full)
88
131
  } catch {
89
132
  const digests: Digests = { lock: '', importers: new Map() }
90
- seen.set(workspaceRoot, { size: -1, mtimeMs: -1, digests })
133
+ seen.set(workspaceRoot, { size: -1, mtimeMs: -1, extras: [], extraStats: [], digests })
91
134
  return digests
92
135
  }
93
136
  const last = seen.get(workspaceRoot)
94
137
  if (last !== undefined && last.size === st.size && last.mtimeMs === st.mtimeMs) {
95
- return last.digests
138
+ const now = await statsOf(workspaceRoot, last.extras)
139
+ if (
140
+ now.every(
141
+ (s, i) =>
142
+ s.size === last.extraStats[i]!.size && s.mtimeMs === last.extraStats[i]!.mtimeMs,
143
+ )
144
+ ) {
145
+ return last.digests
146
+ }
96
147
  }
97
148
  const bytes = await Bun.file(full).bytes()
98
- const lock = xxh3hex(bytes)
99
- if (last !== undefined && last.digests.lock === lock) {
100
- seen.set(workspaceRoot, { size: st.size, mtimeMs: st.mtimeMs, digests: last.digests })
101
- return last.digests
102
- }
149
+ const lockHash = xxh3hex(bytes)
103
150
  const memoFile = path.join(cacheDir, 'lockfile-claims', `${file}.json`)
104
- const importers =
105
- scope === 'workspace'
106
- ? new Map<string, string>()
107
- : ((await readMemo(memoFile, version, lock)) ??
108
- (await computeAndMemo(memoFile, version, lock, digest(decode(bytes)))))
151
+ // The extra files the last digest read, and their hashes now: a warm run
152
+ // re-hashes the files the memo names and never parses to learn them.
153
+ const memo = await readMemo(memoFile, version, lockHash)
154
+ let extras: Record<string, string> = {}
155
+ let importers: ReadonlyMap<string, string> | undefined
156
+ if (memo !== undefined) {
157
+ const now = Object.fromEntries(
158
+ await Promise.all(
159
+ Object.keys(memo.extras).map(
160
+ async (rel) => [rel, await hashExtra(workspaceRoot, rel)] as const,
161
+ ),
162
+ ),
163
+ )
164
+ if (Object.entries(memo.extras).every(([rel, h]) => now[rel] === h)) {
165
+ extras = memo.extras
166
+ importers = memo.importers
167
+ }
168
+ }
169
+ if (importers === undefined) {
170
+ const text = decode(bytes)
171
+ const rels = [...new Set(options.extraFiles?.(text) ?? [])].sort()
172
+ extras = Object.fromEntries(
173
+ await Promise.all(
174
+ rels.map(async (rel) => [rel, await hashExtra(workspaceRoot, rel)] as const),
175
+ ),
176
+ )
177
+ importers =
178
+ scope === 'workspace'
179
+ ? new Map<string, string>()
180
+ : await computeAndMemo(
181
+ memoFile,
182
+ version,
183
+ lockHash,
184
+ extras,
185
+ digest(text, new Map(Object.entries(extras))),
186
+ )
187
+ }
188
+ const rels = Object.keys(extras)
189
+ const lock =
190
+ rels.length === 0
191
+ ? lockHash
192
+ : xxh3hex([lockHash, ...rels.map((r) => `${r}\0${extras[r]}`)].join('\0'))
109
193
  const digests: Digests = { lock, importers }
110
- seen.set(workspaceRoot, { size: st.size, mtimeMs: st.mtimeMs, digests })
194
+ seen.set(workspaceRoot, {
195
+ size: st.size,
196
+ mtimeMs: st.mtimeMs,
197
+ extras: rels,
198
+ extraStats: await statsOf(workspaceRoot, rels),
199
+ digests,
200
+ })
111
201
  return digests
112
202
  }
113
203
  const loadOnce = (ctx: KeyHookContext): Promise<Digests> => {
@@ -127,8 +217,19 @@ export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks
127
217
  // installed tree is in question, and core's answer (all) is right.
128
218
  if (change.before === null || change.after === null) return undefined
129
219
  if (scope === 'workspace') return undefined
130
- const before = digest(decode(change.before))
131
- const after = digest(decode(change.after))
220
+ // WHICH side failed is the whole difference between "your
221
+ // lockfile is broken" and "the base commit's is" — a migration
222
+ // commit hits the second, and the bare parser message named
223
+ // neither (2026-09-20).
224
+ const sideOf = (bytes: Uint8Array, side: string): ReadonlyMap<string, string> => {
225
+ try {
226
+ return digest(decode(bytes), new Map())
227
+ } catch (err) {
228
+ throw new Error(`${err instanceof Error ? err.message : String(err)} (${side})`)
229
+ }
230
+ }
231
+ const before = sideOf(change.before, `as of the base ref`)
232
+ const after = sideOf(change.after, `in the working tree`)
132
233
  const names: string[] = []
133
234
  for (const p of ctx.projects) {
134
235
  const importer = importerOf(ctx.workspaceRoot, p.dir)
@@ -149,13 +250,22 @@ export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks
149
250
  }
150
251
 
151
252
  /**
152
- * A project's digest: its own importer's, or — for a project the lockfile
153
- * has no importer for (outside the workspace's `packages`) — the root
154
- * importer's, the only installed tree it can resolve from. A file with
155
- * neither folds a constant.
253
+ * A project's digest: its own importer's AND the root importer's. The
254
+ * root package's dependencies reach every task — their bins through the
255
+ * root `node_modules/.bin` core puts on every PATH, their modules through
256
+ * Node's resolution walking up to the root `node_modules` (`@types/*`,
257
+ * a config's plugins) — so a root devDependency bump that moved only the
258
+ * root's digest replayed the old tool's output (nx#36415 class,
259
+ * 2026-09-24). A project the lockfile has no importer for (outside the
260
+ * workspace's `packages`) folds the root's alone; a file with neither
261
+ * folds a constant.
156
262
  */
157
263
  function digestFor(importers: ReadonlyMap<string, string>, importer: string): string {
158
- return importers.get(importer) ?? importers.get('.') ?? 'no-importer'
264
+ const own = importers.get(importer)
265
+ const root = importers.get('.')
266
+ if (own === undefined) return root ?? 'no-importer'
267
+ if (root === undefined || importer === '.') return own
268
+ return xxh3hex(`${own}\0${root}`)
159
269
  }
160
270
 
161
271
  /** The lockfile's importer path for a project directory: `.` for the root, POSIX otherwise. */
@@ -168,11 +278,11 @@ async function readMemo(
168
278
  memoFile: string,
169
279
  version: number,
170
280
  lock: string,
171
- ): Promise<Map<string, string> | undefined> {
281
+ ): Promise<{ extras: Record<string, string>; importers: Map<string, string> } | undefined> {
172
282
  try {
173
283
  const memo = (await Bun.file(memoFile).json()) as Memo
174
284
  if (memo.version !== version || memo.lock !== lock) return undefined
175
- return new Map(Object.entries(memo.importers))
285
+ return { extras: memo.extras ?? {}, importers: new Map(Object.entries(memo.importers)) }
176
286
  } catch {
177
287
  return undefined
178
288
  }
@@ -182,9 +292,10 @@ async function computeAndMemo(
182
292
  memoFile: string,
183
293
  version: number,
184
294
  lock: string,
295
+ extras: Record<string, string>,
185
296
  importers: ReadonlyMap<string, string>,
186
297
  ): Promise<ReadonlyMap<string, string>> {
187
- const memo: Memo = { version, lock, importers: Object.fromEntries(importers) }
298
+ const memo: Memo = { version, lock, extras, importers: Object.fromEntries(importers) }
188
299
  // Write-then-rename: a reader never sees a half-written memo, and two
189
300
  // concurrent runs each land a whole one.
190
301
  const tmp = `${memoFile}.tmp-${process.pid}-${Date.now()}`
@@ -216,8 +327,8 @@ export interface ReachGraph {
216
327
  * does not. Lockfiles carry dependency cycles, so the unit is the
217
328
  * strongly connected component: Tarjan's walk (iterative — a dependency
218
329
  * chain can be thousands deep) emits components children-first, and each
219
- * folds its members' material (sorted) and its child components' digests
220
- * (sorted). O(nodes + edges): 1000 importers over 3000 packages digest in
330
+ * folds its members (sorted), each as its material and the materials its
331
+ * edges land on, and its child components' digests (sorted). O(nodes + edges): 1000 importers over 3000 packages digest in
221
332
  * ~20 ms where one traversal per importer took 400.
222
333
  */
223
334
  export function reachDigests(g: ReachGraph): string[] {
@@ -270,10 +381,19 @@ export function reachDigests(g: ReachGraph): string[] {
270
381
  for (const m of members) {
271
382
  for (const w of g.edges[m]!) if (comp[w] !== id) children.add(compHash[comp[w]!]!)
272
383
  }
273
- let h = Bun.hash.xxHash3(`members:${members.length}`)
274
- for (const m of members.map((m) => g.material[m]!).sort()) h = Bun.hash.xxHash3(`${m}\n`, h)
275
- h = Bun.hash.xxHash3(`children:${children.size}`, h)
276
- for (const c of [...children].sort()) h = Bun.hash.xxHash3(`${c}\n`, h)
384
+ // A member is its material AND where its edges land: all members
385
+ // share one digest, so retargeting an edge between two of them (an
386
+ // importer's `y@1.0.0` → `y@1.1.0` inside one cycle) moved nothing
387
+ // and replayed a stale output (item 1013). A self-loop reaches
388
+ // nothing new and is left out.
389
+ const lines = members.map((m) => {
390
+ const targets = [...new Set(g.edges[m]!.filter((w) => w !== m).map((w) => g.material[w]!))]
391
+ return `${g.material[m]!}\0${targets.sort().join('\0')}`
392
+ })
393
+ let h = xxh3(`members:${members.length}`)
394
+ for (const line of lines.sort()) h = xxh3(`${line}\n`, h)
395
+ h = xxh3(`children:${children.size}`, h)
396
+ for (const c of [...children].sort()) h = xxh3(`${c}\n`, h)
277
397
  compHash.push(h.toString(16).padStart(16, '0'))
278
398
  }
279
399
  }