@vzn/vx 0.0.18 → 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 (149) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +107 -70
  3. package/package.json +10 -20
  4. package/src/bin.ts +64 -5
  5. package/src/cache/archive.ts +279 -28
  6. package/src/cache/cache.ts +952 -1584
  7. package/src/cache/chained-cache.ts +14 -10
  8. package/src/cache/config-evals.ts +112 -0
  9. package/src/cache/file-hashes.ts +290 -0
  10. package/src/cache/git-inputs.ts +966 -0
  11. package/src/cache/index.ts +23 -7
  12. package/src/cache/inputs.ts +553 -739
  13. package/src/cache/key-fold.ts +232 -0
  14. package/src/cache/layer.ts +674 -0
  15. package/src/cache/layered-cache.ts +211 -49
  16. package/src/cache/output-index.ts +323 -0
  17. package/src/cache/policy.ts +84 -0
  18. package/src/cache/run-history.ts +152 -0
  19. package/src/cache/zstd.ts +183 -0
  20. package/src/cli/cache.ts +88 -30
  21. package/src/cli/completions.ts +104 -0
  22. package/src/cli/core-alias.ts +25 -0
  23. package/src/cli/format.ts +4 -15
  24. package/src/cli/help.ts +76 -38
  25. package/src/cli/index.ts +82 -17
  26. package/src/cli/info.ts +162 -91
  27. package/src/cli/init.ts +87 -0
  28. package/src/cli/last.ts +130 -23
  29. package/src/cli/lock.ts +36 -18
  30. package/src/cli/plan-format.ts +18 -7
  31. package/src/cli/plugin-commands.ts +34 -12
  32. package/src/cli/run.ts +138 -313
  33. package/src/cli/select.ts +342 -0
  34. package/src/cli/show.ts +151 -71
  35. package/src/cli/upgrade.ts +136 -15
  36. package/src/cli/watch.ts +1175 -116
  37. package/src/cli/why.ts +51 -29
  38. package/src/cli/workspace-config.ts +138 -0
  39. package/src/config.ts +183 -126
  40. package/src/exec/env.ts +11 -0
  41. package/src/exec/executor.ts +62 -31
  42. package/src/exec/index.ts +16 -2
  43. package/src/exec/kill-tree.ts +281 -0
  44. package/src/exec/local-executor.ts +41 -0
  45. package/src/exec/runner.ts +361 -77
  46. package/src/exec/sandbox-binds.ts +211 -0
  47. package/src/exec/sandbox-paths.ts +70 -0
  48. package/src/exec/sandbox-runtime.ts +1152 -415
  49. package/src/exec/sandbox-violations.ts +293 -0
  50. package/src/graph/dependency-spec.ts +14 -14
  51. package/src/graph/index.ts +6 -4
  52. package/src/graph/priorities.ts +130 -0
  53. package/src/graph/scheduler.ts +332 -329
  54. package/src/graph/task-graph.ts +552 -188
  55. package/src/index.ts +111 -102
  56. package/src/orchestrator/admission.ts +161 -0
  57. package/src/orchestrator/colors.ts +8 -3
  58. package/src/orchestrator/deferred-outputs.ts +25 -7
  59. package/src/orchestrator/doctor.ts +363 -0
  60. package/src/orchestrator/events.ts +111 -8
  61. package/src/orchestrator/excluded-keys.ts +155 -0
  62. package/src/orchestrator/execute-task.ts +450 -565
  63. package/src/orchestrator/failure-mode.ts +169 -28
  64. package/src/orchestrator/failure-recap.ts +121 -0
  65. package/src/orchestrator/fingerprint-watch.ts +89 -0
  66. package/src/orchestrator/framed-output.ts +121 -49
  67. package/src/orchestrator/history.ts +123 -104
  68. package/src/orchestrator/hit-restore.ts +285 -0
  69. package/src/orchestrator/index.ts +42 -5
  70. package/src/orchestrator/keyed-projects.ts +89 -0
  71. package/src/orchestrator/local-shortcircuit.ts +83 -23
  72. package/src/orchestrator/lockfile-claim.ts +401 -0
  73. package/src/orchestrator/logger.ts +128 -41
  74. package/src/orchestrator/metrics.ts +201 -72
  75. package/src/orchestrator/miss-save.ts +232 -0
  76. package/src/orchestrator/options.ts +63 -34
  77. package/src/orchestrator/persistent.ts +99 -0
  78. package/src/orchestrator/placement.ts +212 -0
  79. package/src/orchestrator/plan.ts +24 -11
  80. package/src/orchestrator/plugin-host.ts +200 -49
  81. package/src/orchestrator/plugin.ts +190 -14
  82. package/src/orchestrator/prepare.ts +168 -137
  83. package/src/orchestrator/projects.ts +270 -0
  84. package/src/orchestrator/remote-prefetch.ts +22 -2
  85. package/src/orchestrator/run-artifacts.ts +34 -3
  86. package/src/orchestrator/run-context.ts +20 -5
  87. package/src/orchestrator/run-lock.ts +349 -0
  88. package/src/orchestrator/run-records.ts +132 -0
  89. package/src/orchestrator/run-report.ts +6 -4
  90. package/src/orchestrator/run.ts +467 -611
  91. package/src/orchestrator/sandbox-request.ts +569 -0
  92. package/src/orchestrator/save-lane.ts +44 -0
  93. package/src/orchestrator/shell-verdict.ts +117 -0
  94. package/src/orchestrator/signals.ts +167 -0
  95. package/src/orchestrator/stable-keys.ts +259 -10
  96. package/src/orchestrator/status-line.ts +35 -2
  97. package/src/orchestrator/summary.ts +115 -25
  98. package/src/orchestrator/task-hash.ts +103 -21
  99. package/src/orchestrator/task-log-buffer.ts +15 -18
  100. package/src/orchestrator/telemetry-host.ts +27 -8
  101. package/src/orchestrator/telemetry.ts +71 -44
  102. package/src/orchestrator/upstream.ts +91 -45
  103. package/src/util/bun-version.ts +43 -0
  104. package/src/util/cgroup.ts +160 -0
  105. package/src/util/edit-distance.ts +49 -0
  106. package/src/util/errors.ts +86 -0
  107. package/src/util/hash.ts +10 -1
  108. package/src/util/index.ts +42 -5
  109. package/src/util/paths.ts +168 -5
  110. package/src/util/procfs.ts +20 -0
  111. package/src/util/settle.ts +23 -2
  112. package/src/util/size.ts +33 -0
  113. package/src/util/task-id.ts +20 -0
  114. package/src/util/timing.ts +11 -1
  115. package/src/util/verbs.ts +39 -0
  116. package/src/util/which.ts +48 -0
  117. package/src/workspace/affected.ts +368 -60
  118. package/src/workspace/config-cache.ts +271 -32
  119. package/src/workspace/config-eval.ts +60 -16
  120. package/src/workspace/config-imports.ts +131 -9
  121. package/src/workspace/config-schema.ts +1033 -0
  122. package/src/workspace/filter.ts +69 -17
  123. package/src/workspace/fingerprint.ts +59 -7
  124. package/src/workspace/index.ts +37 -3
  125. package/src/workspace/json-data.ts +90 -0
  126. package/src/workspace/load-reads.ts +32 -0
  127. package/src/workspace/lockfile.ts +41 -14
  128. package/src/workspace/migrate-scripts.ts +187 -0
  129. package/src/workspace/migration.ts +353 -0
  130. package/src/workspace/package-graph.ts +239 -31
  131. package/src/workspace/project-loader.ts +295 -815
  132. package/src/workspace/workspace.ts +266 -31
  133. package/plugins/local-cache/index.ts +0 -2
  134. package/plugins/local-executor/index.ts +0 -2
  135. package/plugins/schedule-history/index.ts +0 -2
  136. package/src/cache/cas-backend.ts +0 -115
  137. package/src/cache/digest.ts +0 -32
  138. package/src/cli/migrate-emit.ts +0 -21
  139. package/src/cli/migrate-nx.ts +0 -473
  140. package/src/cli/migrate-scripts.ts +0 -127
  141. package/src/cli/migrate-turbo.ts +0 -401
  142. package/src/cli/migrate.ts +0 -305
  143. package/src/cli/prune.ts +0 -291
  144. package/src/orchestrator/missing-plugin.ts +0 -9
  145. package/src/orchestrator/resources.ts +0 -45
  146. package/src/orchestrator/verify.ts +0 -174
  147. package/src/plugins/local-cache/index.ts +0 -11
  148. package/src/plugins/local-executor/index.ts +0 -45
  149. package/src/plugins/schedule-history/index.ts +0 -109
@@ -0,0 +1,401 @@
1
+ // The claimant's shell: what every lockfile plugin needs around its
2
+ // parser. A plugin that keys each project on its own dependency closure
3
+ // from a lockfile (`@vzn/vx-lockfile`) claims the file
4
+ // (`VxPlugin.fingerprint`), folds one digest per project through `key`,
5
+ // and answers `--affected` by digesting both sides of a change. Only the
6
+ // parser differs per package manager; the memo, the per-run gate, the
7
+ // fallback for a project the file does not list and the `--affected`
8
+ // diff are one implementation here, so a third lockfile is a parser.
9
+ //
10
+ // The digests are memoised on disk under the cache dir by the file's
11
+ // xxh3, so a warm run pays one read + hash + a small JSON read, never a
12
+ // parse; within one process (a run, a watch cycle) the file's size and
13
+ // mtime gate even the read; and per run the read happens once (a WeakMap
14
+ // on the context core hands every `key` call of a run).
15
+
16
+ import path from 'node:path'
17
+ import { rename, stat } from 'node:fs/promises'
18
+ import { xxh3, xxh3hex } from '../util/index.js'
19
+ import type { TaskNode } from '../graph/index.js'
20
+ import type {
21
+ FingerprintChange,
22
+ FingerprintClaim,
23
+ FingerprintContext,
24
+ KeyHookContext,
25
+ } from './plugin.js'
26
+
27
+ export interface LockfileClaimOptions {
28
+ /** The root-relative lockfile name, one of `WORKSPACE_FINGERPRINT_FILES`. */
29
+ readonly file: string
30
+ /**
31
+ * Every project's digest from the file's text: importer directory
32
+ * (root-relative POSIX, `.` for the root) → digest. Called once per
33
+ * distinct content; must be deterministic and throw on a file it
34
+ * cannot read (the plugin's name heads the error).
35
+ */
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[]
45
+ /** Folded into the memo's identity: bump when `digest` folds differently. */
46
+ readonly version: number
47
+ /**
48
+ * `'project'` (default): each task folds its own project's digest.
49
+ * `'workspace'`: the whole file's hash into every task — the coarse key
50
+ * core would fold, through the plugin.
51
+ */
52
+ readonly scope?: 'project' | 'workspace'
53
+ /**
54
+ * The key part's name, as `vx why` shows it under the plugin's name
55
+ * (`@vzn/vx-lockfile/pnpm`). Default `'deps'`.
56
+ */
57
+ readonly part?: string
58
+ }
59
+
60
+ /** The two hooks a lockfile plugin spreads into `definePlugin`. */
61
+ export interface LockfileClaimHooks {
62
+ readonly fingerprint: FingerprintClaim
63
+ key(task: TaskNode, ctx: KeyHookContext): Promise<Readonly<Record<string, string>> | undefined>
64
+ }
65
+
66
+ interface Digests {
67
+ /** xxh3 of the lockfile bytes; '' when the file is absent. */
68
+ readonly lock: string
69
+ readonly importers: ReadonlyMap<string, string>
70
+ }
71
+
72
+ interface Memo {
73
+ version: number
74
+ lock: string
75
+ /** `extraFiles` → content hash when the digests were computed. */
76
+ extras?: Record<string, string>
77
+ importers: Record<string, string>
78
+ }
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
+
104
+ export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks {
105
+ const { file, digest, version } = options
106
+ const scope = options.scope ?? 'project'
107
+ const part = options.part ?? 'deps'
108
+ if (scope !== 'project' && scope !== 'workspace') {
109
+ throw new Error(`scope must be 'project' or 'workspace', not ${JSON.stringify(scope)}`)
110
+ }
111
+ // Per workspace root: what the last read saw. `size` + `mtimeMs` gate a
112
+ // re-read; the content hash decides whether the digests are current.
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
+ >()
123
+ // Per run: every `key` call of one run gets the same context object.
124
+ const perRun = new WeakMap<object, Promise<Digests>>()
125
+
126
+ const load = async (workspaceRoot: string, cacheDir: string): Promise<Digests> => {
127
+ const full = path.join(workspaceRoot, file)
128
+ let st: { size: number; mtimeMs: number }
129
+ try {
130
+ st = await stat(full)
131
+ } catch {
132
+ const digests: Digests = { lock: '', importers: new Map() }
133
+ seen.set(workspaceRoot, { size: -1, mtimeMs: -1, extras: [], extraStats: [], digests })
134
+ return digests
135
+ }
136
+ const last = seen.get(workspaceRoot)
137
+ if (last !== undefined && last.size === st.size && last.mtimeMs === st.mtimeMs) {
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
+ }
147
+ }
148
+ const bytes = await Bun.file(full).bytes()
149
+ const lockHash = xxh3hex(bytes)
150
+ const memoFile = path.join(cacheDir, 'lockfile-claims', `${file}.json`)
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'))
193
+ const digests: Digests = { lock, importers }
194
+ seen.set(workspaceRoot, {
195
+ size: st.size,
196
+ mtimeMs: st.mtimeMs,
197
+ extras: rels,
198
+ extraStats: await statsOf(workspaceRoot, rels),
199
+ digests,
200
+ })
201
+ return digests
202
+ }
203
+ const loadOnce = (ctx: KeyHookContext): Promise<Digests> => {
204
+ let p = perRun.get(ctx)
205
+ if (p === undefined) {
206
+ p = load(ctx.workspaceRoot, ctx.cacheDir)
207
+ perRun.set(ctx, p)
208
+ }
209
+ return p
210
+ }
211
+
212
+ return {
213
+ fingerprint: {
214
+ files: [file],
215
+ affected(change: FingerprintChange, ctx: FingerprintContext) {
216
+ // One side absent: the file appeared or went — every project's
217
+ // installed tree is in question, and core's answer (all) is right.
218
+ if (change.before === null || change.after === null) return undefined
219
+ if (scope === 'workspace') return undefined
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`)
233
+ const names: string[] = []
234
+ for (const p of ctx.projects) {
235
+ const importer = importerOf(ctx.workspaceRoot, p.dir)
236
+ if (digestFor(before, importer) !== digestFor(after, importer)) names.push(p.name)
237
+ }
238
+ return names
239
+ },
240
+ },
241
+ async key(task, ctx) {
242
+ const digests = await loadOnce(ctx)
243
+ if (digests.lock === '') return undefined
244
+ if (scope === 'workspace') return { [part]: digests.lock }
245
+ return {
246
+ [part]: digestFor(digests.importers, importerOf(ctx.workspaceRoot, task.projectDir)),
247
+ }
248
+ },
249
+ }
250
+ }
251
+
252
+ /**
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.
262
+ */
263
+ function digestFor(importers: ReadonlyMap<string, string>, importer: string): string {
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}`)
269
+ }
270
+
271
+ /** The lockfile's importer path for a project directory: `.` for the root, POSIX otherwise. */
272
+ function importerOf(workspaceRoot: string, projectDir: string): string {
273
+ const rel = path.relative(workspaceRoot, projectDir)
274
+ return rel === '' ? '.' : rel.split(path.sep).join('/')
275
+ }
276
+
277
+ async function readMemo(
278
+ memoFile: string,
279
+ version: number,
280
+ lock: string,
281
+ ): Promise<{ extras: Record<string, string>; importers: Map<string, string> } | undefined> {
282
+ try {
283
+ const memo = (await Bun.file(memoFile).json()) as Memo
284
+ if (memo.version !== version || memo.lock !== lock) return undefined
285
+ return { extras: memo.extras ?? {}, importers: new Map(Object.entries(memo.importers)) }
286
+ } catch {
287
+ return undefined
288
+ }
289
+ }
290
+
291
+ async function computeAndMemo(
292
+ memoFile: string,
293
+ version: number,
294
+ lock: string,
295
+ extras: Record<string, string>,
296
+ importers: ReadonlyMap<string, string>,
297
+ ): Promise<ReadonlyMap<string, string>> {
298
+ const memo: Memo = { version, lock, extras, importers: Object.fromEntries(importers) }
299
+ // Write-then-rename: a reader never sees a half-written memo, and two
300
+ // concurrent runs each land a whole one.
301
+ const tmp = `${memoFile}.tmp-${process.pid}-${Date.now()}`
302
+ try {
303
+ await Bun.write(tmp, JSON.stringify(memo))
304
+ await rename(tmp, memoFile)
305
+ } catch {
306
+ // The memo is a speed-up, never a requirement: the digests are
307
+ // computed either way, and the next run computes them again.
308
+ }
309
+ return importers
310
+ }
311
+
312
+ function decode(bytes: Uint8Array): string {
313
+ return new TextDecoder().decode(bytes)
314
+ }
315
+
316
+ // --- Reach digests -----------------------------------------------------------
317
+
318
+ /** A dependency graph: one material string per node and its out-edges by index. */
319
+ export interface ReachGraph {
320
+ readonly material: readonly string[]
321
+ readonly edges: ReadonlyArray<readonly number[]>
322
+ }
323
+
324
+ /**
325
+ * One digest per node over everything the node reaches, Merkle-style: a
326
+ * change anywhere in a node's reach moves its digest, a change elsewhere
327
+ * does not. Lockfiles carry dependency cycles, so the unit is the
328
+ * strongly connected component: Tarjan's walk (iterative — a dependency
329
+ * chain can be thousands deep) emits components children-first, and each
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
332
+ * ~20 ms where one traversal per importer took 400.
333
+ */
334
+ export function reachDigests(g: ReachGraph): string[] {
335
+ const n = g.material.length
336
+ const idx = new Int32Array(n).fill(-1)
337
+ const low = new Int32Array(n)
338
+ const onStack = new Uint8Array(n)
339
+ const stack: number[] = []
340
+ const comp = new Int32Array(n).fill(-1)
341
+ const compHash: string[] = []
342
+ let counter = 0
343
+ for (let root = 0; root < n; root++) {
344
+ if (idx[root] !== -1) continue
345
+ const frames: Array<[number, number]> = [[root, 0]]
346
+ idx[root] = low[root] = counter++
347
+ stack.push(root)
348
+ onStack[root] = 1
349
+ while (frames.length > 0) {
350
+ const frame = frames[frames.length - 1]!
351
+ const v = frame[0]
352
+ const out = g.edges[v]!
353
+ if (frame[1] < out.length) {
354
+ const w = out[frame[1]++]!
355
+ if (idx[w] === -1) {
356
+ idx[w] = low[w] = counter++
357
+ stack.push(w)
358
+ onStack[w] = 1
359
+ frames.push([w, 0])
360
+ } else if (onStack[w] === 1) {
361
+ low[v] = Math.min(low[v]!, idx[w]!)
362
+ }
363
+ continue
364
+ }
365
+ frames.pop()
366
+ if (frames.length > 0) {
367
+ const u = frames[frames.length - 1]![0]
368
+ low[u] = Math.min(low[u]!, low[v]!)
369
+ }
370
+ if (low[v] !== idx[v]) continue
371
+ const members: number[] = []
372
+ for (;;) {
373
+ const w = stack.pop()!
374
+ onStack[w] = 0
375
+ comp[w] = compHash.length
376
+ members.push(w)
377
+ if (w === v) break
378
+ }
379
+ const id = compHash.length
380
+ const children = new Set<string>()
381
+ for (const m of members) {
382
+ for (const w of g.edges[m]!) if (comp[w] !== id) children.add(compHash[comp[w]!]!)
383
+ }
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)
397
+ compHash.push(h.toString(16).padStart(16, '0'))
398
+ }
399
+ }
400
+ return Array.from({ length: n }, (_, i) => compHash[comp[i]!]!)
401
+ }