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