@vzn/vx-migrate 0.0.0 → 0.0.484

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.
@@ -0,0 +1,638 @@
1
+ // `@vzn/vx-migrate` — an Nx repo under vx with nothing written.
2
+ //
3
+ // Fills the `project` stage from the RESOLVED Nx project graph: every
4
+ // package the workspace discovers is given the tasks its Nx targets
5
+ // define, mapped by the same mapper `bunx @vzn/vx-migrate --from nx`
6
+ // renders files from — so what runs here is what a migration would have
7
+ // written, minus the file. Executor targets run through `nx-exec` (one
8
+ // executor, one process, Nx's own `runExecutor`) unless the workspace
9
+ // translates the executor (`executors`); run-commands targets
10
+ // run as the shell they are. A task the package's own vx.config already
11
+ // declares wins; the plugin never overwrites a user's hand.
12
+ //
13
+ // The graph: Nx resolves plugin-inferred targets, `targetDefaults`,
14
+ // named inputs and `{projectRoot}` tokens when it BUILDS the graph, so
15
+ // the plugin reads a built one and never re-derives any of it. Once per
16
+ // run it stats `nx.json` and every discovered package's `project.json`
17
+ // and `package.json` against its snapshot under vx's cache dir; a newer
18
+ // one (or no snapshot) runs `nx graph --file=<snapshot>` — the only time
19
+ // Nx itself runs — and a fresh snapshot costs the stats alone. Design:
20
+ // docs/design/nx-unchanged-2026-09.md.
21
+
22
+ import { stat } from 'node:fs/promises'
23
+ import { availableParallelism } from 'node:os'
24
+ import path from 'node:path'
25
+ import {
26
+ refuseUnknownOptions,
27
+ type PluginOptionKinds,
28
+ type GeneratedProject,
29
+ type ProjectMeta,
30
+ UserError,
31
+ type VxPlugin,
32
+ } from '@vzn/vx'
33
+ import { type AdoptionRun, adoptionPlugin } from '../adoption-plugin.js'
34
+ import { collectGaps } from '../plugin-gaps.js'
35
+ import {
36
+ BUILTIN_EXECUTORS,
37
+ mapNxWorkspace,
38
+ type NxExecutors,
39
+ type NxGraph,
40
+ nxSizeText,
41
+ parseNxGraph,
42
+ readNxJson,
43
+ } from './nx-map.js'
44
+ import { exportGraph } from './export-graph.js'
45
+ export type {
46
+ NxExecutors,
47
+ NxExecutorTarget,
48
+ NxExecutorTranslation,
49
+ NxExecutorTranslator,
50
+ } from './nx-map.js'
51
+ import { listDotenv } from './nx-dotenv.js'
52
+ import { trackedKinds } from '../tracked-outputs.js'
53
+ import { relPosix } from '../paths.js'
54
+ import type { AdoptionMapping } from '../mapping-cache.js'
55
+ import { yarnrcText } from '../script-command.js'
56
+
57
+ /** The snapshot's name under vx's cache dir — local to the machine, like the cache. */
58
+ const SNAPSHOT = 'nx-project-graph.json'
59
+ /** What the snapshot was exported from (`graphInputKey`), beside it. */
60
+ const SNAPSHOT_KEY = 'nx-project-graph.key'
61
+ /** The files at the workspace root whose edit can move Nx's graph. */
62
+ const ROOT_GRAPH_FILE =
63
+ /^(nx\.json|package\.json|\.nxignore|tsconfig[^/]*\.json|pnpm-workspace\.yaml|pnpm-lock\.yaml|package-lock\.json|yarn\.lock|bun\.lockb?)$/
64
+
65
+ export interface NxPluginOptions {
66
+ /**
67
+ * Where `nx.json` lives. Defaults to the workspace root; a repo whose Nx
68
+ * workspace sits elsewhere names the directory here.
69
+ */
70
+ readonly root?: string
71
+ /**
72
+ * An exported graph to read instead of keeping a snapshot — a path
73
+ * relative to `root` (or absolute) written by `nx graph --file=<path>`.
74
+ * With it set the plugin never runs `nx`; the file is the truth until
75
+ * it is exported again.
76
+ */
77
+ readonly graph?: string
78
+ /**
79
+ * Your own executors as commands, by executor name: each function gets
80
+ * the target (project, root, configuration, resolved options) and
81
+ * returns `{ command, timeout?, env? }`, or undefined to run that target
82
+ * through `nx-exec`. `nx:run-commands`, `nx:run-script` and `nx:noop`
83
+ * are translated already and cannot be named. A function's source text
84
+ * keys the cached mapping, so it should depend on its argument alone.
85
+ */
86
+ readonly executors?: NxExecutors
87
+ }
88
+
89
+ /** Each option `NxPluginOptions` names, with its kind: derived from the type, so the two cannot drift. */
90
+ const NX_PLUGIN_KEYS: PluginOptionKinds<NxPluginOptions> = {
91
+ root: 'string',
92
+ graph: 'string',
93
+ executors: 'object',
94
+ }
95
+
96
+ function refuseExecutors(executors: NxExecutors | undefined): void {
97
+ for (const [name, fn] of Object.entries(executors ?? {})) {
98
+ if (BUILTIN_EXECUTORS.has(name))
99
+ throw new UserError(
100
+ `nx() option "executors": ${JSON.stringify(name)} is translated by nx() itself`,
101
+ )
102
+ if (typeof fn !== 'function')
103
+ throw new UserError(`nx() option "executors": ${JSON.stringify(name)} must be a function`)
104
+ }
105
+ }
106
+
107
+ /** The plugin: the adoption skeleton over `mapNxWorkspace`, one mapping per run. */
108
+ export function nx(options: NxPluginOptions = {}): VxPlugin {
109
+ refuseUnknownOptions('nx()', options, NX_PLUGIN_KEYS)
110
+ refuseExecutors(options.executors)
111
+ // One graph load per RUN, shared by `discover` and `project`: the run
112
+ // hands both stages the same projects array (discover's, grown by what
113
+ // it named), so its identity is the run's, as the mapping's is.
114
+ let graphFor: readonly ProjectMeta[] | undefined
115
+ let graph: Promise<LoadedGraph> | undefined
116
+ const graphOf = (
117
+ root: string,
118
+ cacheDir: string,
119
+ projects: readonly ProjectMeta[],
120
+ changes?: WorktreeChanges,
121
+ ) => {
122
+ if (graphFor !== projects) {
123
+ graphFor = projects
124
+ graph = loadGraph(root, cacheDir, projects, options.graph, changes)
125
+ }
126
+ return graph!
127
+ }
128
+ return adoptionPlugin(
129
+ import.meta,
130
+ async (ctx) => {
131
+ const root = options.root ?? ctx.workspaceRoot
132
+ return mapAll(
133
+ root,
134
+ ctx.projects,
135
+ await graphOf(root, ctx.cacheDir, ctx.projects),
136
+ options.executors,
137
+ )
138
+ },
139
+ // At the workspace root only, as `turbo()` claims its file.
140
+ options.root === undefined ? ['nx.json'] : [],
141
+ {
142
+ async config(workspace, ctx) {
143
+ const root = options.root ?? ctx.workspaceRoot
144
+ if (workspace.affectedBase === undefined) {
145
+ const base = await nxBase(root)
146
+ if (base !== undefined) workspace.affectedBase = base
147
+ }
148
+ if (workspace.concurrency === undefined) {
149
+ const parallel = await nxParallel(root)
150
+ if (parallel !== undefined) workspace.concurrency = parallel
151
+ }
152
+ if (workspace.cacheRetention === undefined) {
153
+ const maxSize = await nxMaxCacheSize(root)
154
+ if (maxSize !== undefined) workspace.cacheRetention = { maxSize }
155
+ }
156
+ },
157
+ // An integrated Nx repo keeps projects out of the package manager's
158
+ // list (analogjs: `project.json` libraries no glob names), and their
159
+ // targets had nowhere to attach: 1 of 21 `build` tasks ran. Each
160
+ // graph node with targets at a directory core did not find is named
161
+ // a project, by its package.json name or else its Nx name; a name a
162
+ // project already holds stays unattached, and the mapping says so.
163
+ async discover(ctx) {
164
+ const root = options.root ?? ctx.workspaceRoot
165
+ // Core's status is the workspace's: a root elsewhere asks git itself.
166
+ const changes = options.root === undefined ? () => ctx.worktreeChanges() : undefined
167
+ const { graph } = await graphOf(root, ctx.cacheDir, ctx.projects, changes)
168
+ const dirs = new Set(ctx.projects.map((m) => path.resolve(m.dir)))
169
+ const names = new Set(ctx.projects.map((m) => m.name))
170
+ const named: Array<{ dir: string; name: string }> = []
171
+ for (const [nodeName, node] of Object.entries(graph.nodes)) {
172
+ if (node?.data?.targets === undefined) continue
173
+ const dir = path.resolve(root, node.data.root ?? '')
174
+ if (dirs.has(dir) || !isWithin(ctx.workspaceRoot, dir)) continue
175
+ // A graph older than the tree can name a directory since removed.
176
+ if (
177
+ !(await stat(dir).then(
178
+ (s) => s.isDirectory(),
179
+ () => false,
180
+ ))
181
+ )
182
+ continue
183
+ const pkg = (await Bun.file(path.join(dir, 'package.json'))
184
+ .json()
185
+ .catch(() => null)) as { name?: unknown } | null
186
+ const name = typeof pkg?.name === 'string' && pkg.name !== '' ? pkg.name : nodeName
187
+ if (names.has(name)) continue
188
+ names.add(name)
189
+ dirs.add(dir)
190
+ named.push({ dir, name })
191
+ }
192
+ return named
193
+ },
194
+ },
195
+ )
196
+ }
197
+
198
+ function isWithin(root: string, dir: string): boolean {
199
+ const rel = path.relative(root, dir)
200
+ return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel))
201
+ }
202
+
203
+ /**
204
+ * nx.json's `parallel` (or the legacy runner's option): how many tasks Nx
205
+ * runs at once. Read by nothing, a repo that set 1 for a shared database
206
+ * ran on every core under vx (TanStack/router sets 5, nx-examples 1).
207
+ */
208
+ async function nxParallel(root: string): Promise<number | undefined> {
209
+ // `NX_PARALLEL` (a count, or `50%` of the cores) wins over nx.json, as
210
+ // Nx 23's `readParallelFromArgsAndEnv` reads it; a CI that set 2 for a
211
+ // small runner got nx.json's number under vx.
212
+ const env = process.env['NX_PARALLEL']?.trim()
213
+ if (env) {
214
+ const n = Number.parseInt(env, 10)
215
+ const p = env.endsWith('%') ? Math.floor((availableParallelism() * n) / 100) : n
216
+ if (Number.isInteger(p)) return Math.max(1, p)
217
+ }
218
+ const json = (await readNxJson(root).catch(() => null))?.json as
219
+ | {
220
+ parallel?: unknown
221
+ tasksRunnerOptions?: { default?: { options?: { parallel?: unknown } } }
222
+ }
223
+ | undefined
224
+ const p = json?.parallel ?? json?.tasksRunnerOptions?.default?.options?.parallel
225
+ return typeof p === 'number' && Number.isInteger(p) && p > 0 ? p : undefined
226
+ }
227
+
228
+ /**
229
+ * What `nx affected` compares with when no `--base` is given: `NX_BASE`,
230
+ * then nx.json's `defaultBase` (Nx 19's `affected.defaultBase` before it).
231
+ * Read by nothing, a git-flow repo's `develop` was lost and a bare
232
+ * `vx run … --affected` diffed against origin/HEAD.
233
+ */
234
+ async function nxBase(root: string): Promise<string | undefined> {
235
+ const env = Bun.env['NX_BASE']?.trim()
236
+ if (env) return env
237
+ const json = (await readNxJson(root).catch(() => null))?.json as
238
+ | { defaultBase?: unknown; affected?: { defaultBase?: unknown } }
239
+ | undefined
240
+ const b = json?.defaultBase ?? json?.affected?.defaultBase
241
+ return typeof b === 'string' && b.trim() !== '' ? b.trim() : undefined
242
+ }
243
+
244
+ /**
245
+ * nx.json's `maxCacheSize`, or `NX_MAX_CACHE_SIZE` above it, as core's
246
+ * size: Nx caps its local cache there (`resolveMaxCacheSize`), and read by
247
+ * nothing a capped cache grew without bound under vx, as turbo.json's
248
+ * `cacheMaxSize` once did (G-49). 1024-based, a fraction truncated to
249
+ * bytes; `0` is no cap. A value Nx would refuse is left to core's
250
+ * refusal, which names the field.
251
+ */
252
+ async function nxMaxCacheSize(root: string): Promise<string | undefined> {
253
+ const env = process.env['NX_MAX_CACHE_SIZE']
254
+ const raw =
255
+ env !== undefined
256
+ ? env
257
+ : ((await readNxJson(root).catch(() => null))?.json as { maxCacheSize?: unknown } | undefined)
258
+ ?.maxCacheSize
259
+ return nxSizeText(raw)
260
+ }
261
+
262
+ interface LoadedGraph {
263
+ readonly text: string
264
+ readonly graph: NxGraph
265
+ readonly notes: readonly string[]
266
+ }
267
+
268
+ async function mapAll(
269
+ root: string,
270
+ metas: readonly ProjectMeta[],
271
+ loaded: LoadedGraph,
272
+ executors: NxExecutors | undefined,
273
+ ): Promise<AdoptionRun> {
274
+ const { text, graph, notes } = loaded
275
+ const reads = await nxReads(root, metas, text, graph, notes)
276
+ // The mapping is cached on what it read; a translator is code it ran.
277
+ if (executors !== undefined)
278
+ reads.push(JSON.stringify(Object.entries(executors).map(([k, fn]) => [k, String(fn)])))
279
+ return {
280
+ name: 'nx',
281
+ spareTracked: true,
282
+ reads,
283
+ map: (tracked) => index(root, metas, graph, notes, tracked, executors),
284
+ }
285
+ }
286
+
287
+ function relRoot(p: string): string {
288
+ const s = p.replace(/\/+$/, '')
289
+ return s === '' ? '.' : s
290
+ }
291
+
292
+ const textOf = (file: string): Promise<string> =>
293
+ Bun.file(file)
294
+ .text()
295
+ .catch(() => '\0absent')
296
+
297
+ /**
298
+ * Everything the mapping reads: the graph, nx.json and its `extends` chain, every package manifest
299
+ * and the package.json of each graph node no package matches (its
300
+ * synthetic project's name), the `.env` names in every project dir,
301
+ * NX_LOAD_DOT_ENV_FILES, whether the bins the tasks run are installed,
302
+ * and the graph load's own notes.
303
+ */
304
+ async function nxReads(
305
+ root: string,
306
+ metas: readonly ProjectMeta[],
307
+ graphText: string,
308
+ graph: NxGraph,
309
+ notes: readonly string[],
310
+ ): Promise<string[]> {
311
+ const known = new Set(metas.map((m) => relRoot(path.relative(root, m.dir))))
312
+ const unmatched = Object.values(graph.nodes)
313
+ .map((n) => relRoot(n?.data?.root ?? ''))
314
+ .filter((r) => !known.has(r))
315
+ .sort()
316
+ const dotenv = await listDotenv(root, [...known, ...unmatched])
317
+ const installed = await Promise.all(
318
+ [
319
+ ['.bin', 'nx-exec'],
320
+ ['.bin', 'nx-env'],
321
+ ['nx', 'package.json'],
322
+ ].map((p) => Bun.file(path.join(root, 'node_modules', ...p)).exists()),
323
+ )
324
+ // nx.json's whole `extends` chain: the mapper reads named inputs from it.
325
+ const chain = (await readNxJson(root).catch(() => null))?.files ?? [path.join(root, 'nx.json')]
326
+ return [
327
+ graphText,
328
+ ...(await Promise.all(chain.map(textOf))),
329
+ // A config file added beside mapped tasks changes what an output may cover.
330
+ JSON.stringify(metas.map((m) => [m.name, m.dir, m.packageJson, m.configPath])),
331
+ ...(await Promise.all(unmatched.map((r) => textOf(path.join(root, r, 'package.json'))))),
332
+ JSON.stringify(
333
+ [...dotenv]
334
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
335
+ .map(([d, names]) => [d, [...names].sort()]),
336
+ ),
337
+ String(process.env['NX_LOAD_DOT_ENV_FILES']),
338
+ JSON.stringify(installed),
339
+ await yarnrcText(root),
340
+ ...notes,
341
+ ]
342
+ }
343
+
344
+ async function index(
345
+ root: string,
346
+ metas: readonly ProjectMeta[],
347
+ graph: NxGraph,
348
+ loadNotes: readonly string[],
349
+ trackedFiles: () => Promise<readonly string[] | null>,
350
+ executors: NxExecutors | undefined,
351
+ ): Promise<AdoptionMapping> {
352
+ const notes = [...loadNotes]
353
+ const tracked = await trackedFiles()
354
+ const configs = new Map(
355
+ metas.flatMap((m) =>
356
+ m.configPath === null
357
+ ? []
358
+ : [[relRoot(relPosix(root, m.dir)), path.basename(m.configPath)] as const],
359
+ ),
360
+ )
361
+ const mapped = await mapNxWorkspace(root, metas, graph, {
362
+ cacheable: new Set(),
363
+ attached: new Set(metas.map((m) => m.name)),
364
+ ...(tracked === null ? {} : { tracked: trackedKinds(tracked) }),
365
+ ownConfig: (rel) => configs.get(relRoot(rel)) ?? null,
366
+ ...(executors === undefined ? {} : { executors }),
367
+ })
368
+ notes.push(...mapped.notes)
369
+ const byName = new Map<string, GeneratedProject>()
370
+ const visited = new Set(metas.map((m) => m.name))
371
+ const unattached: string[] = []
372
+ for (const project of mapped.projects) {
373
+ // The mapper synthesizes a project for a graph node no package
374
+ // matches; `discover` made each one a project, but for a name a
375
+ // package holds or a directory gone. Those have nowhere to go.
376
+ if (!visited.has(project.name)) {
377
+ unattached.push(project.name)
378
+ continue
379
+ }
380
+ byName.set(project.name, project)
381
+ }
382
+ if (unattached.length > 0) {
383
+ notes.push(
384
+ `Nx project(s) ${unattached.join(', ')} have no workspace package to attach targets to ` +
385
+ '(a package holds the name, or the directory is gone) — run those targets with nx',
386
+ )
387
+ }
388
+ // The bins executor lines and `.env`-loading lines start with: installed
389
+ // by this package, so absent only when the plugin is loaded by path (a
390
+ // checkout, a link) — then every such task would fail with `not found`.
391
+ const uses = (bin: string) =>
392
+ [...byName.values()].some((p) =>
393
+ p.tasks.some((t) => {
394
+ const cmd = (t.task?.['exec'] as { command?: unknown } | undefined)?.command
395
+ return typeof cmd === 'string' && cmd.startsWith(`${bin} `)
396
+ }),
397
+ )
398
+ let loadsNx = false
399
+ for (const [bin, what] of [
400
+ ['nx-exec', 'executor targets run'],
401
+ ['nx-env', 'targets with `.env` files run'],
402
+ ] as const) {
403
+ if (!uses(bin)) continue
404
+ loadsNx = true
405
+ if (!(await Bun.file(path.join(root, 'node_modules', '.bin', bin)).exists())) {
406
+ notes.push(
407
+ `${what} through \`${bin}\`, which is not in node_modules/.bin — add ` +
408
+ "@vzn/vx-migrate to the workspace's devDependencies so its bin is on every task's PATH",
409
+ )
410
+ }
411
+ }
412
+ // Both bins run Nx's own code from the workspace; a repo run from an
413
+ // exported `graph` may have none installed.
414
+ if (
415
+ loadsNx &&
416
+ !(await Bun.file(path.join(root, 'node_modules', 'nx', 'package.json')).exists())
417
+ ) {
418
+ notes.push(
419
+ 'nx-exec and nx-env load Nx from the workspace, and node_modules/nx is not there — ' +
420
+ 'install nx, or those tasks fail',
421
+ )
422
+ }
423
+ return {
424
+ byName,
425
+ gaps: collectGaps(
426
+ mapped.projects.filter((p) => byName.has(p.name)),
427
+ notes,
428
+ ),
429
+ }
430
+ }
431
+
432
+ /**
433
+ * The project roots of the graph last exported, absolute; none without
434
+ * one. Not the workspace root: its own files count as the root's do
435
+ * (`ROOT_GRAPH_FILE`), and a stray write there re-exported every run.
436
+ */
437
+ async function snapshotRoots(root: string, snapshot: string): Promise<string[]> {
438
+ try {
439
+ const nodes = parseNxGraph(await Bun.file(snapshot).text(), snapshot).nodes
440
+ return Object.values(nodes)
441
+ .map((n) => path.resolve(root, n?.data?.root ?? ''))
442
+ .filter((d) => d !== path.resolve(root))
443
+ } catch {
444
+ return []
445
+ }
446
+ }
447
+
448
+ /** The newest mtime among the files whose edit changes the graph, or 0 when none is readable. */
449
+ async function newestInput(root: string, dirs: readonly string[]): Promise<number> {
450
+ // nx.json's `extends` chain too: a base's edit changes the graph as much.
451
+ const nxJson = await readNxJson(root).catch(() => null)
452
+ const files = [
453
+ ...(nxJson?.files ?? [path.join(root, 'nx.json')]),
454
+ path.join(root, 'package.json'),
455
+ ]
456
+ for (const d of dirs) files.push(path.join(d, 'project.json'), path.join(d, 'package.json'))
457
+ const mtimes = await Promise.all(
458
+ files.map((f) =>
459
+ stat(f).then(
460
+ (s) => s.mtimeMs,
461
+ () => 0,
462
+ ),
463
+ ),
464
+ )
465
+ return Math.max(...mtimes)
466
+ }
467
+
468
+ /** `DiscoverContext.worktreeChanges`: git's changed paths, root-relative, or null outside a worktree. */
469
+ type WorktreeChanges = () => Promise<readonly string[] | null>
470
+
471
+ /** `git status` at `root` itself, for a graph loaded without the run's (another root, a skipped discover). */
472
+ async function ownChanges(root: string): Promise<string[] | null> {
473
+ const status = Bun.spawn(['git', '--no-optional-locks', 'status', '--porcelain', '-z', '-uall'], {
474
+ cwd: root,
475
+ stdout: 'pipe',
476
+ stderr: 'ignore',
477
+ })
478
+ const [out, code] = await Promise.all([new Response(status.stdout).text(), status.exited])
479
+ if (code !== 0) return null
480
+ const paths: string[] = []
481
+ const records = out.split('\0')
482
+ for (let i = 0; i < records.length; i++) {
483
+ const r = records[i]!
484
+ if (r.length < 4) continue
485
+ paths.push(r.slice(3))
486
+ // A rename or copy carries its source as the next record.
487
+ if (r[0] === 'R' || r[0] === 'C') i++
488
+ }
489
+ return paths
490
+ }
491
+
492
+ /**
493
+ * What the graph is computed from, as one digest: nx.json's chain by content,
494
+ * and — since Nx derives edges from SOURCE imports
495
+ * (`@nx/js`) — the worktree as git sees it: HEAD, `git status -z -uall`, and
496
+ * the content of every path that lists. Freshness by manifest mtimes kept a
497
+ * snapshot without the edge an added `import` makes, and a later edit to the
498
+ * imported project replayed its dependant from cache (Next 26). The status
499
+ * text alone is not enough: it names a modified file by path, not by what it
500
+ * holds, so a second edit to a dirty file kept the key (measured, item 1075).
501
+ * The status is the run's own when core hands it over (`changes`): a second
502
+ * whole-tree walk cost refine ~96 ms of a 417 ms warm run (I-6).
503
+ * Null outside a git worktree: the caller falls back to the mtimes.
504
+ */
505
+ async function graphInputKey(
506
+ root: string,
507
+ cacheDir: string,
508
+ dirs: readonly string[],
509
+ changes: WorktreeChanges | undefined,
510
+ ): Promise<string | null> {
511
+ const head = Bun.spawn(['git', 'rev-parse', '--verify', '-q', 'HEAD'], {
512
+ cwd: root,
513
+ stdout: 'pipe',
514
+ stderr: 'ignore',
515
+ })
516
+ const [headOut, changed] = await Promise.all([
517
+ new Response(head.stdout).text(),
518
+ changes === undefined ? ownChanges(root) : changes(),
519
+ ])
520
+ await head.exited
521
+ if (changed === null) return null
522
+ // The cache dir holds the snapshot itself: a workspace that does not
523
+ // ignore it would see the export move the key it was keyed on.
524
+ const cacheRel = path.relative(root, cacheDir).split(path.sep).join('/')
525
+ const inCache = (p: string): boolean =>
526
+ cacheRel !== '' &&
527
+ !cacheRel.startsWith('..') &&
528
+ (p === cacheRel || p.startsWith(`${cacheRel}/`))
529
+ // Nx's own caches, written by the export itself: under a root project
530
+ // (a standalone repo) that does not ignore them, each export moved the key
531
+ // and the next run exported again. `.nx/installation` pins Nx's version
532
+ // and stays in.
533
+ const nxCache = (p: string): boolean =>
534
+ p.startsWith('.nx/cache/') || p.startsWith('.nx/workspace-data/')
535
+ // What can move the graph: a file under a project root (its sources, the
536
+ // configs a plugin infers targets from), or a root file Nx reads. A task's
537
+ // stray write at the root (a report, a log) is not, and counting it
538
+ // re-exported the graph on every run after it.
539
+ // A `project.json` anywhere: a new one is a project no root lists yet.
540
+ const roots = dirs.map((d) => path.relative(root, d).split(path.sep).join('/'))
541
+ const graphFile = (p: string): boolean =>
542
+ p === 'project.json' ||
543
+ p.endsWith('/project.json') ||
544
+ roots.some((r) => r === '' || p.startsWith(`${r}/`)) ||
545
+ (!p.includes('/') && ROOT_GRAPH_FILE.test(p))
546
+ const listed = [...new Set(changed.filter((p) => !inCache(p) && !nxCache(p) && graphFile(p)))]
547
+ // A manifest needs no read of its own: git lists it when it is edited, and
548
+ // HEAD moves when an edit is committed (reading all 2,000 cost 50 ms at
549
+ // 1,000 projects). nx.json's chain does: a base may sit in node_modules,
550
+ // where git does not look.
551
+ const nxJson = await readNxJson(root).catch(() => null)
552
+ const files = [
553
+ ...(nxJson?.files ?? [path.join(root, 'nx.json')]),
554
+ ...listed.sort().map((p) => path.join(root, p)),
555
+ ]
556
+ const hasher = new Bun.CryptoHasher('sha256')
557
+ hasher.update(`${headOut.trim()}\0`)
558
+ const bodies = await Promise.all(
559
+ files.map((f) =>
560
+ Bun.file(f)
561
+ .bytes()
562
+ .catch(() => null),
563
+ ),
564
+ )
565
+ files.forEach((f, i) => {
566
+ const body = bodies[i] ?? null
567
+ hasher.update(`${f}\0${body === null ? 'gone' : body.length}\0`)
568
+ if (body !== null) hasher.update(body)
569
+ })
570
+ return hasher.digest('hex')
571
+ }
572
+
573
+ async function loadGraph(
574
+ root: string,
575
+ cacheDir: string,
576
+ metas: readonly ProjectMeta[],
577
+ exported: string | undefined,
578
+ changes: WorktreeChanges | undefined,
579
+ ): Promise<LoadedGraph> {
580
+ const notes: string[] = []
581
+ const { text, label } = await loadGraphText(root, cacheDir, metas, exported, notes, changes)
582
+ return { text, graph: parseNxGraph(text, label), notes }
583
+ }
584
+
585
+ async function loadGraphText(
586
+ root: string,
587
+ cacheDir: string,
588
+ metas: readonly ProjectMeta[],
589
+ exported: string | undefined,
590
+ notes: string[],
591
+ changes: WorktreeChanges | undefined,
592
+ ): Promise<{ text: string; label: string }> {
593
+ if (exported !== undefined) {
594
+ const file = path.resolve(root, exported)
595
+ const text = await Bun.file(file)
596
+ .text()
597
+ .catch((err: unknown) => {
598
+ const msg = err instanceof Error ? err.message : String(err)
599
+ throw new UserError(`[@vzn/vx-migrate] nx(): cannot read graph ${exported}: ${msg}`)
600
+ })
601
+ return { text, label: exported }
602
+ }
603
+ const snapshot = path.join(cacheDir, SNAPSHOT)
604
+ const keyFile = path.join(cacheDir, SNAPSHOT_KEY)
605
+ // The last graph's project roots with the discovered ones: `discover`
606
+ // loads the graph before the projects it names are projects, and an
607
+ // edit under one of them must still move the key.
608
+ const dirs = [...new Set([...metas.map((m) => m.dir), ...(await snapshotRoots(root, snapshot))])]
609
+ const [have, key, keyed] = await Promise.all([
610
+ stat(snapshot).then(
611
+ (s) => s.mtimeMs,
612
+ () => -1,
613
+ ),
614
+ graphInputKey(root, cacheDir, dirs, changes),
615
+ Bun.file(keyFile)
616
+ .text()
617
+ .catch(() => null),
618
+ ])
619
+ // Keyed before the export, so an edit made while Nx computes costs one
620
+ // more export instead of hiding under the new snapshot.
621
+ const stale = key === null ? have < (await newestInput(root, dirs)) : have < 0 || keyed !== key
622
+ if (stale) {
623
+ const failure = await exportGraph(root, snapshot)
624
+ if (failure !== null) {
625
+ if (have < 0) throw new UserError(`[@vzn/vx-migrate] nx(): ${failure}`)
626
+ notes.push(`${failure} — running on the previous graph snapshot`)
627
+ } else if (key !== null) {
628
+ // Keyed as the next run will key it: over the roots this export
629
+ // found too, or a first export (no snapshot to read roots from)
630
+ // re-exported on the run after.
631
+ const found = (await snapshotRoots(root, snapshot)).filter((d) => !dirs.includes(d))
632
+ const next =
633
+ found.length > 0 ? await graphInputKey(root, cacheDir, [...dirs, ...found], changes) : key
634
+ if (next !== null) await Bun.write(keyFile, next)
635
+ }
636
+ }
637
+ return { text: await Bun.file(snapshot).text(), label: path.relative(root, snapshot) }
638
+ }