@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.
- package/LICENSE +21 -0
- package/README.md +306 -0
- package/index.ts +4 -0
- package/package.json +44 -6
- package/src/adoption-plugin.ts +125 -0
- package/src/bin.ts +18 -0
- package/src/dotenv-probe.ts +86 -0
- package/src/glob-grammar.ts +63 -0
- package/src/index.ts +15 -0
- package/src/mapping-cache.ts +101 -0
- package/src/migrate-nx.ts +154 -0
- package/src/migrate-turbo.ts +400 -0
- package/src/migrate.ts +165 -0
- package/src/nx/export-graph.ts +29 -0
- package/src/nx/index.ts +638 -0
- package/src/nx/nx-deps.ts +272 -0
- package/src/nx/nx-dotenv.ts +106 -0
- package/src/nx/nx-inputs.ts +210 -0
- package/src/nx/nx-map.ts +1430 -0
- package/src/nx/nx-outputs.ts +231 -0
- package/src/nx/nx-upstream.ts +356 -0
- package/src/nx-cache/index.ts +236 -0
- package/src/nx-command.ts +458 -0
- package/src/nx-dotenv.cjs +41 -0
- package/src/nx-env.cjs +152 -0
- package/src/nx-exec.cjs +294 -0
- package/src/paths.ts +36 -0
- package/src/plugin-gaps.ts +48 -0
- package/src/remote-deadline.ts +11 -0
- package/src/remote-retry.ts +52 -0
- package/src/remote-token.ts +18 -0
- package/src/script-command.ts +69 -0
- package/src/shared-outputs.ts +373 -0
- package/src/tracked-outputs.ts +248 -0
- package/src/turbo/index.ts +291 -0
- package/src/turbo/turbo-map.ts +1976 -0
- package/src/turbo-cache/index.ts +661 -0
- package/src/workspace-notes.ts +54 -0
package/src/nx/nx-map.ts
ADDED
|
@@ -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
|
+
}
|