@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,1430 @@
1
+ // Nx → vx mapping over the RESOLVED project graph, shared by the two
2
+ // consumers: `bunx @vzn/vx-migrate --from nx` writes it to files, and the
3
+ // `nx()` plugin runs it live. Static by design: targets that Nx plugins
4
+ // infer at runtime are whatever the graph says. Named inputs expand from
5
+ // nx.json when readable; the graph's dependency edges are ignored (vx
6
+ // derives package edges from package.json manifests) except for one
7
+ // report line counting edges with no manifest counterpart.
8
+ //
9
+ // Executors: `nx:run-commands`, a plain `command` and `nx:run-script` are
10
+ // shell; `nx:noop` is a group; an executor the workspace translates
11
+ // (`nx({ executors })`) is the command its function returns; every other
12
+ // executor runs through this package's `nx-exec` bin with the executor and
13
+ // its resolved options on the command line (design:
14
+ // docs/design/nx-unchanged-2026-09.md). A
15
+ // configuration is a task of its own, `<target>:<configuration>`; the
16
+ // default configuration is folded into the base task.
17
+
18
+ import path from 'node:path'
19
+ import {
20
+ buildPackageGraph,
21
+ type GeneratedProject,
22
+ type GeneratedTask,
23
+ type ProjectMeta,
24
+ UserError,
25
+ } from '@vzn/vx'
26
+ import { mapRunCommands, shellQuote } from '../nx-command.js'
27
+ import { scriptCommand, yarnPnp } from '../script-command.js'
28
+ import {
29
+ ownFileTakeBacks,
30
+ resolveSharedOutputs,
31
+ resolveSharedWorkspaceOutputs,
32
+ wildcardOutput,
33
+ wildcardTodo,
34
+ } from '../shared-outputs.js'
35
+ import type { TrackedKinds } from '../tracked-outputs.js'
36
+ import { packageScripts, relPosix } from '../paths.js'
37
+ import { mapNxDeps, matchNxProjects, type TaskNameFor } from './nx-deps.js'
38
+ import {
39
+ dotenvCandidates,
40
+ type DotenvListing,
41
+ existingDotenv,
42
+ listDotenv,
43
+ ownerTargetOf,
44
+ } from './nx-dotenv.js'
45
+ import { emptyNxInputs, expandNxInputs } from './nx-inputs.js'
46
+ import { planNxUpstream, type NxUpstream } from './nx-upstream.js'
47
+ import { mapNxOutputs, nxDefaultOutputs, nxProjectOutputs } from './nx-outputs.js'
48
+
49
+ const PLACEHOLDER = "echo 'TODO(vx-migrate): fill in' && exit 1"
50
+
51
+ interface NxTarget {
52
+ executor?: string
53
+ command?: string
54
+ options?: Record<string, unknown>
55
+ configurations?: Record<string, Record<string, unknown>>
56
+ defaultConfiguration?: string
57
+ inputs?: unknown[]
58
+ outputs?: string[]
59
+ dependsOn?: unknown[]
60
+ cache?: boolean
61
+ continuous?: boolean
62
+ parallelism?: boolean
63
+ syncGenerators?: unknown[]
64
+ metadata?: { nonAtomizedTarget?: unknown }
65
+ }
66
+
67
+ interface NxNode {
68
+ name?: string
69
+ data?: {
70
+ root?: string
71
+ namedInputs?: Record<string, unknown[]>
72
+ targets?: Record<string, NxTarget>
73
+ metadata?: { targetGroups?: unknown }
74
+ }
75
+ }
76
+
77
+ type NxEdge = { source?: string; target?: string }
78
+
79
+ /** The two shapes a graph file takes, reduced to the one the mapper reads. */
80
+ export interface NxGraph {
81
+ nodes: Record<string, NxNode>
82
+ dependencies: unknown
83
+ }
84
+
85
+ /**
86
+ * Both `{ graph: { nodes, dependencies } }` (`nx graph --file`) and
87
+ * top-level `{ nodes, dependencies }` (Nx's own cache) exist across nx
88
+ * versions. `label` names the source in the error.
89
+ */
90
+ export function parseNxGraph(text: string, label: string): NxGraph {
91
+ let parsed: unknown
92
+ try {
93
+ parsed = JSON.parse(text)
94
+ } catch (err) {
95
+ const msg = err instanceof Error ? err.message : String(err)
96
+ throw new UserError(`failed to parse ${label}: ${msg}`)
97
+ }
98
+ // `?.`: a file of `null` is JSON, and read as an object it threw a bare
99
+ // TypeError instead of naming the shape it wanted (item 811).
100
+ const g = ((parsed as { graph?: unknown } | null)?.graph ?? parsed) as {
101
+ nodes?: unknown
102
+ dependencies?: unknown
103
+ }
104
+ const nodes = g?.nodes
105
+ if (typeof nodes !== 'object' || nodes === null) {
106
+ throw new UserError(
107
+ `${label}: unrecognized shape — expected { graph: { nodes, dependencies } } ` +
108
+ 'or { nodes, dependencies }',
109
+ )
110
+ }
111
+ checkNxNodes(nodes as Record<string, unknown>, label)
112
+ for (const node of Object.values(nodes as Record<string, NxNode>)) {
113
+ const targets = node.data?.targets ?? {}
114
+ for (const [name, target] of Object.entries(targets)) {
115
+ const canonical = target.executor === undefined ? undefined : LEGACY[target.executor]
116
+ if (canonical !== undefined) target.executor = canonical
117
+ // Nx's own normalization (`target-normalization`): a target with
118
+ // neither runs its dependencies, or is nothing and is dropped. A graph
119
+ // that skipped it (an older export) wrote a failing placeholder.
120
+ if (target.executor === undefined && target.command === undefined) {
121
+ if ((target.dependsOn ?? []).length > 0) target.executor = 'nx:noop'
122
+ else delete targets[name]
123
+ }
124
+ }
125
+ }
126
+ return { nodes: nodes as Record<string, NxNode>, dependencies: g.dependencies }
127
+ }
128
+
129
+ /**
130
+ * Nx 15–16's names for its own executors, which a graph keeps as the
131
+ * project wrote them: each re-exported `nx:`'s, and a migration read
132
+ * them as unknown executors, a failing placeholder each.
133
+ */
134
+ const LEGACY: Readonly<Record<string, string>> = {
135
+ '@nrwl/workspace:run-commands': 'nx:run-commands',
136
+ '@nx/workspace:run-commands': 'nx:run-commands',
137
+ '@nrwl/workspace:run-script': 'nx:run-script',
138
+ '@nx/workspace:run-script': 'nx:run-script',
139
+ }
140
+
141
+ const isRecord = (v: unknown): v is Record<string, unknown> =>
142
+ typeof v === 'object' && v !== null && !Array.isArray(v)
143
+
144
+ /** Every dependency name the root package.json declares; none without one. */
145
+ async function rootDependencies(root: string): Promise<ReadonlySet<string>> {
146
+ const pkg = (await Bun.file(path.join(root, 'package.json'))
147
+ .json()
148
+ .catch(() => null)) as Record<string, unknown> | null
149
+ const names = new Set<string>()
150
+ for (const field of ['dependencies', 'devDependencies', 'optionalDependencies']) {
151
+ const deps = pkg?.[field]
152
+ if (deps && typeof deps === 'object') for (const n of Object.keys(deps)) names.add(n)
153
+ }
154
+ return names
155
+ }
156
+
157
+ /**
158
+ * The shape of every node field the mapper reads, refused by name. A
159
+ * number where the graph holds a list reached the mapper's loops as a
160
+ * TypeError: `"dependsOn": true` threw `true is not iterable` (fuzzed,
161
+ * L-15, as turbo.json's reader did).
162
+ */
163
+ function checkNxNodes(nodes: Record<string, unknown>, label: string): void {
164
+ const refuse = (at: string, what: string): never => {
165
+ throw new UserError(`${label}: ${at} must be ${what}`)
166
+ }
167
+ const optional = (v: unknown, at: string, ok: (v: unknown) => boolean, what: string): void => {
168
+ if (v !== undefined && !ok(v)) refuse(at, what)
169
+ }
170
+ const strings = (v: unknown) => Array.isArray(v) && v.every((x) => typeof x === 'string')
171
+ const entries = (v: unknown) =>
172
+ Array.isArray(v) && v.every((x) => typeof x === 'string' || isRecord(x))
173
+ for (const [id, node] of Object.entries(nodes)) {
174
+ const at = `nodes.${JSON.stringify(id)}`
175
+ if (!isRecord(node)) refuse(at, 'an object')
176
+ const n = node as Record<string, unknown>
177
+ optional(n['name'], `${at}.name`, (v) => typeof v === 'string', 'a string')
178
+ optional(n['data'], `${at}.data`, isRecord, 'an object')
179
+ const data = (n['data'] ?? {}) as Record<string, unknown>
180
+ optional(data['root'], `${at}.data.root`, (v) => typeof v === 'string', 'a string')
181
+ optional(data['targets'], `${at}.data.targets`, isRecord, 'an object of targets')
182
+ for (const [name, target] of Object.entries((data['targets'] ?? {}) as object)) {
183
+ const t = `${at}.data.targets.${JSON.stringify(name)}`
184
+ if (!isRecord(target)) refuse(t, 'an object')
185
+ const tt = target as Record<string, unknown>
186
+ optional(tt['dependsOn'], `${t}.dependsOn`, entries, 'an array of targets')
187
+ optional(tt['inputs'], `${t}.inputs`, entries, 'an array of inputs')
188
+ optional(tt['outputs'], `${t}.outputs`, strings, 'an array of strings')
189
+ optional(tt['options'], `${t}.options`, isRecord, 'an object')
190
+ for (const k of ['executor', 'command'])
191
+ optional(tt[k], `${t}.${k}`, (v) => typeof v === 'string', 'a string')
192
+ }
193
+ }
194
+ }
195
+
196
+ export interface MapNxOptions {
197
+ /** nx.json's legacy `cacheableOperations`, for a graph whose targets carry no `cache` field. */
198
+ readonly cacheable: ReadonlySet<string>
199
+ /**
200
+ * The projects whose tasks will exist (the plugin's workspace packages).
201
+ * Absent, every mapped project's do: the CLI writes a config for each.
202
+ */
203
+ readonly attached?: ReadonlySet<string>
204
+ /**
205
+ * What git tracks under a root-relative project directory: a
206
+ * wildcard-first output of a kind it has none of stays cached, as in
207
+ * `turbo()`. Absent, every wildcard-first output runs uncached.
208
+ */
209
+ readonly tracked?: (rel: string) => TrackedKinds
210
+ /**
211
+ * The config file name a root-relative project's mapped tasks live beside
212
+ * (null: none), which core holds an output to. Absent, every spelling.
213
+ */
214
+ readonly ownConfig?: (rel: string) => string | null
215
+ /**
216
+ * A script's `npm_package_name` / `npm_package_version`: `vx-migrate`
217
+ * reads them from the manifest it imports (`{ raw: 'pkg.version' }`), so a
218
+ * bump reaches a written config, as `turbo()`'s mapper does; absent, the
219
+ * values themselves (the plugin re-maps on a manifest edit).
220
+ */
221
+ readonly manifestField?: (key: 'name' | 'version') => unknown
222
+ /**
223
+ * A one-shot migration (`bunx @vzn/vx-migrate`), not the live plugin:
224
+ * `nx-release-publish` is not written, and the notes and TODOs speak to
225
+ * a written config. Executors are `nx-exec` lines either way.
226
+ */
227
+ readonly migration?: boolean
228
+ /**
229
+ * The workspace's own translation of an executor to a command, by
230
+ * executor name; one that returns undefined leaves the target to
231
+ * `nx-exec`. The live plugin's only: a migration writes no function.
232
+ */
233
+ readonly executors?: NxExecutors
234
+ }
235
+
236
+ /** One target of a translated executor, as the graph resolved it. */
237
+ export interface NxExecutorTarget {
238
+ readonly executor: string
239
+ readonly project: string
240
+ /** The project's directory, workspace-root-relative (`.` at the root). */
241
+ readonly projectRoot: string
242
+ readonly target: string
243
+ /** Set for a `<target>:<configuration>` task; the default one is folded into the base task. */
244
+ readonly configuration: string | undefined
245
+ /** The resolved options, the configuration's merged over the target's. */
246
+ readonly options: Readonly<Record<string, unknown>>
247
+ }
248
+
249
+ /** What a translated target runs as. The command runs in the project dir. */
250
+ export interface NxExecutorTranslation {
251
+ readonly command: string
252
+ /** `exec.timeout`, in ms. */
253
+ readonly timeout?: number
254
+ /** Defined in the task's environment, as a run-commands `env` is. */
255
+ readonly env?: Readonly<Record<string, string>>
256
+ }
257
+
258
+ /** Undefined runs the target through `nx-exec`, as an untranslated executor does. */
259
+ export type NxExecutorTranslator = (target: NxExecutorTarget) => NxExecutorTranslation | undefined
260
+
261
+ export type NxExecutors = Readonly<Record<string, NxExecutorTranslator>>
262
+
263
+ /** Executors the mapper translates itself, which a workspace function may not claim. */
264
+ export const BUILTIN_EXECUTORS: ReadonlySet<string> = new Set([
265
+ 'nx:noop',
266
+ 'nx:run-commands',
267
+ 'nx:run-script',
268
+ ...Object.keys(LEGACY),
269
+ ])
270
+
271
+ /** The options with what the mapper reads itself: the root's dependency names. */
272
+ type MapOpts = MapNxOptions & {
273
+ readonly rootDeps: ReadonlySet<string>
274
+ readonly pnp: boolean
275
+ /** Tasks per `syncGenerators` list, said once in the notes. */
276
+ readonly syncTasks: Map<string, number>
277
+ }
278
+
279
+ export interface NxMapping {
280
+ readonly projects: GeneratedProject[]
281
+ /** Workspace-wide gaps, one line each. */
282
+ readonly notes: string[]
283
+ }
284
+
285
+ function normRel(p: string): string {
286
+ return p === '' || p === '.' ? '.' : p.replace(/\/$/, '')
287
+ }
288
+
289
+ export async function mapNxWorkspace(
290
+ root: string,
291
+ metas: readonly ProjectMeta[],
292
+ graph: NxGraph,
293
+ opts: MapNxOptions,
294
+ ): Promise<NxMapping> {
295
+ const nodeMap = graph.nodes
296
+ const g = graph
297
+
298
+ const { namedInputs, cacheable, globalSync } = await readNxJsonFacts(root)
299
+ const mapOpts: MapOpts = {
300
+ ...opts,
301
+ cacheable: new Set([...opts.cacheable, ...cacheable]),
302
+ rootDeps: await rootDependencies(root),
303
+ pnp: yarnPnp(root),
304
+ syncTasks: new Map(),
305
+ }
306
+
307
+ const metaByRel = new Map<string, ProjectMeta>()
308
+ for (const meta of metas) metaByRel.set(normRel(relPosix(root, meta.dir)), meta)
309
+ const metaByNode = new Map<string, ProjectMeta>()
310
+ const nodeByMeta = new Map<ProjectMeta, NxNode>()
311
+ const nodeNameOf = new Map<ProjectMeta, string>()
312
+ for (const [nodeName, node] of Object.entries(nodeMap)) {
313
+ const meta = metaByRel.get(normRel(node?.data?.root ?? ''))
314
+ if (meta) {
315
+ metaByNode.set(nodeName, meta)
316
+ nodeByMeta.set(meta, node)
317
+ nodeNameOf.set(meta, nodeName)
318
+ }
319
+ }
320
+
321
+ // Graph nodes with no discovered counterpart: the ROOT project is
322
+ // the common case — `packages/*` discovery never lists the
323
+ // workspace root, but Nx routinely defines root targets. Synthesize
324
+ // a meta for any unmatched node whose root resolves inside the
325
+ // workspace, so its targets migrate too.
326
+ const allMetas: ProjectMeta[] = [...metas]
327
+ const taken = new Set(metas.map((m) => m.name))
328
+ for (const [nodeName, node] of Object.entries(nodeMap)) {
329
+ if (metaByNode.has(nodeName)) continue
330
+ const relRoot = normRel(node?.data?.root ?? '')
331
+ if (node?.data?.targets === undefined) continue
332
+ const dir = relRoot === '' || relRoot === '.' ? root : path.join(root, relRoot)
333
+ let pkg: ProjectMeta['packageJson'] = { name: nodeName }
334
+ try {
335
+ pkg = (await Bun.file(path.join(dir, 'package.json')).json()) as ProjectMeta['packageJson']
336
+ } catch {
337
+ // No manifest at the node root — keep the synthetic one.
338
+ }
339
+ // A manifest name a package already holds (a root package.json named
340
+ // after its app) replaced that package's tasks with the node's; the
341
+ // Nx name is the node's own.
342
+ const name = [pkg.name, nodeName].find((n) => n && !taken.has(n))
343
+ if (name === undefined) continue
344
+ taken.add(name)
345
+ const synthetic: ProjectMeta = {
346
+ name,
347
+ dir,
348
+ packageJson: pkg,
349
+ configPath: null,
350
+ }
351
+ allMetas.push(synthetic)
352
+ metaByNode.set(nodeName, synthetic)
353
+ nodeByMeta.set(synthetic, node)
354
+ nodeNameOf.set(synthetic, nodeName)
355
+ }
356
+
357
+ const upstream = planNxUpstream(nodeMap, g.dependencies, namedInputs, metaByNode)
358
+
359
+ const taskNameFor: TaskNameFor = (project, target, configuration) => {
360
+ const t = nodeMap[project]?.data?.targets?.[target]
361
+ if (t === undefined) return null
362
+ const v = variants(target, t).find((x) => x.configuration === configuration)
363
+ if (v === undefined) return null
364
+ // The name another target holds is that target's (see the loop below).
365
+ const others = nodeMap[project]?.data?.targets ?? {}
366
+ return v.name !== target && Object.hasOwn(others, v.name) ? null : v.name
367
+ }
368
+
369
+ // Once per project, not per task and variant: `path.relative` was a
370
+ // fifth of the mapping at 1,000 projects (item 608).
371
+ const relOf = new Map<ProjectMeta, string>()
372
+ for (const meta of allMetas) {
373
+ if (nodeByMeta.get(meta)?.data?.targets) relOf.set(meta, normRel(relPosix(root, meta.dir)))
374
+ }
375
+ // Nx loads a task's `.env` files unless NX_LOAD_DOT_ENV_FILES is `false`;
376
+ // one listing per project dir decides which exist (~4 ms at 1,000).
377
+ const listing: DotenvListing | null =
378
+ process.env['NX_LOAD_DOT_ENV_FILES'] === 'false'
379
+ ? null
380
+ : await listDotenv(root, [...relOf.values()])
381
+
382
+ const mapped: Array<{ meta: ProjectMeta; tasks: GeneratedTask[] }> = []
383
+ let releasePublish = 0
384
+ // A configuration variant's task (`build:production`) and what it runs.
385
+ const configured = new WeakMap<GeneratedTask, string>()
386
+ // The target an atomizer split (cypress's `e2e`, named by each
387
+ // `e2e-ci--<spec>`'s `nonAtomizedTarget`), each configuration of it too.
388
+ const split = new Set<GeneratedTask>()
389
+ for (const meta of allMetas) {
390
+ const node = nodeByMeta.get(meta)
391
+ const targets = node?.data?.targets
392
+ if (!targets) continue
393
+ const tasks: GeneratedTask[] = []
394
+ const projectName = node?.name ?? meta.name
395
+ const projectRel = relOf.get(meta)!
396
+ const scripts = packageScripts(meta)
397
+ // Relative to the project dir, where vx runs the task.
398
+ const dotenvFor = (
399
+ l: DotenvListing,
400
+ targetName: string,
401
+ configuration: string | undefined,
402
+ ): string[] => {
403
+ const [owner, parent] = ownerTargetOf(targetName, targets, node?.data?.metadata?.targetGroups)
404
+ return existingDotenv(dotenvCandidates(projectRel, owner, configuration, parent), l).map(
405
+ (f) => relPosix(projectRel, f),
406
+ )
407
+ }
408
+ const atomized = new Set(Object.values(targets).map((t) => t.metadata?.nonAtomizedTarget))
409
+ // A target's name wins over a configuration task's (`vite`'s `build`
410
+ // is `vite:build`, which target `vite:build` names), as Nx resolves
411
+ // `a:vite:build` to the target: two keys of one name in the written
412
+ // object kept the last, and the real build was silently `vite --x`.
413
+ const taken = new Set(Object.keys(targets))
414
+ for (const [targetName, target] of Object.entries(targets)) {
415
+ // Nx adds `nx-release-publish` to every package for `nx release
416
+ // publish`, which skips a private package and a published version
417
+ // and rewrites `workspace:` ranges first: no one line is that, and a
418
+ // failing placeholder per package was the migration's loudest gap.
419
+ if (opts.migration === true && isReleasePublish(target.executor)) {
420
+ releasePublish++
421
+ continue
422
+ }
423
+ const clashes: string[] = []
424
+ let base: GeneratedTask | undefined
425
+ for (const v of variants(targetName, target)) {
426
+ if (v.name !== targetName) {
427
+ if (taken.has(v.name)) {
428
+ clashes.push(v.configuration!)
429
+ continue
430
+ }
431
+ taken.add(v.name)
432
+ }
433
+ const t = buildTask(
434
+ meta,
435
+ projectRel,
436
+ scripts,
437
+ projectName,
438
+ targetName,
439
+ target,
440
+ v,
441
+ nodeNameOf.get(meta)!,
442
+ upstream,
443
+ metaByNode,
444
+ nodeMap,
445
+ taskNameFor,
446
+ mapOpts,
447
+ listing === null ? null : dotenvFor(listing, targetName, v.configuration),
448
+ )
449
+ if (v.name !== targetName) configured.set(t, v.configuration!)
450
+ else base = t
451
+ if (atomized.has(targetName)) split.add(t)
452
+ tasks.push(t)
453
+ }
454
+ for (const c of clashes)
455
+ base?.todos.push(
456
+ `configuration ${JSON.stringify(c)} would be task "${targetName}:${c}", which another target names — not written; give it a task of its own name`,
457
+ )
458
+ }
459
+ mapped.push({ meta, tasks })
460
+ }
461
+ // The `nx-input:<name>` twins the `^` inputs above asked for, in every
462
+ // project a graph node owns — one with no targets too.
463
+ const byMeta = new Map(mapped.map((m) => [m.meta, m]))
464
+ for (const [nodeName, list] of upstream.inputTasks()) {
465
+ const meta = metaByNode.get(nodeName)!
466
+ const entry = byMeta.get(meta)
467
+ if (entry !== undefined) entry.tasks.push(...list)
468
+ else mapped.push({ meta, tasks: list })
469
+ }
470
+
471
+ // Nx gives `^name` no edges when no project runs the target; core refuses
472
+ // a `^name` no project declares as a typo, so such an edge is dropped
473
+ // before the shared-output rule reads which tasks have a `^` edge.
474
+ // Counted over the projects that will carry tasks: a target only the
475
+ // unattached root declares kept its `^` edge, and core refused the run
476
+ // (item 1051).
477
+ const emitted = new Set<string>()
478
+ const emittedIds = new Set<string>()
479
+ for (const { meta, tasks } of mapped) {
480
+ if (opts.attached !== undefined && !opts.attached.has(meta.name)) continue
481
+ for (const t of tasks) {
482
+ if (t.task === null) continue
483
+ emitted.add(t.name)
484
+ emittedIds.add(`${meta.name}#${t.name}`)
485
+ }
486
+ }
487
+ const nxDirect = nxDirectDeps(nodeMap, g.dependencies)
488
+ const nxEdges = nxDependencyTargets(nodeMap, nxDirect)
489
+ const nxClosureMemo = new Map<string, ReadonlySet<string>>()
490
+ const nxClosure = (from: string): ReadonlySet<string> => {
491
+ let r = nxClosureMemo.get(from)
492
+ if (r !== undefined) return r
493
+ const seen = new Set<string>()
494
+ const stack = [...(nxDirect.get(from) ?? [])]
495
+ while (stack.length > 0) {
496
+ const n = stack.pop()!
497
+ if (seen.has(n) || n === from) continue
498
+ seen.add(n)
499
+ stack.push(...(nxDirect.get(n) ?? []))
500
+ }
501
+ nxClosureMemo.set(from, (r = seen))
502
+ return r
503
+ }
504
+ // What vx's `^` already reaches: a manifest path, direct or through
505
+ // another project, orders `^x` and folds its key (item 931). Built once,
506
+ // on the first `^` edge.
507
+ let pkgGraph: ReturnType<typeof buildPackageGraph> | undefined
508
+ const packageGraph = () => (pkgGraph ??= buildPackageGraph([...metas]))
509
+ let reach: ((name: string) => ReadonlySet<string>) | undefined
510
+ const reached = (name: string): ReadonlySet<string> => {
511
+ if (reach === undefined) {
512
+ const graph = packageGraph()
513
+ const memo = new Map<string, ReadonlySet<string>>()
514
+ reach = (n) => {
515
+ let r = memo.get(n)
516
+ if (r === undefined) memo.set(n, (r = new Set(graph.transitiveDeps(n))))
517
+ return r
518
+ }
519
+ }
520
+ return reach(name)
521
+ }
522
+ const projects: GeneratedProject[] = []
523
+ for (const { meta, tasks } of mapped) {
524
+ const nodeName = nodeNameOf.get(meta)
525
+ for (const t of tasks) {
526
+ const c = configured.get(t)
527
+ if (nodeName !== undefined && c !== undefined)
528
+ passConfiguration(t.task, nodeName, c, nxEdges, metaByNode, emittedIds, taskNameFor)
529
+ if (nodeName !== undefined) {
530
+ narrowToNxGraph(
531
+ t.task,
532
+ nodeName,
533
+ meta.name,
534
+ nxClosure,
535
+ nxEdges,
536
+ metaByNode,
537
+ emittedIds,
538
+ (p) => packageGraph().directDeps(p),
539
+ )
540
+ followNxGraph(t.task, nodeName, meta.name, nxEdges, metaByNode, emittedIds, reached)
541
+ }
542
+ dropUnheldDeps(t, emitted, emittedIds)
543
+ }
544
+ projects.push({
545
+ name: meta.name,
546
+ dir: meta.dir,
547
+ importLines: [],
548
+ tasks: resolveSharedOutputs(tasks),
549
+ })
550
+ }
551
+
552
+ resolveAtomizedWorkspaceOutputs(root, projects, split)
553
+ // A migration leaves Nx behind, so `nx sync` is no advice there: what a
554
+ // generator keeps (the TypeScript one's tsconfig `references`) is the
555
+ // user's to keep. `nx()` runs with Nx installed.
556
+ const syncAdvice =
557
+ opts.migration === true
558
+ ? 'keep what they write (`@nx/js:typescript-sync`: the tsconfig `references`) up to date by hand'
559
+ : 'run `nx sync` when they are out of date'
560
+ const notes: string[] =
561
+ globalSync.length === 0
562
+ ? []
563
+ : [
564
+ `nx.json \`sync.globalGenerators\` (${globalSync.map((g) => JSON.stringify(g)).join(', ')}): ` +
565
+ `Nx runs them before a run, and vx does not — ${syncAdvice}`,
566
+ ]
567
+ if (releasePublish > 0)
568
+ notes.push(
569
+ `\`@nx/js:release-publish\` on ${releasePublish} project${releasePublish === 1 ? '' : 's'} ` +
570
+ '(`nx-release-publish`, Nx release’s publish step) is not written: publish with your ' +
571
+ 'package manager (`npm publish`, `pnpm publish -r`, `bun publish`)',
572
+ )
573
+ for (const [gens, n] of mapOpts.syncTasks)
574
+ notes.push(
575
+ `\`syncGenerators\` (${gens}) on ${n} task${n === 1 ? '' : 's'}: ` +
576
+ `Nx runs them before those targets, and vx does not — ${syncAdvice}`,
577
+ )
578
+ return { projects, notes }
579
+ }
580
+
581
+ /**
582
+ * The shared-workspace-output rule keeps the first task on a path cached.
583
+ * Cypress's atomizer gives `e2e` the whole `videos` dir and each
584
+ * `e2e-ci--<spec>` a subdir of it, so `e2e` first ran every spec's CI task
585
+ * uncached — the tasks atomizing exists to cache and distribute
586
+ * (nx-examples). The split target is tried last too, and the order that
587
+ * leaves more tasks cached wins; on a tie (jest's children share one path)
588
+ * the declared order stands.
589
+ */
590
+ function resolveAtomizedWorkspaceOutputs(
591
+ root: string,
592
+ projects: GeneratedProject[],
593
+ split: ReadonlySet<GeneratedTask>,
594
+ ): void {
595
+ const last = projects.map((p) => ({
596
+ name: p.name,
597
+ dir: p.dir,
598
+ tasks: [...p.tasks.filter((t) => !split.has(t)), ...p.tasks.filter((t) => split.has(t))],
599
+ }))
600
+ const cached = (ps: readonly { name: string; dir: string; tasks: GeneratedTask[] }[]): number => {
601
+ const copy = structuredClone(ps.map((p) => ({ name: p.name, dir: p.dir, tasks: p.tasks })))
602
+ resolveSharedWorkspaceOutputs(root, copy)
603
+ return copy.reduce(
604
+ (n, p) => n + p.tasks.filter((t) => t.task?.['cache'] !== undefined).length,
605
+ 0,
606
+ )
607
+ }
608
+ const better = split.size > 0 && cached(last) > cached(projects)
609
+ resolveSharedWorkspaceOutputs(root, better ? last : projects)
610
+ }
611
+
612
+ /**
613
+ * One task per configuration: the base task carries the default
614
+ * configuration's options (what `nx run p:t` runs), every other one is
615
+ * `<t>:<c>`. Nx merges a configuration over the options field by field.
616
+ */
617
+ interface Variant {
618
+ /** The vx task name. */
619
+ name: string
620
+ configuration: string | undefined
621
+ options: Record<string, unknown>
622
+ }
623
+
624
+ function variants(targetName: string, target: NxTarget): Variant[] {
625
+ const base = target.options ?? {}
626
+ const configs =
627
+ typeof target.configurations === 'object' && target.configurations !== null
628
+ ? target.configurations
629
+ : {}
630
+ const dflt =
631
+ typeof target.defaultConfiguration === 'string' && configs[target.defaultConfiguration]
632
+ ? target.defaultConfiguration
633
+ : undefined
634
+ const out: Variant[] = [
635
+ {
636
+ name: targetName,
637
+ configuration: dflt,
638
+ options: dflt === undefined ? base : { ...base, ...configs[dflt] },
639
+ },
640
+ ]
641
+ for (const [c, over] of Object.entries(configs)) {
642
+ if (c === dflt || typeof over !== 'object' || over === null) continue
643
+ out.push({ name: `${targetName}:${c}`, configuration: c, options: { ...base, ...over } })
644
+ }
645
+ return out
646
+ }
647
+
648
+ /** What the mapper reads from nx.json: the named inputs and the legacy cacheable list. */
649
+ interface NxJsonFacts {
650
+ readonly namedInputs: Record<string, unknown[]> | null
651
+ readonly cacheable: ReadonlySet<string>
652
+ /** `sync.globalGenerators`: Nx runs them before a run's tasks. */
653
+ readonly globalSync: readonly string[]
654
+ }
655
+
656
+ /**
657
+ * `nx.json` as Nx reads it: an `extends` base (a path or a package,
658
+ * resolved from the file's directory) merged shallowly under it, the base's
659
+ * own `extends` first. Only the raw file was read, so named inputs a base
660
+ * declared fell back to `{projectRoot}/**` with no word and a `sharedGlobals`
661
+ * edit re-ran nothing (item 1050). `files` lists every file read, which the
662
+ * graph snapshot's freshness stats. Null when `nx.json` is absent; a file
663
+ * that does not parse throws.
664
+ */
665
+ export async function readNxJson(
666
+ root: string,
667
+ ): Promise<{ json: Record<string, unknown>; files: string[] } | null> {
668
+ const first = path.join(root, 'nx.json')
669
+ if (!(await Bun.file(first).exists())) return null
670
+ const files: string[] = []
671
+ const layers: Record<string, unknown>[] = []
672
+ let at: string | null = first
673
+ while (at !== null && !files.includes(at)) {
674
+ files.push(at)
675
+ const parsed = (Bun.JSONC.parse(await Bun.file(at).text()) ?? {}) as Record<string, unknown>
676
+ layers.unshift(parsed)
677
+ const ext = parsed['extends']
678
+ at = typeof ext === 'string' ? Bun.resolveSync(ext, path.dirname(at)) : null
679
+ }
680
+ const json: Record<string, unknown> = {}
681
+ for (const layer of layers) Object.assign(json, layer)
682
+ delete json['extends']
683
+ return { json, files }
684
+ }
685
+
686
+ export async function readNxJsonFacts(root: string): Promise<NxJsonFacts> {
687
+ const none: NxJsonFacts = { namedInputs: null, cacheable: new Set(), globalSync: [] }
688
+ try {
689
+ const read = await readNxJson(root)
690
+ if (read === null) return none
691
+ const parsed = read.json as {
692
+ namedInputs?: unknown
693
+ tasksRunnerOptions?: { default?: { options?: { cacheableOperations?: unknown } } }
694
+ sync?: { globalGenerators?: unknown }
695
+ }
696
+ const named = parsed?.namedInputs
697
+ const ops = parsed?.tasksRunnerOptions?.default?.options?.cacheableOperations
698
+ return {
699
+ namedInputs:
700
+ typeof named === 'object' && named !== null ? (named as Record<string, unknown[]>) : null,
701
+ cacheable: new Set(
702
+ Array.isArray(ops) ? ops.filter((o): o is string => typeof o === 'string') : [],
703
+ ),
704
+ globalSync: Array.isArray(parsed?.sync?.globalGenerators)
705
+ ? parsed.sync.globalGenerators.filter((g): g is string => typeof g === 'string')
706
+ : [],
707
+ }
708
+ } catch {
709
+ // An unreadable nx.json (or base) just degrades named-input refs to TODOs.
710
+ return none
711
+ }
712
+ }
713
+
714
+ function dropUnheldDeps(
715
+ t: GeneratedTask,
716
+ emitted: ReadonlySet<string>,
717
+ emittedIds: ReadonlySet<string>,
718
+ ): void {
719
+ const task = t.task
720
+ const deps = task?.['dependsOn']
721
+ if (task === null || !Array.isArray(deps)) return
722
+ // A pattern (`^build-*`, `*` in core's dependency syntax) may match
723
+ // nothing; core takes that, as Nx does.
724
+ const kept = deps.filter((d) => {
725
+ if (typeof d !== 'string' || d.includes('*')) return true
726
+ if (d.startsWith('^')) return emitted.has(d.slice(1))
727
+ // `pkg#task` on a project no package holds (the root project with no
728
+ // vx.config) refused the whole run as "no such project".
729
+ if (!d.includes('#') || emittedIds.has(d)) return true
730
+ t.todos.push(
731
+ `dependsOn ${JSON.stringify(d)}: no vx project runs it — edge dropped, and the key misses it`,
732
+ )
733
+ return false
734
+ })
735
+ if (kept.length === deps.length) return
736
+ // A group keeps its (now empty) list: `dependsOn` is all a group is.
737
+ if (kept.length > 0 || task['exec'] === undefined) task['dependsOn'] = kept
738
+ else delete task['dependsOn']
739
+ }
740
+
741
+ const targetNamesMemo = new WeakMap<object, string[]>()
742
+ /** Every target name in the graph, once per graph: what an Nx target glob expands over. */
743
+ function allTargetNames(nodeMap: Readonly<Record<string, NxNode>>): string[] {
744
+ let names = targetNamesMemo.get(nodeMap)
745
+ if (names === undefined) {
746
+ const set = new Set<string>()
747
+ for (const n of Object.values(nodeMap))
748
+ for (const t of Object.keys(n?.data?.targets ?? {})) set.add(t)
749
+ names = [...set]
750
+ targetNamesMemo.set(nodeMap, names)
751
+ }
752
+ return names
753
+ }
754
+
755
+ function buildTask(
756
+ meta: ProjectMeta,
757
+ projectRel: string,
758
+ scripts: Record<string, unknown>,
759
+ projectName: string,
760
+ targetName: string,
761
+ target: NxTarget,
762
+ variant: Variant,
763
+ nodeName: string,
764
+ upstream: NxUpstream,
765
+ metaByNode: ReadonlyMap<string, ProjectMeta>,
766
+ nodeMap: Readonly<Record<string, NxNode>>,
767
+ taskNameFor: TaskNameFor,
768
+ opts: MapOpts,
769
+ dotenv: readonly string[] | null,
770
+ ): GeneratedTask {
771
+ const todos: string[] = []
772
+ const options = variant.options
773
+
774
+ const mapped = mapCommand(
775
+ targetName,
776
+ target,
777
+ options,
778
+ projectRel,
779
+ projectName,
780
+ variant.configuration,
781
+ scripts,
782
+ todos,
783
+ dotenv,
784
+ opts.pnp,
785
+ meta.packageJson,
786
+ opts.manifestField,
787
+ opts.executors,
788
+ )
789
+
790
+ const inputs = emptyNxInputs()
791
+ const at = {
792
+ rel: projectRel,
793
+ name: projectName,
794
+ outputs: nxProjectOutputs(nodeMap[nodeName]?.data?.targets, projectRel, projectName),
795
+ rootDeps: opts.rootDeps,
796
+ }
797
+ expandNxInputs(target.inputs ?? [], upstream.namedOf(nodeName), at, inputs, todos)
798
+ const cacheWanted =
799
+ target.cache === true || (target.cache === undefined && opts.cacheable.has(targetName))
800
+ // An output only matters to a task vx caches: Nx's default `build` /
801
+ // `public` note sat on every uncached `build` that declares none.
802
+ const outTodos = cacheWanted ? todos : []
803
+ const { outFiles, wsOutFiles } = mapNxOutputs(
804
+ target.outputs ??
805
+ nxDefaultOutputs(targetName, options, projectRel, outTodos, opts.tracked?.(projectRel).tops),
806
+ options,
807
+ projectRel,
808
+ projectName,
809
+ outTodos,
810
+ )
811
+ const deps = mapNxDeps(
812
+ target.dependsOn ?? [],
813
+ metaByNode,
814
+ (t) => Object.hasOwn(nodeMap[nodeName]?.data?.targets ?? {}, t),
815
+ taskNameFor,
816
+ (p, t) => Object.hasOwn(nodeMap[p]?.data?.targets ?? {}, t),
817
+ todos,
818
+ (patterns) =>
819
+ matchNxProjects(
820
+ patterns,
821
+ Object.entries(nodeMap).map(([name, n]) => ({
822
+ name,
823
+ tags: ((n?.data as { tags?: unknown } | undefined)?.tags as string[] | undefined) ?? [],
824
+ ...(typeof n?.data?.root === 'string' ? { root: n.data.root } : {}),
825
+ })),
826
+ ),
827
+ allTargetNames(nodeMap),
828
+ variant.name === targetName
829
+ ? undefined
830
+ : { node: nodeName, configuration: variant.configuration! },
831
+ options,
832
+ )
833
+
834
+ // Nx's rule, not a guess: a target is cached when it says `cache: true`
835
+ // (Nx ≥ 17 writes it into the graph from `cacheableOperations` too —
836
+ // refine's `build` carries it, its `dev` does not) or its name is in the
837
+ // legacy `cacheableOperations` list. Until item 591 a target with
838
+ // outputs and no `cache` was cached anyway, so refine's 204 persistent
839
+ // `dev` targets (tsup --watch, outputs `dist`) were cached and then
840
+ // made uncached only by the shared-output rule (2026-09-22).
841
+ const readyWhen = mapped?.readyWhen
842
+ const persistent = readyWhen !== undefined || persistentTarget(target)
843
+ // vx cleans outputs before a run and Nx never does. With git's file list
844
+ // known, a wildcard-first output (`**/*.d.ts`) stays cached: each
845
+ // committed file it reaches is taken back with `!` after mapping
846
+ // (`spareTrackedOutputs`), and what the clean removes is what the task
847
+ // writes (owner, 2026-10-03). Without it, the sources cannot be told apart.
848
+ const wild =
849
+ opts.tracked === undefined
850
+ ? (wildcardOutput(outFiles) ?? wildcardOutput(wsOutFiles))
851
+ : undefined
852
+ if (wild !== undefined && cacheWanted && !persistent) todos.push(wildcardTodo(wild))
853
+ // The manifest or config an output covers is taken back, so core loads it.
854
+ if (wild === undefined) outFiles.push(...ownFileTakeBacks(outFiles, opts.ownConfig?.(projectRel)))
855
+ const cacheEnabled = !persistent && cacheWanted && wild === undefined
856
+ if (persistent && cacheWanted) {
857
+ todos.push('Nx caches this target, and vx never caches a persistent task — uncached here')
858
+ }
859
+ // Both were dropped in silence: a target Nx runs alone ran beside others.
860
+ if (target.parallelism === false) {
861
+ todos.push(
862
+ '`parallelism: false`: Nx runs this target alone, and vx has no per-task exclusivity — ' +
863
+ 'run it with `--concurrency 1` where it must not share the machine',
864
+ )
865
+ }
866
+ // One note, not a todo per task: Nx's TypeScript plugin gives every
867
+ // typecheck target `@nx/js:typescript-sync` (83 on typebot).
868
+ if (Array.isArray(target.syncGenerators) && target.syncGenerators.length > 0) {
869
+ const gens = target.syncGenerators.map((g) => JSON.stringify(g)).join(', ')
870
+ opts.syncTasks.set(gens, (opts.syncTasks.get(gens) ?? 0) + 1)
871
+ }
872
+ if (cacheEnabled && mapped !== null) {
873
+ // No `inputs` is Nx's `default` and `^default`: the project's
874
+ // `default` named input (its whole tree unless declared) and each
875
+ // dependency's. No gap to report (487 lines per run on refine,
876
+ // 2026-09-22).
877
+ if (target.inputs === undefined)
878
+ expandNxInputs(['default', '^default'], upstream.namedOf(nodeName), at, inputs, todos)
879
+ if (inputs.files.length === 0 && target.inputs === undefined) inputs.files.push('**/*')
880
+ // Before the env block: a dependency's `{ env }` passes through too.
881
+ for (const edge of upstream.resolve(nodeName, inputs, todos))
882
+ if (!deps.includes(edge)) deps.push(edge)
883
+ }
884
+
885
+ if (mapped === null) {
886
+ // nx:noop → vx group task: dependsOn only, no exec, no cache
887
+ // (vx forbids cache on groups). A noop with nothing to chain has
888
+ // no vx representation — skipped with a report line.
889
+ if (deps.length === 0) {
890
+ todos.push('nx:noop target with no dependsOn — nothing to represent; skipped')
891
+ return { name: variant.name, task: null, todos }
892
+ }
893
+ return { name: variant.name, task: { dependsOn: deps }, todos }
894
+ }
895
+ if (opts.migration === true) {
896
+ const call = nxSelfCall(mapped.command)
897
+ if (call !== undefined) todos.push(call)
898
+ }
899
+ const exec: Record<string, unknown> = { command: mapped.command }
900
+ const env: Record<string, unknown> = {}
901
+ if (inputs.envNames.length > 0) env.passThrough = inputs.envNames
902
+ // Nx hands every task its target (`getNxEnvVariablesForTask`), and
903
+ // `nx exec -- <cmd>`, a package script's way to run under Nx, reads it:
904
+ // unset, it booted Nx's own task runner, which ran the target and its
905
+ // dependencies again. `LERNA_PACKAGE_NAME` is the project too: Lerna
906
+ // runs on Nx's runner and documents it to scripts. A run-commands `env`
907
+ // still wins, as in Nx.
908
+ env.define = {
909
+ NX_TASK_TARGET_PROJECT: projectName,
910
+ NX_TASK_TARGET_TARGET: targetName,
911
+ LERNA_PACKAGE_NAME: projectName,
912
+ ...(variant.configuration === undefined
913
+ ? {}
914
+ : { NX_TASK_TARGET_CONFIGURATION: variant.configuration }),
915
+ ...mapped.env,
916
+ }
917
+ exec.env = env
918
+ if (mapped.timeout !== undefined) exec.timeout = mapped.timeout
919
+ if (readyWhen !== undefined) {
920
+ exec.persistent = { readyWhen }
921
+ } else if (persistent) {
922
+ // Nx starts a continuous task's dependents once it has started
923
+ // (tasks-schedule.js `canBeScheduled`, Nx 23): ready on spawn, as here.
924
+ exec.persistent = {}
925
+ }
926
+ const task: Record<string, unknown> = { exec }
927
+ if (deps.length > 0) task.dependsOn = deps
928
+ if (cacheEnabled) {
929
+ const cacheInputs: Record<string, unknown> = { files: inputs.files }
930
+ if (inputs.wsFiles.length > 0) cacheInputs.workspaceFiles = inputs.wsFiles
931
+ if (inputs.envNames.length > 0) cacheInputs.env = inputs.envNames
932
+ // The `.env` files the task loads are inputs, and gitignored ones
933
+ // (`.env.local`) are invisible to a glob: their bytes, read per run.
934
+ // Their paths are project-relative: `runtime`, in the project dir.
935
+ if (mapped.envInputs.length > 0) cacheInputs.runtime = [envProbe(mapped.envInputs)]
936
+ if (inputs.runtimeCmds.length > 0) cacheInputs.workspaceRuntime = inputs.runtimeCmds
937
+ const outputs: Record<string, unknown> = { files: outFiles }
938
+ if (wsOutFiles.length > 0) outputs.workspaceFiles = wsOutFiles
939
+ task.cache = { inputs: cacheInputs, outputs }
940
+ }
941
+
942
+ return { name: variant.name, todos, task }
943
+ }
944
+
945
+ /**
946
+ * The `$npm_*` variables a script body reads, which Nx's `<pm> run <name>`
947
+ * sets and an inlined body does not: `echo $npm_package_version` printed
948
+ * nothing under `nx()`. The name and version are the manifest's (a bump
949
+ * re-maps: the mapping keys on every manifest), the event the script's
950
+ * own name unless hooks are folded beside it (each has its own); any
951
+ * other is a todo, as core's `vx init` does it (D-34).
952
+ */
953
+ function npmScriptEnv(
954
+ command: string,
955
+ script: string,
956
+ folded: boolean,
957
+ manifest: { readonly name?: unknown; readonly version?: unknown },
958
+ manifestField: MapNxOptions['manifestField'],
959
+ todos: string[],
960
+ ): Record<string, unknown> {
961
+ const env: Record<string, unknown> = {}
962
+ const unset = new Set<string>()
963
+ for (const [, v] of command.matchAll(/\$\{?(npm_[A-Za-z0-9_]+)/g)) {
964
+ const value =
965
+ v === 'npm_package_name'
966
+ ? manifest.name
967
+ : v === 'npm_package_version'
968
+ ? manifest.version
969
+ : v === 'npm_lifecycle_event' && !folded
970
+ ? script
971
+ : undefined
972
+ if (typeof value !== 'string') unset.add(v!)
973
+ else if (manifestField !== undefined && v !== 'npm_lifecycle_event')
974
+ env[v!] = manifestField(v === 'npm_package_name' ? 'name' : 'version')
975
+ else env[v!] = value
976
+ }
977
+ for (const v of unset)
978
+ todos.push(`nx:run-script: \`$${v}\` is set by the package manager's \`run\`, not here — unset`)
979
+ return env
980
+ }
981
+
982
+ /** What a target runs as; null for `nx:noop`, which is a group task. */
983
+ interface MappedCommand {
984
+ readonly command: string
985
+ readonly env: Readonly<Record<string, unknown>>
986
+ readonly readyWhen: string | undefined
987
+ /** The `.env` files the command loads, relative to the project dir: key inputs. */
988
+ readonly envInputs: readonly string[]
989
+ /** A translated executor's `exec.timeout`. */
990
+ readonly timeout?: number
991
+ }
992
+
993
+ /**
994
+ * `dotenv` is the task's existing `.env` files relative to the project dir,
995
+ * or null when Nx would load none (NX_LOAD_DOT_ENV_FILES=false). A shell
996
+ * line that has any, or a run-commands `envFile`, runs under `nx-env`; an
997
+ * executor line hands them to `nx-exec`.
998
+ */
999
+ function mapCommand(
1000
+ targetName: string,
1001
+ target: NxTarget,
1002
+ options: Record<string, unknown>,
1003
+ projectRel: string,
1004
+ projectName: string,
1005
+ configuration: string | undefined,
1006
+ scripts: Record<string, unknown>,
1007
+ todos: string[],
1008
+ dotenv: readonly string[] | null,
1009
+ pnp: boolean,
1010
+ manifest: { readonly name?: unknown; readonly version?: unknown },
1011
+ manifestField: MapNxOptions['manifestField'],
1012
+ executors: NxExecutors | undefined,
1013
+ ): MappedCommand | null {
1014
+ const executor = target.executor
1015
+ if (executor === 'nx:noop') {
1016
+ // No command by definition — the vx equivalent is a group task
1017
+ // (handled by the caller; nothing to map here).
1018
+ return null
1019
+ }
1020
+ const files = dotenv ?? []
1021
+ const line = (command: string): MappedCommand => ({
1022
+ command,
1023
+ env: {},
1024
+ readyWhen: undefined,
1025
+ envInputs: [],
1026
+ })
1027
+ const shell = (
1028
+ command: string,
1029
+ envFile: string | undefined,
1030
+ rest: Pick<MappedCommand, 'env' | 'readyWhen'> = { env: {}, readyWhen: undefined },
1031
+ readyAll: readonly string[] = [],
1032
+ ): MappedCommand => {
1033
+ if (files.length === 0 && envFile === undefined && readyAll.length === 0)
1034
+ return { ...rest, command, envInputs: [] }
1035
+ const flags = files.flatMap((f) => ['--dotenv', shellQuote(f)])
1036
+ if (envFile !== undefined) flags.push('--envFile', shellQuote(envFile))
1037
+ for (const s of readyAll) flags.push('--ready-when', shellQuote(s))
1038
+ return {
1039
+ ...rest,
1040
+ command: `nx-env ${flags.join(' ')} -- ${shellQuote(command)}`,
1041
+ envInputs: envFile === undefined ? files : [...files, envFile],
1042
+ }
1043
+ }
1044
+ // run-commands, and a plain `command` (its shorthand, over the same
1045
+ // options) — see nx-command.ts.
1046
+ const plain =
1047
+ executor === undefined && typeof target.command === 'string' && target.command.length > 0
1048
+ if (executor === 'nx:run-commands' || plain) {
1049
+ const rc = mapRunCommands(
1050
+ plain ? { ...options, command: target.command } : options,
1051
+ { projectRel, projectName },
1052
+ todos,
1053
+ )
1054
+ if (rc === null) return line(PLACEHOLDER)
1055
+ // Nx loads `envFile` from its own working directory, the workspace
1056
+ // root, and not at all under NX_LOAD_DOT_ENV_FILES=false.
1057
+ const envFile =
1058
+ rc.envFile === undefined || dotenv === null
1059
+ ? undefined
1060
+ : path.posix.isAbsolute(rc.envFile)
1061
+ ? rc.envFile
1062
+ : relPosix(projectRel, path.posix.normalize(rc.envFile))
1063
+ return shell(rc.command, envFile, { env: rc.env, readyWhen: rc.readyWhen }, rc.readyAll)
1064
+ }
1065
+ if (executor === 'nx:run-script') {
1066
+ const script = typeof options.script === 'string' ? options.script : targetName
1067
+ // package.json is a boundary: a script value is whatever the file holds.
1068
+ // This read used to be typed `Record<string, string>`, so `body.length`
1069
+ // type-checked on a value that need not be a string at all (item 446).
1070
+ const raw = scripts[script]
1071
+ const body = typeof raw === 'string' ? raw : undefined
1072
+ // An empty script is a target Nx lists and `pnpm run` runs as nothing
1073
+ // (novu's `test:watch: ""`, 2026-09-11); as a command it is a config
1074
+ // that refuses to load, so it is the placeholder with its todo.
1075
+ if (body !== undefined && body.length > 0) {
1076
+ const command = scriptCommand(script, body, scripts, pnp)
1077
+ // `yarn run <name>` sets its own.
1078
+ const env =
1079
+ command === `yarn run ${script}`
1080
+ ? {}
1081
+ : npmScriptEnv(command, script, command !== body, manifest, manifestField, todos)
1082
+ return shell(command, undefined, { env, readyWhen: undefined })
1083
+ }
1084
+ todos.push(
1085
+ body === undefined
1086
+ ? `nx:run-script: package.json has no ${JSON.stringify(script)} script`
1087
+ : `nx:run-script: package.json script ${JSON.stringify(script)} is empty`,
1088
+ )
1089
+ return line(PLACEHOLDER)
1090
+ }
1091
+ if (executor === undefined) {
1092
+ todos.push(`target has neither an executor nor a command — options: ${JSON.stringify(options)}`)
1093
+ return line(PLACEHOLDER)
1094
+ }
1095
+ const translate = executors?.[executor]
1096
+ if (translate !== undefined) {
1097
+ const at = `nx({ executors }): ${JSON.stringify(executor)} for ${projectName}:${targetName}${configuration === undefined ? '' : `:${configuration}`}`
1098
+ const t = translate({
1099
+ executor,
1100
+ project: projectName,
1101
+ projectRoot: projectRel,
1102
+ target: targetName,
1103
+ configuration,
1104
+ options,
1105
+ }) as unknown
1106
+ if (t !== undefined) {
1107
+ const mapped = translation(t, at)
1108
+ // `.env` files load as they do for a run-commands line: Nx loads
1109
+ // them for every executor.
1110
+ return {
1111
+ ...shell(mapped.command, undefined, { env: mapped.env, readyWhen: undefined }),
1112
+ ...(mapped.timeout === undefined ? {} : { timeout: mapped.timeout }),
1113
+ }
1114
+ }
1115
+ }
1116
+ // Every other executor runs as itself, one process per task, through
1117
+ // this package's `nx-exec` bin: the executor and its options are on the
1118
+ // command line, so the key sees them and the line pastes into a shell.
1119
+ argsTodo(options, todos)
1120
+ return {
1121
+ ...line(nxExecCommand(executor, projectName, targetName, configuration, options, files)),
1122
+ envInputs: files,
1123
+ }
1124
+ }
1125
+
1126
+ /** A translator's return, held at the boundary: it is the workspace's code. */
1127
+ function translation(
1128
+ t: unknown,
1129
+ at: string,
1130
+ ): { command: string; env: Record<string, unknown>; timeout: number | undefined } {
1131
+ if (!isRecord(t)) throw new UserError(`${at}: must return { command } or undefined`)
1132
+ for (const key of Object.keys(t))
1133
+ if (key !== 'command' && key !== 'timeout' && key !== 'env')
1134
+ throw new UserError(`${at}: unknown field "${key}" (allowed: command, env, timeout)`)
1135
+ if (typeof t.command !== 'string' || t.command.trim() === '')
1136
+ throw new UserError(`${at}: command must be a non-empty string`)
1137
+ const timeout = t.timeout
1138
+ if (
1139
+ timeout !== undefined &&
1140
+ !(typeof timeout === 'number' && Number.isInteger(timeout) && timeout > 0)
1141
+ )
1142
+ throw new UserError(`${at}: timeout must be a positive integer (ms)`)
1143
+ const env = t.env ?? {}
1144
+ if (!isRecord(env) || Object.values(env).some((v) => typeof v !== 'string'))
1145
+ throw new UserError(`${at}: env must map names to strings`)
1146
+ return { command: t.command, env, timeout }
1147
+ }
1148
+
1149
+ /**
1150
+ * A written command that still runs Nx (`nx run app:build`, `npx nx test`,
1151
+ * a package script's `nx exec -- tsc`) works only while Nx is installed:
1152
+ * the migration's TODO for it. Undefined for a line that does not.
1153
+ */
1154
+ function nxSelfCall(command: string): string | undefined {
1155
+ const m = /(?:^|[\s;&|('"])(?:(?:npx|bunx|pnpm(?: exec)?|yarn)\s+)?nx\s+([a-z][\w:-]*)/.exec(
1156
+ command,
1157
+ )
1158
+ if (m === null) return undefined
1159
+ return m[1] === 'exec'
1160
+ ? 'the command runs under `nx exec --`, which needs Nx installed — drop `nx exec --` and keep the command after it'
1161
+ : `the command runs \`nx ${m[1]}\`, which needs Nx installed — name that task in dependsOn, or run its command here`
1162
+ }
1163
+
1164
+ const isReleasePublish = (executor: string | undefined): boolean =>
1165
+ executor === '@nx/js:release-publish' || executor === '@nrwl/js:release-publish'
1166
+
1167
+ function argsTodo(options: Record<string, unknown>, todos: string[]): void {
1168
+ if (/\{args\.[^}]*\}/.test(JSON.stringify(options))) {
1169
+ todos.push(
1170
+ '`{args.*}` in the options: params forwarding is not supported — put the value in the option',
1171
+ )
1172
+ }
1173
+ }
1174
+
1175
+ /**
1176
+ * Prints each file's name and bytes, for `cache.inputs.runtime`: the name
1177
+ * keeps a line moved from one file to the next (a different precedence) a
1178
+ * different key.
1179
+ */
1180
+ function envProbe(files: readonly string[]): string {
1181
+ return `for f in ${files.map(shellQuote).join(' ')}; do echo "$f"; cat -- "$f" 2>/dev/null; echo; done`
1182
+ }
1183
+
1184
+ /** The `nx-exec` line for one target, shell-quoted; `--options` only when there are any. */
1185
+ export function nxExecCommand(
1186
+ executor: string,
1187
+ project: string,
1188
+ target: string,
1189
+ configuration: string | undefined,
1190
+ options: Record<string, unknown>,
1191
+ dotenv: readonly string[] = [],
1192
+ ): string {
1193
+ const parts = ['nx-exec', executor, '--project', project, '--target', target]
1194
+ if (configuration !== undefined) parts.push('--configuration', configuration)
1195
+ // JSON's own escapes keep a newline out of the line.
1196
+ if (Object.keys(options).length > 0) parts.push('--options', JSON.stringify(options))
1197
+ for (const f of dotenv) parts.push('--dotenv', f)
1198
+ return parts.map(shellQuote).join(' ')
1199
+ }
1200
+
1201
+ /**
1202
+ * The executors whose LIFETIME is known: a server never exits, a build
1203
+ * does. Any executor runs through `nx-exec`; this table only decides
1204
+ * `persistent`, and only for a target whose graph says no `continuous`.
1205
+ */
1206
+ const KNOWN_EXECUTORS: Record<string, { persistent: boolean }> = {
1207
+ '@nx/vite:build': { persistent: false },
1208
+ '@nx/vite:dev-server': { persistent: true },
1209
+ '@nx/vite:preview-server': { persistent: true },
1210
+ '@nx/vite:test': { persistent: false },
1211
+ '@nx/vitest:test': { persistent: false },
1212
+ '@nx/jest:jest': { persistent: false },
1213
+ '@nx/eslint:lint': { persistent: false },
1214
+ '@nx/js:tsc': { persistent: false },
1215
+ '@nx/js:node': { persistent: true },
1216
+ '@nx/js:verdaccio': { persistent: true },
1217
+ '@nx/webpack:webpack': { persistent: false },
1218
+ '@nx/webpack:dev-server': { persistent: true },
1219
+ '@nx/web:file-server': { persistent: true },
1220
+ '@nx/esbuild:esbuild': { persistent: false },
1221
+ '@nx/rollup:rollup': { persistent: false },
1222
+ '@nx/next:build': { persistent: false },
1223
+ '@nx/next:server': { persistent: true },
1224
+ '@nx/storybook:storybook': { persistent: true },
1225
+ '@nx/storybook:build': { persistent: false },
1226
+ '@nx/playwright:playwright': { persistent: false },
1227
+ '@nx/cypress:cypress': { persistent: false },
1228
+ '@angular-devkit/build-angular:dev-server': { persistent: true },
1229
+ }
1230
+
1231
+ /**
1232
+ * Does this target run a server that never exits? Nx's own word first: a
1233
+ * target that says `continuous` (Nx ≥ 21 infers it for every serve target)
1234
+ * is one, and one that says `continuous: false` is not. A graph from an
1235
+ * older Nx says nothing, so a known server executor is one whatever the
1236
+ * target is called. The target NAME is never the signal: a cached
1237
+ * `nx:run-commands` target named `dev` ran uncached every time while the
1238
+ * same target named `gen` cached (nx#32610).
1239
+ */
1240
+ function persistentTarget(target: NxTarget): boolean {
1241
+ if (typeof target.continuous === 'boolean') return target.continuous
1242
+ const known = target.executor === undefined ? undefined : KNOWN_EXECUTORS[target.executor]
1243
+ return known?.persistent ?? false
1244
+ }
1245
+
1246
+ /** Each project node's direct dependencies on the Nx graph, projects only. */
1247
+ function nxDirectDeps(
1248
+ nodeMap: Readonly<Record<string, NxNode>>,
1249
+ dependencies: unknown,
1250
+ ): Map<string, string[]> {
1251
+ const direct = new Map<string, string[]>()
1252
+ if (typeof dependencies === 'object' && dependencies !== null) {
1253
+ for (const [source, edges] of Object.entries(dependencies as Record<string, NxEdge[]>)) {
1254
+ if (!Array.isArray(edges)) continue
1255
+ const to = edges
1256
+ .map((e) => e?.target)
1257
+ // An `npm:` node has no root: a package, not a project.
1258
+ .filter(
1259
+ (t): t is string => typeof t === 'string' && typeof nodeMap[t]?.data?.root === 'string',
1260
+ )
1261
+ direct.set(source, [...new Set(to)])
1262
+ }
1263
+ }
1264
+ return direct
1265
+ }
1266
+
1267
+ /**
1268
+ * The projects Nx links a `^target` edge of `from` to
1269
+ * (`processTasksForDependencies`): each dependency on the NX graph that
1270
+ * has the target, and through one that lacks it, that one's
1271
+ * dependencies. vx's `^target` follows package.json alone, so an edge Nx
1272
+ * draws from `implicitDependencies` or a tsconfig path (nx-examples'
1273
+ * e2e projects → their apps) ordered nothing and folded nothing: an
1274
+ * app's source edit left its e2e `typecheck` a hit. It was reported
1275
+ * "not representable"; it is an explicit `pkg#target` edge.
1276
+ */
1277
+ function nxDependencyTargets(
1278
+ nodeMap: Readonly<Record<string, NxNode>>,
1279
+ direct: ReadonlyMap<string, readonly string[]>,
1280
+ ): (from: string, target: string) => readonly string[] {
1281
+ const memo = new Map<string, readonly string[]>()
1282
+ return (from, target) => {
1283
+ const key = `${from}\0${target}`
1284
+ const known = memo.get(key)
1285
+ if (known !== undefined) return known
1286
+ const out: string[] = []
1287
+ const seen = new Set<string>([from])
1288
+ const stack = [...(direct.get(from) ?? [])].reverse()
1289
+ while (stack.length > 0) {
1290
+ const n = stack.pop()!
1291
+ if (seen.has(n)) continue
1292
+ seen.add(n)
1293
+ if (Object.hasOwn(nodeMap[n]?.data?.targets ?? {}, target)) out.push(n)
1294
+ else stack.push(...[...(direct.get(n) ?? [])].reverse())
1295
+ }
1296
+ memo.set(key, out)
1297
+ return out
1298
+ }
1299
+ }
1300
+
1301
+ /**
1302
+ * Nx hands a `^name` edge the configuration the run asked for, and each
1303
+ * dependency runs it where it declares it, else its default
1304
+ * (`resolveConfiguration`). A `^name` beside a configuration task ran
1305
+ * every dependency's default (analog: 17 `development` / `production`
1306
+ * builds), and both beside each other would race on one `dist`. Where any
1307
+ * dependency Nx links declares the configuration, the `^name` becomes an
1308
+ * explicit edge per dependency: its configured task, else its base one.
1309
+ */
1310
+ function passConfiguration(
1311
+ task: Record<string, unknown> | null,
1312
+ nodeName: string,
1313
+ configuration: string,
1314
+ nxEdges: (from: string, target: string) => readonly string[],
1315
+ metaByNode: ReadonlyMap<string, ProjectMeta>,
1316
+ emittedIds: ReadonlySet<string>,
1317
+ taskNameFor: TaskNameFor,
1318
+ ): void {
1319
+ const deps = task?.['dependsOn']
1320
+ if (task === null || !Array.isArray(deps)) return
1321
+ const out: unknown[] = []
1322
+ for (const d of deps) {
1323
+ if (typeof d !== 'string' || !d.startsWith('^') || d.includes('*')) {
1324
+ out.push(d)
1325
+ continue
1326
+ }
1327
+ const name = d.slice(1)
1328
+ const nodes = nxEdges(nodeName, name)
1329
+ if (!nodes.some((n) => taskNameFor(n, name, configuration) !== null)) {
1330
+ out.push(d)
1331
+ continue
1332
+ }
1333
+ for (const n of nodes) {
1334
+ const m = metaByNode.get(n)
1335
+ if (m === undefined) continue
1336
+ const id = `${m.name}#${taskNameFor(n, name, configuration) ?? name}`
1337
+ if (emittedIds.has(id) && !out.includes(id)) out.push(id)
1338
+ }
1339
+ }
1340
+ task['dependsOn'] = out
1341
+ }
1342
+
1343
+ /**
1344
+ * A `^name` whose manifest reaches a project Nx's graph does not (an
1345
+ * `implicitDependencies: ["!a"]` that breaks a manifest cycle) becomes the
1346
+ * explicit edges Nx draws for it: vx's `^` follows the manifest, and the
1347
+ * edge Nx dropped came back as a task cycle core refused.
1348
+ */
1349
+ function narrowToNxGraph(
1350
+ task: Record<string, unknown> | null,
1351
+ nodeName: string,
1352
+ pkgName: string,
1353
+ nxClosure: (node: string) => ReadonlySet<string>,
1354
+ nxEdges: (from: string, target: string) => readonly string[],
1355
+ metaByNode: ReadonlyMap<string, ProjectMeta>,
1356
+ emittedIds: ReadonlySet<string>,
1357
+ directDeps: (pkg: string) => readonly string[],
1358
+ ): void {
1359
+ const deps = task?.['dependsOn']
1360
+ if (
1361
+ task === null ||
1362
+ !Array.isArray(deps) ||
1363
+ !deps.some((d) => typeof d === 'string' && d.startsWith('^'))
1364
+ )
1365
+ return
1366
+ const nx = new Set<string>()
1367
+ for (const n of nxClosure(nodeName)) {
1368
+ const m = metaByNode.get(n)
1369
+ if (m !== undefined) nx.add(m.name)
1370
+ }
1371
+ if (directDeps(pkgName).every((p) => nx.has(p))) return
1372
+ const out: unknown[] = []
1373
+ for (const d of deps) {
1374
+ if (typeof d !== 'string' || !d.startsWith('^') || d.includes('*')) {
1375
+ out.push(d)
1376
+ continue
1377
+ }
1378
+ const name = d.slice(1)
1379
+ for (const n of nxEdges(nodeName, name)) {
1380
+ const m = metaByNode.get(n)
1381
+ if (m === undefined) continue
1382
+ const id = `${m.name}#${name}`
1383
+ if (emittedIds.has(id) && !out.includes(id)) out.push(id)
1384
+ }
1385
+ }
1386
+ // A group keeps its list, as `dropUnheldDeps` keeps one.
1387
+ if (out.length > 0 || task['exec'] === undefined) task['dependsOn'] = out
1388
+ else delete task['dependsOn']
1389
+ }
1390
+
1391
+ /** Each `^name` of `task` gains the explicit edges Nx's graph draws for it and vx's `^` does not. */
1392
+ function followNxGraph(
1393
+ task: Record<string, unknown> | null,
1394
+ nodeName: string,
1395
+ pkgName: string,
1396
+ nxEdges: (from: string, target: string) => readonly string[],
1397
+ metaByNode: ReadonlyMap<string, ProjectMeta>,
1398
+ emittedIds: ReadonlySet<string>,
1399
+ reached: (name: string) => ReadonlySet<string>,
1400
+ ): void {
1401
+ const deps = task?.['dependsOn']
1402
+ if (!Array.isArray(deps)) return
1403
+ for (const d of [...deps]) {
1404
+ if (typeof d !== 'string' || !d.startsWith('^')) continue
1405
+ const name = d.slice(1)
1406
+ for (const n of nxEdges(nodeName, name)) {
1407
+ const m = metaByNode.get(n)
1408
+ if (m === undefined || reached(pkgName).has(m.name)) continue
1409
+ const id = `${m.name}#${name}`
1410
+ if (emittedIds.has(id) && !deps.includes(id)) deps.push(id)
1411
+ }
1412
+ }
1413
+ }
1414
+
1415
+ /** Nx's cache-size grammar (`10GB`, `1.5 GB`, bare bytes) as core's size; `0` is no cap. */
1416
+ export function nxSizeText(raw: unknown): string | undefined {
1417
+ if (typeof raw !== 'string' && typeof raw !== 'number') return undefined
1418
+ const text = String(raw).trim()
1419
+ const m = /^(\d+\.?\d*|\.\d+)\s?([KMG]?B)?$/.exec(text)
1420
+ if (m === null) return text
1421
+ const units = ['B', 'KB', 'MB', 'GB']
1422
+ let n = Math.floor(Number(m[1]) * 1024 ** units.indexOf(m[2] ?? 'B'))
1423
+ if (n === 0) return undefined
1424
+ let u = 0
1425
+ while (u < units.length - 1 && n % 1024 === 0) {
1426
+ n /= 1024
1427
+ u++
1428
+ }
1429
+ return `${n}${units[u]}`
1430
+ }