@vzn/vx-migrate 0.0.635 → 0.0.637

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1261 @@
1
+ // package.json scripts → vx tasks: the source for a workspace that comes
2
+ // from nowhere (no turbo.json, no nx). `vx init` is this mapper.
3
+ //
4
+ // Scripts carry a command and nothing else, so the mapping is honest about
5
+ // what it cannot know: every task gets `exec.command` verbatim; `build`
6
+ // gets the conventional `dependsOn: ['^build']` and `test` / `typecheck`
7
+ // wait for `build` when the package has one (`lint` does not); a dev-server
8
+ // shaped script becomes persistent. Caching is opt-in and needs declared
9
+ // inputs AND outputs, which a script cannot tell us — so NO task gets a
10
+ // cache block; `build` carries a TODO showing the block to add. Until
11
+ // 2026-09-04 `build` was emitted with whole-project inputs and EMPTY
12
+ // outputs "to fill in": that is not an uncached task but a no-output one —
13
+ // it hits on unchanged inputs and skips the build with nothing to restore,
14
+ // so a deleted `dist` stayed deleted under a green `up-to-date` run
15
+ // (reproduced on the init walkthrough). A block that guessed `dist/**`
16
+ // would restore the wrong tree for every package that writes somewhere
17
+ // else, and a wrong restore is the worst failure vx has.
18
+ //
19
+ // Two npm conventions are mapped rather than copied, because copying them
20
+ // loses behaviour: `pre<x>` / `post<x>` hooks, which npm runs around `x`
21
+ // without being named, are folded into `x`'s command in that order (a
22
+ // standalone `prebuild` task is one `vx run build` never runs — and the
23
+ // hook is usually `rimraf dist`); and a script that is nothing but
24
+ // `<pm> run <other>` becomes a GROUP over `<other>`, so the graph sees the
25
+ // dependency instead of a package-manager subprocess it cannot cache.
26
+ // Where the manager runs no such hooks (Yarn 2+, D-31; npm or pnpm told
27
+ // not to, D-33) `prebuild` is a script like any other.
28
+
29
+ import { existsSync, readFileSync } from 'node:fs'
30
+ import path from 'node:path'
31
+ import {
32
+ foldScriptHooks,
33
+ type GeneratedProject,
34
+ type GeneratedTask,
35
+ type MigrationPlan,
36
+ PERSISTENT_TODO,
37
+ pruneOrphanPersistentNotes,
38
+ taskNameProblem,
39
+ } from '@vzn/vx'
40
+ import type { PackageMeta } from './packages.js'
41
+ import { relPosix } from './paths.js'
42
+
43
+ /**
44
+ * Task names that conventionally never exit. Every mapper guesses the same
45
+ * way from a name — a source that SAYS a task is long-running (turbo's
46
+ * `persistent: true`, an Nx dev-server executor) is believed instead.
47
+ */
48
+ const PERSISTENT_TASK_NAMES: ReadonlySet<string> = new Set([
49
+ 'dev',
50
+ 'start',
51
+ 'serve',
52
+ 'watch',
53
+ 'preview',
54
+ ])
55
+
56
+ /**
57
+ * Where a framework's build writes by default, for the cache TODO: the
58
+ * hint said `dist/**` to every build, `next build` included, which writes
59
+ * `.next`. Each tool's documented default; anything else keeps `dist/**`.
60
+ */
61
+ const BUILD_OUTPUTS: readonly [RegExp, readonly string[]][] = [
62
+ [/\snext build\s/, ['.next/**', '!.next/cache/**']],
63
+ [/\snux[ti] build\s/, ['.output/**']],
64
+ [/\s(?:remix|react-router|react-scripts|docusaurus) build\s/, ['build/**']],
65
+ [/\sgatsby build\s/, ['public/**']],
66
+ [/\s(?:storybook build|build-storybook)\s/, ['storybook-static/**']],
67
+ ]
68
+
69
+ /** A directory a TODO may name: relative, plain, no hidden or tool dir. */
70
+ const OUTPUT_DIR = /^(?:\.\/)?((?![./])[\w@-][\w@./-]*?)\/?$/
71
+
72
+ /**
73
+ * The directory the command says it writes, when no tool above names one:
74
+ * an out-dir flag (`tsc --outDir lib`, `babel -d lib`), else the
75
+ * directories it cleans first (`del-cli distribution`, `rimraf dist
76
+ * types`). ky's build writes `distribution/`, and the TODO said `dist/**`
77
+ * (D-90). A file (`tsconfig.tsbuildinfo`) and `node_modules` are no output.
78
+ */
79
+ function namedOutputs(command: string): string[] | undefined {
80
+ const dir = (w: string | undefined): string | undefined => {
81
+ const m = w === undefined ? null : OUTPUT_DIR.exec(w.replace(/^(["'])(.*)\1$/, '$2'))
82
+ if (m === null) return undefined
83
+ const d = m[1]!
84
+ return /\.[a-z]+$/i.test(d) || d.split('/').includes('node_modules') ? undefined : d
85
+ }
86
+ const flag = /\s(?:--outDir|--out-dir|--outdir|-d)[\s=](\S+)/.exec(` ${command} `)
87
+ const out = dir(flag?.[1])
88
+ if (out !== undefined) return [`${out}/**`]
89
+ for (const segment of command.split(/[;&|()]+/)) {
90
+ const words = segment.trim().split(/\s+/)
91
+ const at =
92
+ words[0] === 'rimraf' || words[0] === 'del-cli' || words[0] === 'del'
93
+ ? 1
94
+ : words[0] === 'rm' || (words[0] === 'shx' && words[1] === 'rm')
95
+ ? words.indexOf('rm') + 1
96
+ : -1
97
+ if (at < 0) continue
98
+ const args = words.slice(at).filter((w) => !w.startsWith('-'))
99
+ // A dir the command makes again (kit's `rm -rf ./missing_ids && mkdir
100
+ // ./missing_ids && vite build`) is a scratch dir it prepares, not output.
101
+ const made = new Set([...command.matchAll(/\bmkdir\s+(?:-p\s+)?(\S+)/g)].map((m) => dir(m[1])))
102
+ const dirs = args
103
+ .map(dir)
104
+ .filter((d): d is string => d !== undefined)
105
+ .filter((d) => !made.has(d))
106
+ // Every cleaned path a directory vx can name, or the guess is not this.
107
+ return dirs.length > 0 && dirs.length === args.filter((w) => !/\.[a-z]+$/i.test(w)).length
108
+ ? dirs.map((d) => `${d}/**`)
109
+ : undefined
110
+ }
111
+ return undefined
112
+ }
113
+
114
+ /** The cache block the task that builds `command` should declare, as a TODO on it. */
115
+ export function cacheTodo(command: string): string {
116
+ // Padded, separators as spaces: `next build && …` and `(next build)` match.
117
+ const words = ` ${command.replace(/[;&|()]/g, ' ')} `
118
+ const hit = BUILD_OUTPUTS.find(([re]) => re.test(words))
119
+ const outputs = (hit?.[1] ?? namedOutputs(command) ?? ['dist/**']).map((o) => `'${o}'`).join(', ')
120
+ return `cache: add \`cache: { inputs: { files: ['src/**'] }, outputs: { files: [${outputs}] } }\` with this package's real inputs and outputs — without it the task always runs and every file here, what it writes included, folds into the key its dependents fold; a block with EMPTY outputs would be a cached no-op, not an uncached task`
121
+ }
122
+
123
+ // `lint` is not here: a linter reads sources, and an edge to `build`
124
+ // serialises the two for nothing (the init walkthrough, 2026-09-04).
125
+ const AFTER_BUILD = new Set(['test', 'typecheck', 'check', 'e2e'])
126
+ const LIFECYCLE = /^(pre|post)(install|publish|pack|version)$|^(prepare|prepublishOnly|install)$/
127
+
128
+ /**
129
+ * The script a command delegates to when it is NOTHING but a package-manager
130
+ * invocation of another script: `npm run x`, `pnpm x`, `yarn run x`, `bun run
131
+ * x`, plus npm's bare `npm test` / `npm start`. Flags, arguments or a chain
132
+ * make it a real command again, which is left verbatim.
133
+ */
134
+ export function delegatedScript(command: string): string | null {
135
+ const m =
136
+ /^(?:(?:npm run|(pnpm|pn|yarn|bun)(?: (run))?) ([^\s&|;<>()$`'"\\]+)|npm (test|start))$/.exec(
137
+ command.trim(),
138
+ )
139
+ if (m === null) return null
140
+ if (m[4] !== undefined) return m[4]
141
+ const [, alias, run, name] = m
142
+ // `pn` is pnpm's own short name (pnpm 11; pnpm/pnpm's scripts run it).
143
+ const manager = alias === 'pn' ? 'pnpm' : alias
144
+ // Bare, the manager's own command wins over a script of that name:
145
+ // `bun test` is Bun's test runner and `bun build` its bundler, never the
146
+ // `test` / `build` script, and a group over the script ran the wrong
147
+ // thing (item 908). `pnpm test` and `yarn test` do run the script.
148
+ if (manager !== undefined && run === undefined && OWN_COMMANDS[manager]!.has(name!)) return null
149
+ return name!
150
+ }
151
+
152
+ /** What `<manager> <name>`, with no `run`, runs as the manager's own command. */
153
+ // Listing a name that runs the script only keeps the command verbatim, so
154
+ // each set leans wide; missing one of the manager's own made a group over
155
+ // a script the manager never runs (D-32: `pnpm docs` opened npm's docs page,
156
+ // `bun deploy` answered "reserved", `yarn check` verified dependencies).
157
+ const OWN_COMMANDS: Readonly<Record<string, ReadonlySet<string>>> = {
158
+ // `bun --help`, Bun 1.4.2, the short aliases, and the names it reserves
159
+ // or runs itself (probed: `bun lint` runs the script).
160
+ bun: new Set(
161
+ 'run test x repl exec install i add a remove rm update audit dedupe prune outdated link unlink publish patch pm info why build init create c upgrade feedback list deploy config login logout whoami help'.split(
162
+ ' ',
163
+ ),
164
+ ),
165
+ // pnpm 10.33.0's `commandNames`, and the npm commands it passes through
166
+ // to npm whatever the scripts say.
167
+ pnpm: new Set(
168
+ 'add approve-builds audit bin c cache cat-file cat-index ci clean-install completion config create dedupe deploy dislink dlx doctor env exec fetch find-hash get i ic ignored-builds import init install install-clean install-test it la licenses link list ll ln ls m multi outdated pack patch patch-commit patch-remove prune publish rb rebuild recursive remove restart rm root run run-script self-update server set setup store un uni uninstall unlink up update upgrade why access adduser bugs deprecate dist-tag docs edit find home info issues login logout owner ping prefix profile pkg repo s se search set-script show star stars team token unpublish unstar v version view whoami xmas help'.split(
169
+ ' ',
170
+ ),
171
+ ),
172
+ // `yarn help`, Yarn 1.22.22 and Yarn 4.5.0: one set for both.
173
+ yarn: new Set(
174
+ 'access add audit autoclean bin cache check config constraints create dedupe dlx exec explain files generate-lock-entry global help import info init install licenses link list login logout node npm outdated owner pack patch patch-commit plugin policies publish rebuild remove run search set stage tag team unlink unplug up upgrade upgrade-interactive version versions why workspace workspaces'.split(
175
+ ' ',
176
+ ),
177
+ ),
178
+ }
179
+
180
+ /**
181
+ * The package manager that owns `dir`, when it runs `pre` / `post` hooks
182
+ * around a script; null when it runs none. The manager is the nearest `packageManager` field or
183
+ * lockfile, and its settings are read beside it, as each reads them for a
184
+ * member too. Probed with a `prebuild` / `build` / `postbuild` trio: Yarn
185
+ * 2+ runs none (D-31); npm runs none under `ignore-scripts=true` in
186
+ * `.npmrc`; pnpm runs none under `enable-pre-post-scripts=false` there or
187
+ * `enablePrePostScripts: false` in `pnpm-workspace.yaml` (D-33); Bun and
188
+ * Yarn 1 run them whatever those say. No manager found is npm's default.
189
+ */
190
+ function runsScriptHooks(dir: string, memo: Map<string, Owner>): string | null {
191
+ const { manager, at } = ownerOf(dir, memo)
192
+ const read = (f: string): string | undefined => {
193
+ try {
194
+ return readFileSync(path.join(at, f), 'utf8')
195
+ } catch {
196
+ return undefined
197
+ }
198
+ }
199
+ const npmrc = read('.npmrc') ?? ''
200
+ const set = (key: string, value: string): boolean =>
201
+ new RegExp(`^\\s*${key}\\s*=\\s*${value}\\s*$`, 'm').test(npmrc)
202
+ const runs =
203
+ manager === 'berry'
204
+ ? false
205
+ : manager === 'npm'
206
+ ? !set('ignore-scripts', 'true')
207
+ : manager === 'pnpm'
208
+ ? !set('enable-pre-post-scripts', 'false') && !pnpmSkipsHooks(read('pnpm-workspace.yaml'))
209
+ : true
210
+ return runs ? manager : null
211
+ }
212
+
213
+ /**
214
+ * `enablePrePostScripts: false` in `pnpm-workspace.yaml`, read as pnpm reads
215
+ * it, as YAML: a line regex missed `false # hooks off` and a flow mapping,
216
+ * which pnpm 10 honours, and folded hooks pnpm does not run.
217
+ */
218
+ function pnpmSkipsHooks(yaml: string | undefined): boolean {
219
+ if (yaml === undefined) return false
220
+ try {
221
+ const parsed = Bun.YAML.parse(yaml) as { enablePrePostScripts?: unknown } | null
222
+ return parsed?.enablePrePostScripts === false
223
+ } catch {
224
+ return false
225
+ }
226
+ }
227
+
228
+ /** The package manager that owns a directory, and the directory that says so. */
229
+ interface Owner {
230
+ manager: string
231
+ at: string
232
+ }
233
+
234
+ /**
235
+ * The nearest `packageManager` field or lockfile from `dir` up: `berry` for
236
+ * Yarn 2+, `yarn` for Yarn 1, else the manager's name; none found is npm.
237
+ */
238
+ function ownerOf(dir: string, memo: Map<string, Owner>): Owner {
239
+ const known = memo.get(dir)
240
+ if (known !== undefined) return known
241
+ const read = (f: string): string | undefined => {
242
+ try {
243
+ return readFileSync(path.join(dir, f), 'utf8')
244
+ } catch {
245
+ return undefined
246
+ }
247
+ }
248
+ let manager: string | undefined
249
+ try {
250
+ // A byte-order mark is stripped, as npm, pnpm and discovery strip it:
251
+ // JSON.parse threw on one and the field went unread.
252
+ const manifest = (read('package.json') ?? '').replace(/^\uFEFF/, '')
253
+ const pm = (JSON.parse(manifest) as { packageManager?: unknown }).packageManager
254
+ // A manager vx knows nothing of (zod's `nub@0.8.3`) says nothing about
255
+ // hooks: the lockfile beside it does, and "nub ran `postbuild`" was a
256
+ // claim nothing had checked (D-96).
257
+ if (typeof pm === 'string' && /^(npm|pnpm|yarn|bun)@/.test(pm))
258
+ manager = /^yarn@([2-9]|\d{2,})/.test(pm) ? 'berry' : pm.split('@')[0]
259
+ } catch {}
260
+ if (manager === undefined) {
261
+ const yarnLock = read('yarn.lock')
262
+ if (yarnLock !== undefined) manager = yarnLock.includes('\n__metadata:') ? 'berry' : 'yarn'
263
+ else if (existsSync(path.join(dir, 'pnpm-lock.yaml'))) manager = 'pnpm'
264
+ else if (existsSync(path.join(dir, 'package-lock.json'))) manager = 'npm'
265
+ else if (['bun.lock', 'bun.lockb'].some((f) => existsSync(path.join(dir, f)))) manager = 'bun'
266
+ }
267
+ const up = path.dirname(dir)
268
+ const owner =
269
+ manager !== undefined
270
+ ? { manager, at: dir }
271
+ : up === dir
272
+ ? { manager: 'npm', at: dir }
273
+ : ownerOf(up, memo)
274
+ memo.set(dir, owner)
275
+ return owner
276
+ }
277
+
278
+ /**
279
+ * Yarn 2+ runs a script in its own shell, where `run <script>` is `yarn
280
+ * run <script>`; vx's shell has no `run`, and 23 of berry's scripts
281
+ * (`run build:zip:worker`, `run test:unit packages/…`) mapped to tasks
282
+ * that failed "command not found" (D-92). Spelled out, at the head of
283
+ * each `&&` / `||` / `;` / `|` segment.
284
+ */
285
+ function berryRun(command: string): string {
286
+ return command.replace(/(^|&&|\|\||[;|(])(\s*)run(?=\s)/g, '$1$2yarn run')
287
+ }
288
+
289
+ const MANIFEST_IMPORT = "import pkg from './package.json' with { type: 'json' }"
290
+
291
+ /**
292
+ * The `$npm_*` variables a command reads. Every manager sets them for a
293
+ * script (probed: npm, pnpm, bun and yarn printed the version, the name
294
+ * and the script's name) and vx sets none, so a migrated `echo
295
+ * $npm_package_version` printed nothing (D-34). The name and version are
296
+ * read from the manifest at evaluation, so a bump reaches them; the event
297
+ * is the script's own name, which a folded hook does not share.
298
+ */
299
+ function npmEnv(
300
+ command: string,
301
+ script: string,
302
+ folded: boolean,
303
+ ): { define: Record<string, unknown>; unset: string[]; readsManifest: boolean } {
304
+ const define: Record<string, unknown> = {}
305
+ const unset: string[] = []
306
+ let readsManifest = false
307
+ for (const [, v] of command.matchAll(/\$\{?(npm_[A-Za-z0-9_]+)/g)) {
308
+ if (v! in define || unset.includes(v!)) continue
309
+ if (v === 'npm_package_name' || v === 'npm_package_version') {
310
+ define[v] = { raw: v === 'npm_package_name' ? 'pkg.name' : 'pkg.version' }
311
+ readsManifest = true
312
+ } else if (v === 'npm_lifecycle_event' && !folded) define[v] = script
313
+ else unset.push(v!)
314
+ }
315
+ return { define, unset, readsManifest }
316
+ }
317
+
318
+ /**
319
+ * A script that never exits by design (D-40): a `watch` segment in its
320
+ * name (`build:watch`, `watch-css`), a `--watch` flag, `tsc -w` /
321
+ * `rollup -w`, or nodemon. Run as a plain task it held its dependents
322
+ * forever. A bare `-w` elsewhere is not read: `npm run x -w pkg` is a
323
+ * workspace.
324
+ */
325
+ function isWatcher(name: string, command: string): boolean {
326
+ if (name.split(/[:\-_.]/).includes('watch')) return true
327
+ return (
328
+ /(?:^|\s)--watch(?:All)?(?:[=\s]|$)/.test(command) ||
329
+ /\b(?:tsc|rollup)\b[^&|;]*\s-w(?:\s|$)/.test(command) ||
330
+ /(?:^|[\s&|;])nodemon\b/.test(command)
331
+ )
332
+ }
333
+
334
+ /** A server run alone (`serve website`, `http-server`), or a tool's own server verb. */
335
+ const SERVERS = new Set(['serve', 'http-server', 'live-server', 'sirv', 'webpack-dev-server'])
336
+ const SERVER_TOOLS = new Set([
337
+ 'vite',
338
+ 'next',
339
+ 'astro',
340
+ 'nuxt',
341
+ 'nuxi',
342
+ 'remix',
343
+ 'react-router',
344
+ 'docusaurus',
345
+ 'storybook',
346
+ 'netlify',
347
+ 'wrangler',
348
+ 'webpack',
349
+ 'expo',
350
+ 'react-native',
351
+ ])
352
+ const SERVER_VERBS = new Set(['dev', 'serve', 'start', 'preview'])
353
+ const LAUNCHERS = new Set(['cross-env', 'npx', 'bunx', 'exec', 'dlx'])
354
+
355
+ /**
356
+ * A command that runs a server (D-91): docusaurus's `serve website` and
357
+ * `netlify dev` mapped as one-shot tasks, so a dependent waited forever and
358
+ * a server that exits on stdin EOF ended at once. Read per `&&` segment,
359
+ * past env assignments and launchers; a quoted string is another tool's
360
+ * argument (`start-server-and-test 'vite preview' …` exits).
361
+ */
362
+ function servesCommand(command: string): boolean {
363
+ for (const segment of foreground(command.replace(/"[^"]*"|'[^']*'/g, ' '))) {
364
+ const words = segment
365
+ .trim()
366
+ .split(/\s+/)
367
+ .filter((w) => w !== '')
368
+ let i = 0
369
+ while (
370
+ i < words.length &&
371
+ (/^[A-Za-z_][A-Za-z0-9_]*=/.test(words[i]!) ||
372
+ LAUNCHERS.has(words[i]!) ||
373
+ words[i]!.startsWith('-') ||
374
+ (/^(pnpm|npm|yarn)$/.test(words[i]!) && LAUNCHERS.has(words[i + 1] ?? '')))
375
+ ) {
376
+ // A launcher's `--package <name>` / `-p <name>` names what to install,
377
+ // not the program: docusaurus's `pnpm dlx --package netlify-cli netlify
378
+ // dev` read `netlify-cli` as the program (D-112).
379
+ i += words[i] === '--package' || words[i] === '-p' ? 2 : 1
380
+ }
381
+ const [program, verb] = [words[i], words[i + 1]]
382
+ if (program === undefined) continue
383
+ if (SERVERS.has(program)) return true
384
+ if (SERVER_TOOLS.has(program) && verb !== undefined && SERVER_VERBS.has(verb)) return true
385
+ if (program === 'vite' && (verb === undefined || verb.startsWith('-'))) return true
386
+ }
387
+ return false
388
+ }
389
+
390
+ /**
391
+ * Whether script `name` never exits: by name, as a watcher, as a server, or
392
+ * running such a script of its own package by name (docusaurus's
393
+ * `start:baseUrl`: `cross-env BASE_URL=… pnpm start`) or through a runner.
394
+ */
395
+ function isPersistent(
396
+ name: string,
397
+ scripts: Readonly<Record<string, unknown>>,
398
+ seen: Set<string> = new Set(),
399
+ ): boolean {
400
+ const own = scripts[name]
401
+ if (typeof own !== 'string' || seen.has(name)) return false
402
+ seen.add(name)
403
+ if (PERSISTENT_TASK_NAMES.has(name) || isWatcher(name, own) || servesCommand(own)) return true
404
+ // Through a runner too: `concurrently "npm:web" "npm:api"` and `run-p web
405
+ // api` over two servers mapped as one-shot tasks (D-113).
406
+ const text = Object.entries(scripts).filter(
407
+ (e): e is [string, string] => typeof e[1] === 'string',
408
+ )
409
+ return foreground(own).some((segment) =>
410
+ scriptRefs(segment, text).some((ref) => isPersistent(ref, scripts, seen)),
411
+ )
412
+ }
413
+
414
+ /** The `&&` / `;` / `|` segments of a command, less those it backgrounds with a single `&`. */
415
+ function foreground(command: string): string[] {
416
+ return command
417
+ .split(/&&|\|\||[;|()]/)
418
+ .flatMap((part) => part.split('&').slice(-1))
419
+ .filter((segment) => segment.trim() !== '')
420
+ }
421
+
422
+ function scriptsOf(meta: PackageMeta): Record<string, unknown> {
423
+ // package.json is a boundary: `scripts` is whatever the file holds. A
424
+ // string or an array would enumerate its indices as script names.
425
+ const raw = (meta.packageJson as unknown as { scripts?: unknown }).scripts
426
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return {}
427
+ return raw as Record<string, unknown>
428
+ }
429
+
430
+ /**
431
+ * A `build` that only delegates (`build: pnpm run compile`) is a group over
432
+ * its target, and the `^build` edge every `build` carries belongs on the
433
+ * task that does the work: the group ran `compile` with no edge at all, so
434
+ * a package compiled before the ones it imports had built (item 907). On
435
+ * the group itself it would not hold — the group waits on both, `compile`
436
+ * on neither. Followed through a chain of groups to the first command,
437
+ * which takes the cache TODO too: a group has no command to cache, and the
438
+ * TODO a delegating `build` never got was the one the header promised
439
+ * (item 1045).
440
+ */
441
+ function upstreamBuildOnWorker(tasks: GeneratedTask[]): void {
442
+ const byName = new Map(tasks.map((t) => [t.name, t]))
443
+ const seen = new Set<string>()
444
+ let at = byName.get('build')
445
+ while (at?.task !== undefined && at.task !== null && !('exec' in at.task)) {
446
+ if (seen.has(at.name)) return
447
+ seen.add(at.name)
448
+ const deps = at.task['dependsOn']
449
+ at = Array.isArray(deps) ? byName.get(String(deps[0])) : undefined
450
+ }
451
+ if (at === undefined || at.name === 'build' || at.task === null || at.task === undefined) return
452
+ // The worker IS `build`'s work, and so is every group on the way to it:
453
+ // a `typecheck` behind `build: npm run typecheck` waited for `build`,
454
+ // and the run refused the cycle (D-16).
455
+ for (const name of [...seen, at.name]) {
456
+ const t = byName.get(name)!.task!
457
+ if (name !== 'build' && Array.isArray(t['dependsOn'])) {
458
+ t['dependsOn'] = (t['dependsOn'] as string[]).filter((d) => d !== 'build')
459
+ }
460
+ }
461
+ const deps = Array.isArray(at.task['dependsOn']) ? (at.task['dependsOn'] as string[]) : []
462
+ if (!deps.includes('^build')) at.task['dependsOn'] = ['^build', ...deps]
463
+ const todo = cacheTodo(String((at.task['exec'] as { command?: unknown }).command))
464
+ if (!at.todos.includes(todo)) at.todos.push(todo)
465
+ }
466
+
467
+ /**
468
+ * A root script that runs the members rather than checking the repo:
469
+ * pnpm's `-r` / `--filter` / `-C`, Yarn's `--cwd` (excalidraw's
470
+ * `build:common`) and `yarn workspace <name>` (cal.com's `prisma`), npm's
471
+ * `--prefix`, the workspace flags, Bun's `--filter`,
472
+ * Vite+'s `vp run` (tiptap), and the other runners, vx itself included. Mapped, it ran every member's
473
+ * task again beside the member's own (D-45).
474
+ */
475
+ const RUNS_MEMBERS =
476
+ /(^|[\s;&|(])(turbo|nx|lerna|ultra|wireit|nps|moon|rush|vx)(\s|$)|\bworkspaces?\s+(foreach|run)\b|\byarn\s+workspace\s|\bvp\s+run\s/
477
+
478
+ /** A workspace flag, read only where a package manager takes it (`pmRunsMembers`). */
479
+ const MEMBER_FLAG =
480
+ /^(-r|--recursive|--filter|-F|--workspaces|-ws|--workspace|-w|-C|--dir|--cwd|--prefix|--if-present|--parallel|--stream)(=|$)/
481
+
482
+ /**
483
+ * The workspace flags are a package manager's: on the program it runs they
484
+ * mean something else, and berry's `bench` (`yarn node -r ./setup.ts …`,
485
+ * node's `--require`) was left out as running the members (D-81). With no
486
+ * manager named, `node <bin> run` is one (npm/cli's own npm), and not
487
+ * `docker run -w` (D-83).
488
+ */
489
+ function pmRunsMembers(script: string): boolean {
490
+ for (const segment of script.split(/&&|\|\||[;|()]/)) {
491
+ const words = segment.trim().split(/\s+/)
492
+ let i = words.findIndex((w) => /^(pnpm|pn|npm|yarn|bun)$/.test(w))
493
+ // npm/cli runs itself: `node . run test --workspaces`.
494
+ if (i < 0) i = words.findIndex((w, j) => w === 'run' && words[j - 2] === 'node')
495
+ if (i < 0) continue
496
+ // `exec`, `dlx` and `x` take the manager's flags up to the program name.
497
+ let program = false
498
+ for (i++; i < words.length; i++) {
499
+ const w = words[i]!
500
+ if (MEMBER_FLAG.test(w)) return true
501
+ if (program && !w.startsWith('-')) break
502
+ if (w === 'node') break
503
+ if (w === 'exec' || w === 'dlx' || w === 'x') program = true
504
+ }
505
+ }
506
+ return false
507
+ }
508
+
509
+ // A bare target may escape a space or paren (`cd packages/my\ app`).
510
+ const CD = /(?:^|[\s;&|(])cd\s+("[^"]*"|'[^']*'|(?:\\.|[^\s;&|()\\])+)/g
511
+
512
+ /**
513
+ * A `cd` into a member's directory, or one holding members, runs that
514
+ * member's work (kit's `cd packages/kit && vitest run`); bun's
515
+ * `typecheck` (`cd test && bun run typecheck`) enters no member and was
516
+ * left out (D-83). A target vx cannot read (`$DIR`, `~`) still counts.
517
+ */
518
+ function cdsToMembers(script: string, rootDir: string, memberDirs: readonly string[]): boolean {
519
+ for (const m of script.matchAll(CD)) {
520
+ const quoted = /^(["'])(.*)\1$/.exec(m[1]!)
521
+ const target = quoted ? quoted[2]! : m[1]!.replace(/\\(.)/g, '$1')
522
+ if (/[$`~*?]/.test(target) || target === '-') return true
523
+ const dir = path.resolve(rootDir, target)
524
+ const inside = (a: string, b: string): boolean => a === b || a.startsWith(b + path.sep)
525
+ if (memberDirs.some((d) => inside(d, dir) || inside(dir, d))) return true
526
+ }
527
+ return false
528
+ }
529
+
530
+ /**
531
+ * The manager a root's files name, or undefined: `pnpm-workspace.yaml` is
532
+ * pnpm's whatever the lockfile, and the npm `ownerOf` falls back to at the
533
+ * file-system root with no lockfile anywhere is no claim.
534
+ */
535
+ function rootManager(dir: string, memo: Map<string, Owner>): string | undefined {
536
+ if (existsSync(path.join(dir, 'pnpm-workspace.yaml'))) return 'pnpm'
537
+ const owner = ownerOf(dir, memo)
538
+ const guessed =
539
+ owner.at === path.parse(owner.at).root && !existsSync(path.join(owner.at, 'package-lock.json'))
540
+ return guessed ? undefined : owner.manager
541
+ }
542
+
543
+ /**
544
+ * How the repo's own manager runs the members, for the root note: an npm
545
+ * repo read "(`pnpm -r`, `--filter`, a runner)" (insomnia).
546
+ */
547
+ function membersExample(manager: string | undefined): string {
548
+ switch (manager) {
549
+ case 'npm':
550
+ return '`--workspaces`, `-w`'
551
+ case 'yarn':
552
+ return '`yarn workspaces run`, `yarn workspace`'
553
+ case 'berry':
554
+ return '`yarn workspaces foreach`, `yarn workspace`'
555
+ case 'bun':
556
+ return '`bun --filter`'
557
+ default:
558
+ return '`pnpm -r`, `--filter`'
559
+ }
560
+ }
561
+
562
+ /**
563
+ * The hooks a package's build hides in when it has no `build` script:
564
+ * react-navigation's twelve packages build in `prepack: bob build`, their
565
+ * root's `build` (`lerna run prepack`) runs the members and is left out,
566
+ * and the repo mapped with no build at all.
567
+ */
568
+ const LIFECYCLE_BUILD_HOOKS = ['prepack', 'prepublishOnly', 'prepublish', 'prepare']
569
+ const BUILDER =
570
+ /(?:^|[\s;&|(])(?:bob build|tsc|tsup|tsdown|rollup|vite build|babel|unbuild|esbuild|webpack|microbundle|pkgroll|bunchee|preconstruct build)(?:\s|$)/
571
+
572
+ /** The workspace flags that move a package manager off the package it runs in. */
573
+ const ELSEWHERE_FLAG = /^(-r|--recursive|--filter|-F|--workspaces|-ws|--workspace)(=|$)/
574
+ const DIR_FLAG = /^(-C|--dir|--cwd|--prefix)(?:=(.*))?$/
575
+
576
+ /**
577
+ * The part of a member's script that runs another member's work: pinia's
578
+ * online-playground `build` is `pnpm -C ../pinia run build && vite build`,
579
+ * and mapped verbatim it built pinia again beside pinia's own task, writing
580
+ * the dist its siblings read. `yarn workspace <name>`, a runner, `pnpm -r`
581
+ * / `--filter`, and a `cd` / `-C` / `--cwd` / `--prefix` whose innermost
582
+ * member is another one count; a fixture inside the member, or the root,
583
+ * does not.
584
+ */
585
+ function siblingRun(script: string, dir: string, others: readonly string[]): string | undefined {
586
+ const inside = (a: string, b: string): boolean => a === b || a.startsWith(b + path.sep)
587
+ const intoOther = (target: string): boolean => {
588
+ const t = path.resolve(dir, target.replace(/^(["'])(.*)\1$/, '$2'))
589
+ if (/[$`~*?]/.test(target)) return false
590
+ const owner = [dir, ...others]
591
+ .filter((d) => inside(t, d))
592
+ .sort((x, y) => y.length - x.length)[0]
593
+ return owner !== undefined && owner !== dir
594
+ }
595
+ for (const segment of script.split(/&&|\|\||[;|()]/)) {
596
+ const s = segment.trim()
597
+ if (RUNS_MEMBERS.test(` ${s}`)) return s
598
+ for (const m of s.matchAll(CD)) if (intoOther(m[1]!)) return s
599
+ const words = s.split(/\s+/)
600
+ const at = words.findIndex((w) => /^(pnpm|pn|npm|yarn|bun)$/.test(w))
601
+ if (at < 0) continue
602
+ for (let i = at + 1; i < words.length; i++) {
603
+ const w = words[i]!
604
+ if (ELSEWHERE_FLAG.test(w)) return s
605
+ const flag = DIR_FLAG.exec(w)
606
+ if (flag === null) continue
607
+ const target = flag[2] ?? words[++i]
608
+ if (target !== undefined && intoOther(target)) return s
609
+ }
610
+ }
611
+ return undefined
612
+ }
613
+
614
+ /**
615
+ * The package globs a `lerna.json` beside a lone root lists (Lerna's
616
+ * default when it names none), or undefined: Lerna-classic repos list
617
+ * their packages there, not in `workspaces`, and vx saw the root alone
618
+ * (D-111).
619
+ */
620
+ function lernaPackages(dir: string): string[] | undefined {
621
+ let json: unknown
622
+ try {
623
+ json = JSON.parse(readFileSync(path.join(dir, 'lerna.json'), 'utf8').replace(/^\uFEFF/, ''))
624
+ } catch {
625
+ return undefined
626
+ }
627
+ const listed = (json as { packages?: unknown } | null)?.packages
628
+ return Array.isArray(listed) && listed.every((g) => typeof g === 'string') && listed.length > 0
629
+ ? listed
630
+ : ['packages/*']
631
+ }
632
+
633
+ function lernaNote(globs: readonly string[]): string {
634
+ const list = globs.map((g) => JSON.stringify(g)).join(', ')
635
+ return `lerna.json lists the packages (${list}), but package.json declares no \`workspaces\`, so vx sees the root alone: add \`"workspaces": [${list}]\` to package.json and run \`vx init\` again`
636
+ }
637
+
638
+ /**
639
+ * Yarn 2+ installs with Plug'n'Play unless `.yarnrc.yml` names another
640
+ * linker: a package's bins live in `.pnp.cjs`, and a task's `json5` exited
641
+ * 127 under vx, which runs no `yarn` in front of a command (probed on Yarn
642
+ * 4.5).
643
+ */
644
+ function usesPnp(dir: string): boolean {
645
+ let rc = ''
646
+ try {
647
+ rc = readFileSync(path.join(dir, '.yarnrc.yml'), 'utf8')
648
+ } catch {}
649
+ const linker = /^nodeLinker:\s*["']?([\w-]+)/m.exec(rc)?.[1]
650
+ return linker === undefined || linker === 'pnp'
651
+ }
652
+
653
+ const PNP_NOTE =
654
+ "Yarn Plug'n'Play installs this repo: a package's bins live in `.pnp.cjs`, not `node_modules/.bin`, so a task's `tsc` is not found under vx — set `nodeLinker: node-modules` in `.yarnrc.yml` and run `yarn install`, or write each command as `yarn exec '<command>'`"
655
+
656
+ /** A runner of this package's scripts by name: `run-p build:*`. */
657
+ const RUNS_OWN = /(?:^|\s)(?:run-s|run-p|npm-run-all)(?:\s|$)/
658
+
659
+ /** A package manager running a script by name: `pnpm build`, `npm run x`, `bun run x`. */
660
+ const RUNS_SCRIPT =
661
+ /(?:^|[\s;&|(])(?:pnpm|pn|npm|yarn|bun)\s+(?:run(?:-script)?\s+)?([^\s;&|()'"-][^\s;&|()'"]*)/g
662
+
663
+ /**
664
+ * The scripts a command runs by name: a package manager's (`pnpm x`, `npm
665
+ * run x`), `run-s` / `run-p` / `npm-run-all` (`build:*` is one segment,
666
+ * `build:**` any), and `concurrently "npm:x"`. A root `ci: run-s build:all
667
+ * lint` over `build:all: pnpm -r build` mapped as a root task (D-95).
668
+ */
669
+ function scriptRefs(command: string, scripts: readonly (readonly [string, string])[]): string[] {
670
+ const refs = [...command.matchAll(RUNS_SCRIPT)].map((m) => m[1]!)
671
+ for (const m of command.matchAll(/["']?\b(?:npm|pnpm|yarn|bun):([^\s"']+)/g)) refs.push(m[1]!)
672
+ for (const segment of command.split(/&&|\|\||[;|()]/)) {
673
+ const words = segment.trim().split(/\s+/)
674
+ const at = words.findIndex((w) => /^(run-s|run-p|npm-run-all)$/.test(w))
675
+ if (at < 0) continue
676
+ for (const w of words.slice(at + 1)) {
677
+ if (w.startsWith('-')) continue
678
+ const name = w.replace(/^(["'])(.*)\1$/, '$2')
679
+ if (!name.includes('*')) {
680
+ refs.push(name)
681
+ continue
682
+ }
683
+ const re = new RegExp(
684
+ `^${name
685
+ .split(/(\*\*|\*)/)
686
+ .map((part) =>
687
+ part === '**'
688
+ ? '.*'
689
+ : part === '*'
690
+ ? '[^:]*'
691
+ : part.replace(/[.+?^${}()|[\]\\]/g, '\\$&'),
692
+ )
693
+ .join('')}$`,
694
+ )
695
+ for (const [n] of scripts) if (re.test(n)) refs.push(n)
696
+ }
697
+ }
698
+ return refs
699
+ }
700
+
701
+ /**
702
+ * The scripts that run the members, directly or through another of these
703
+ * scripts: vite's `ci-docs` (`pnpm build && pnpm docs-build`) runs each
704
+ * member's build through the root's own `build` (`pnpm -r … run build`).
705
+ */
706
+ function runningMembers(
707
+ scripts: Readonly<Record<string, unknown>>,
708
+ rootDir: string,
709
+ memberDirs: readonly string[],
710
+ ): Set<string> {
711
+ const text = Object.entries(scripts).filter(
712
+ (e): e is [string, string] => typeof e[1] === 'string',
713
+ )
714
+ const out = new Set(
715
+ text
716
+ .filter(
717
+ ([, v]) =>
718
+ RUNS_MEMBERS.test(` ${v}`) || pmRunsMembers(v) || cdsToMembers(v, rootDir, memberDirs),
719
+ )
720
+ .map(([n]) => n),
721
+ )
722
+ for (let grew = true; grew;) {
723
+ grew = false
724
+ for (const [n, v] of text) {
725
+ if (out.has(n)) continue
726
+ if (scriptRefs(v, text).some((r) => out.has(r))) {
727
+ out.add(n)
728
+ grew = true
729
+ }
730
+ }
731
+ }
732
+ return out
733
+ }
734
+
735
+ /**
736
+ * `outside`: the root manifest when the root is no member (pnpm's and
737
+ * Yarn's default), whose own scripts (`lint: eslint .`) went unmapped
738
+ * without a word; `outsideDir` is where it sits.
739
+ */
740
+ export function migrateScripts(
741
+ metas: readonly PackageMeta[],
742
+ outside?: Readonly<Record<string, unknown>>,
743
+ outsideDir?: string,
744
+ ): MigrationPlan {
745
+ const projects: GeneratedProject[] = []
746
+ const hookMemo = new Map<string, Owner>()
747
+ // The workspace root among members: many of its scripts run the
748
+ // workspace (`npm run build --workspaces`, `pnpm -r build`), and a root
749
+ // task made of one ran every member's build again under `--all`. Since
750
+ // D-39 a hand-written root config makes the root a project, and
751
+ // `--force` replaced it so (D-45). A lone package is its repo's project
752
+ // and maps.
753
+ const root = metas.length > 1 ? workspaceRootOf(metas) : undefined
754
+ const notes: string[] = []
755
+ const lifecycleBuilds: [string, string, string][] = []
756
+ // The rest check the whole repo (`lint: oxlint .`, `test: vitest`):
757
+ // `vx run lint` found no project in remix, wagmi or element-plus. Such a
758
+ // script maps onto the root, when the root has a name (vx skips a
759
+ // nameless one's config), no config of its own, and no member declares
760
+ // the task, so `--all` never runs one check twice.
761
+ const outsideName =
762
+ typeof outside?.['name'] === 'string' && outside['name'] !== '' ? outside['name'] : undefined
763
+ // insomnia's root is named as its `packages/insomnia`: a root config made
764
+ // it a project, and every later run was refused for the duplicate (D-129).
765
+ const clash = outsideName === undefined ? undefined : metas.find((m) => m.name === outsideName)
766
+ // A root with a config of its own is kept as written, and a run of `vx
767
+ // init` after the one that mapped it keeps it like a member's (X-27).
768
+ const rootKept = root !== undefined && root.configPath !== null
769
+ const rootMeta: PackageMeta | undefined =
770
+ root !== undefined
771
+ ? rootKept
772
+ ? undefined
773
+ : root
774
+ : outsideName !== undefined && outsideDir !== undefined && clash === undefined
775
+ ? { name: outsideName, dir: outsideDir, packageJson: outside as never, configPath: null }
776
+ : undefined
777
+ const memberTasks = new Set(
778
+ metas
779
+ .filter((m) => m !== root)
780
+ .flatMap((m) => Object.entries(scriptsOf(m)))
781
+ .filter(([, v]) => typeof v === 'string' && v !== '')
782
+ .map(([n]) => n),
783
+ )
784
+ // The root scripts left out, by why: jest's root `build` (every package's)
785
+ // went unnamed beside the website's, and `vx run build --all` built the
786
+ // website alone (D-85). A left-out script's `pre` / `post` hook goes with
787
+ // it where the manager runs hooks: kept, it became a task of its own.
788
+ const leftOut = (meta: PackageMeta): { runs: string[]; shared: string[]; out: Set<string> } => {
789
+ const scripts = scriptsOf(meta)
790
+ const runs = runningMembers(
791
+ scripts,
792
+ meta.dir,
793
+ metas.filter((m) => m !== root).map((m) => m.dir),
794
+ )
795
+ const hooks = runsScriptHooks(meta.dir, hookMemo) !== null
796
+ const hookOf = (n: string): string | undefined => {
797
+ const base = hooks ? /^(?:pre|post)(.+)$/.exec(n)?.[1] : undefined
798
+ return base !== undefined && typeof scripts[base] === 'string' ? base : undefined
799
+ }
800
+ // A hook rides in its script's command, so it is judged with it.
801
+ const named = Object.keys(scripts).filter(
802
+ (n) => typeof scripts[n] === 'string' && !LIFECYCLE.test(n) && hookOf(n) === undefined,
803
+ )
804
+ const left = {
805
+ runs: named.filter((n) => runs.has(n)),
806
+ shared: named.filter((n) => memberTasks.has(n) && !runs.has(n)),
807
+ }
808
+ const outNames = new Set([...left.runs, ...left.shared])
809
+ const out = new Set(
810
+ Object.keys(scripts).filter((n) => outNames.has(n) || outNames.has(hookOf(n) ?? '')),
811
+ )
812
+ return { ...left, out }
813
+ }
814
+ const rootScripts = (meta: PackageMeta): Record<string, unknown> => {
815
+ const scripts = scriptsOf(meta)
816
+ const { out } = leftOut(meta)
817
+ return Object.fromEntries(
818
+ Object.entries(scripts).filter(([n, v]) => typeof v === 'string' && !out.has(n)),
819
+ )
820
+ }
821
+ const mapsOf = (meta: PackageMeta | undefined): number =>
822
+ meta === undefined ? 0 : Object.keys(rootScripts(meta)).filter((n) => !LIFECYCLE.test(n)).length
823
+ const rootMapped = mapsOf(rootMeta)
824
+ const listed = (names: readonly string[]): string =>
825
+ names.slice(0, 8).join(', ') + (names.length > 8 ? ` and ${names.length - 8} more` : '')
826
+ const outsideScripts = outside?.['scripts']
827
+ const outsideRuns =
828
+ typeof outsideScripts === 'object' &&
829
+ outsideScripts !== null &&
830
+ Object.values(outsideScripts).some((v) => typeof v === 'string' && v !== '')
831
+ const unnamedMaps = (): string[] => {
832
+ const meta: PackageMeta = {
833
+ name: 'package.json',
834
+ dir: outsideDir!,
835
+ packageJson: outside as never,
836
+ configPath: null,
837
+ }
838
+ const scripts = scriptsOf(meta)
839
+ return Object.keys(rootScripts(meta)).filter((n) => {
840
+ // A hook rides in its script's command and is no script to name.
841
+ const base = /^(?:pre|post)(.+)$/.exec(n)?.[1]
842
+ return !LIFECYCLE.test(n) && (base === undefined || !(base in scripts))
843
+ })
844
+ }
845
+ // Why a root mapped nothing: `eslint .` beside a member's `lint` runs
846
+ // nothing of the workspace, and the note said it did.
847
+ const rootLeftOut = (): string => {
848
+ const judged = rootKept ? root : rootMeta
849
+ if (judged === undefined) return 'run the workspace'
850
+ const scripts = scriptsOf(judged)
851
+ const named = Object.keys(scripts).filter(
852
+ (n) => typeof scripts[n] === 'string' && scripts[n] !== '' && !LIFECYCLE.test(n),
853
+ )
854
+ const runs = runningMembers(
855
+ scripts,
856
+ judged.dir,
857
+ metas.filter((m) => m !== root).map((m) => m.dir),
858
+ )
859
+ const shared = named.filter((n) => memberTasks.has(n) && !runs.has(n))
860
+ if (shared.length === 0) return 'run the workspace'
861
+ const list = `${shared.slice(0, 8).join(', ')}${shared.length > 8 ? ', …' : ''}`
862
+ return `${shared.length < named.length ? 'run the workspace or ' : ''}share a member's task name (${list})`
863
+ }
864
+ const rootName =
865
+ root?.name ??
866
+ (outsideRuns
867
+ ? typeof outside?.['name'] === 'string' && outside['name'] !== ''
868
+ ? outside['name']
869
+ : 'package.json'
870
+ : undefined)
871
+ if (rootName !== undefined && rootMapped > 0) {
872
+ const { runs, shared } = leftOut(rootMeta!)
873
+ notes.push(
874
+ `${rootName} (the workspace root): its scripts that check the whole repo are its tasks` +
875
+ (runs.length > 0
876
+ ? `; left out as running the members (${membersExample(rootMeta && rootManager(rootMeta.dir, hookMemo))}, a runner): ${listed(runs)}`
877
+ : '') +
878
+ (shared.length > 0
879
+ ? `; left out as a member's task name, so \`--all\` never runs one twice: ${listed(shared)} — one that does other work maps by hand under a name of its own`
880
+ : ''),
881
+ )
882
+ } else if (clash !== undefined && outsideDir !== undefined && unnamedMaps().length > 0) {
883
+ const would = unnamedMaps()
884
+ notes.push(
885
+ `${clash.name} (the workspace root) not mapped: ${relPosix(outsideDir, clash.dir)} has the same "name", and vx names a project by it; rename the root's and run \`vx init\` again to map ${would.length} of its scripts (${would.slice(0, 8).join(', ')}${would.length > 8 ? ', …' : ''})`,
886
+ )
887
+ } else if (rootName === 'package.json' && outsideDir !== undefined && unnamedMaps().length > 0) {
888
+ // react's nameless root: "its scripts run the workspace" was not why,
889
+ // and 30 of its scripts (`build`, `lint`, `test`) map once it has a
890
+ // name (D-87).
891
+ const would = unnamedMaps()
892
+ notes.push(
893
+ `package.json (the workspace root) not mapped: it has no "name", and vx names a project by it; give it one and run \`vx init\` again to map ${would.length} of its scripts (${would.slice(0, 8).join(', ')}${would.length > 8 ? ', …' : ''})`,
894
+ )
895
+ } else if (rootName !== undefined && !(rootKept && mapsOf(root) > 0)) {
896
+ // A nameless root's vx.config is skipped (vx names projects by their
897
+ // manifest's name), so the hand-written one needs a name first (vuejs/core).
898
+ notes.push(
899
+ `${rootName} (the workspace root) not mapped: its scripts ${rootLeftOut()}; declare its own tasks in its vx.config by hand` +
900
+ (rootName === 'package.json' ? ', after giving its package.json a "name"' : ''),
901
+ )
902
+ }
903
+ const mapped = metas.filter((m) => m !== root)
904
+ if (rootMeta !== undefined && rootMapped > 0) mapped.push(rootMeta)
905
+ for (const meta of mapped) {
906
+ const scripts = meta === rootMeta ? rootScripts(meta) : scriptsOf(meta)
907
+ const hooksBy = runsScriptHooks(meta.dir, hookMemo)
908
+ const runsHooks = hooksBy !== null
909
+ const berry = ownerOf(meta.dir, hookMemo).manager === 'berry'
910
+ const runnable = Object.keys(scripts).filter(
911
+ (n) => typeof scripts[n] === 'string' && scripts[n] !== '',
912
+ )
913
+ // A script whose name no task may carry (`lint#fix`, `^up`) was written
914
+ // as one, and the next run refused the whole config (item 1033). It is
915
+ // left out, with a TODO, and a script delegating to it keeps its
916
+ // command rather than naming it as an edge.
917
+ const refused = runnable.flatMap((n) => {
918
+ const why = taskNameProblem(n)
919
+ return why === null ? [] : [[n, why] as const]
920
+ })
921
+ const names = runnable.filter((n) => taskNameProblem(n) === null)
922
+ if (meta !== rootMeta && !('build' in scripts)) {
923
+ const hook = LIFECYCLE_BUILD_HOOKS.find(
924
+ (h) => typeof scripts[h] === 'string' && BUILDER.test(scripts[h]),
925
+ )
926
+ if (hook !== undefined) lifecycleBuilds.push([meta.name, hook, scripts[hook] as string])
927
+ }
928
+ if (runnable.length === 0) continue
929
+ const hasBuild = names.includes('build')
930
+ const has = (n: string): boolean => names.includes(n)
931
+ // Lifecycle hooks are npm's, not tasks anyone runs by name, and a hook
932
+ // of a script that exists rides inside that script's command.
933
+ const isTask = (n: string): boolean => {
934
+ if (!has(n) || LIFECYCLE.test(n)) return false
935
+ const hookOf = runsHooks ? /^(pre|post)(.+)$/.exec(n) : null
936
+ return hookOf === null || !has(hookOf[2]!) || LIFECYCLE.test(hookOf[2]!)
937
+ }
938
+ const tasks: GeneratedTask[] = []
939
+ let readsManifest = false
940
+ const text = Object.entries(scripts).filter(
941
+ (e): e is [string, string] => typeof e[1] === 'string',
942
+ )
943
+ for (const name of names) {
944
+ if (!isTask(name)) continue
945
+
946
+ const todos: string[] = []
947
+ const own = berry ? berryRun(scripts[name] as string) : (scripts[name] as string)
948
+ const delegate = delegatedScript(own)
949
+ // npm lifecycle hooks (`prepack`, `prepublishOnly`, …) belong to the
950
+ // package manager and never ride inside a task — `pack` stays alone.
951
+ const hook = (h: string): boolean => runsHooks && has(h) && !LIFECYCLE.test(h)
952
+ const hooks = [`pre${name}`, `post${name}`].filter(hook)
953
+ // A group over a script that is no task (`setup: npm run prepare`)
954
+ // named a task nothing defines, and the run `vx init` suggested
955
+ // refused the config (D-12).
956
+ if (delegate !== null && isTask(delegate) && delegate !== name && hooks.length === 0) {
957
+ // A group: no exec, the graph runs the target. `npm test` calling
958
+ // `vitest` through `npm run test:unit` is two tasks, not a subprocess.
959
+ const task: Record<string, unknown> = { dependsOn: [delegate] }
960
+ if (AFTER_BUILD.has(name) && hasBuild && delegate !== 'build') {
961
+ task['dependsOn'] = [delegate, 'build']
962
+ }
963
+ tasks.push({ name, todos, task })
964
+ continue
965
+ }
966
+
967
+ const command = foldScriptHooks(
968
+ hook(`pre${name}`) ? (scripts[`pre${name}`] as string) : undefined,
969
+ own,
970
+ hook(`post${name}`) ? (scripts[`post${name}`] as string) : undefined,
971
+ )
972
+ if (hooks.length > 0) {
973
+ todos.push(
974
+ `${hooksBy} ran ${hooks.map((h) => `\`${h}\``).join(' and ')} around this script without being asked; folded into the command in that order`,
975
+ )
976
+ }
977
+ const sibling =
978
+ meta === rootMeta
979
+ ? undefined
980
+ : siblingRun(
981
+ own,
982
+ meta.dir,
983
+ metas.filter((m) => m !== root && m !== meta).map((m) => m.dir),
984
+ )
985
+ if (sibling !== undefined) {
986
+ todos.push(
987
+ `\`${sibling}\` runs another member's work outside the graph, again beside that member's own task: name that task under dependsOn (\`<member>#<task>\`) and drop it from the command`,
988
+ )
989
+ }
990
+ // A chain of this package's own scripts (`check: pnpm run build &&
991
+ // pnpm run lint`) ran each again inside the command, beside the task
992
+ // of that name: `vx run check` built twice. The chain's order may
993
+ // matter, so it is named rather than turned into a group.
994
+ // Through `run-s` / `run-p` / `npm-run-all` too (`build: run-p
995
+ // build:*`), each segment naming every task it runs (M-48). A
996
+ // persistent one has PERSISTENT_TODO.
997
+ const persistent = isPersistent(name, scripts)
998
+ let ranTasks = 0
999
+ const ownRuns = own
1000
+ .split(/&&|\|\||;/)
1001
+ .map((part) => part.trim())
1002
+ .filter((part) => {
1003
+ const d = delegatedScript(part)
1004
+ if (d !== null) {
1005
+ if (d === name || !isTask(d)) return false
1006
+ ranTasks++
1007
+ return true
1008
+ }
1009
+ if (persistent || !RUNS_OWN.test(part)) return false
1010
+ const refs = new Set(scriptRefs(part, text).filter((r) => r !== name && isTask(r)))
1011
+ ranTasks += refs.size
1012
+ return refs.size > 0
1013
+ })
1014
+ if (ownRuns.length > 0) {
1015
+ const list = ownRuns.map((r) => `\`${r}\``).join(', ')
1016
+ todos.push(
1017
+ `${list} ${ownRuns.length === 1 ? 'runs' : 'run'} this package's own ${
1018
+ ranTasks === 1
1019
+ ? 'task again inside the command, beside that task: name it'
1020
+ : 'tasks again inside the command, beside those tasks: name them'
1021
+ } under dependsOn and drop ${ownRuns.length === 1 ? 'it' : 'them'} from the command`,
1022
+ )
1023
+ }
1024
+ const exec: Record<string, unknown> = { command }
1025
+ const npm = npmEnv(command, name, hooks.length > 0)
1026
+ if (Object.keys(npm.define).length > 0) exec['env'] = { define: npm.define }
1027
+ if (npm.readsManifest) readsManifest = true
1028
+ for (const v of npm.unset) {
1029
+ todos.push(
1030
+ `the script reads $${v}, which the package manager sets and vx does not: define it under exec.env.define or drop it`,
1031
+ )
1032
+ }
1033
+ const task: Record<string, unknown> = { exec }
1034
+ if (persistent) {
1035
+ exec['persistent'] = {}
1036
+ todos.push(PERSISTENT_TODO)
1037
+ }
1038
+ if (name === 'build') {
1039
+ task['dependsOn'] = ['^build']
1040
+ todos.push(cacheTodo(own))
1041
+ } else if (AFTER_BUILD.has(name) && hasBuild) {
1042
+ task['dependsOn'] = ['build']
1043
+ }
1044
+ tasks.push({ name, todos, task })
1045
+ }
1046
+ for (const [name, why] of refused) {
1047
+ tasks.push({
1048
+ name,
1049
+ todos: [
1050
+ `script ${JSON.stringify(name)} not migrated: its name ${why} — rename the script, or add it by hand under another name`,
1051
+ ],
1052
+ task: null,
1053
+ })
1054
+ }
1055
+ upstreamBuildOnWorker(tasks)
1056
+ if (tasks.length > 0) {
1057
+ const importLines = readsManifest ? [MANIFEST_IMPORT] : []
1058
+ projects.push({ name: meta.name, dir: meta.dir, importLines, tasks })
1059
+ }
1060
+ }
1061
+ const owner = metas[0] === undefined ? undefined : ownerOf(metas[0].dir, hookMemo)
1062
+ if (owner?.manager === 'berry' && usesPnp(owner.at)) notes.push(PNP_NOTE)
1063
+
1064
+ if (lifecycleBuilds.length > 0) {
1065
+ const [name, hook, command] = lifecycleBuilds[0]!
1066
+ const more = lifecycleBuilds.length - 1
1067
+ notes.push(
1068
+ `${lifecycleBuilds.length === 1 ? 'a package builds' : `${lifecycleBuilds.length} packages build`} only in a lifecycle script (\`${hook}: ${command}\` in ${name}${more > 0 ? ` and ${more} more` : ''}), which the package manager runs on pack or install and vx never runs: add a \`build\` script running it and run \`vx init\` again`,
1069
+ )
1070
+ }
1071
+ const lerna = metas.length === 1 ? lernaPackages(metas[0]!.dir) : undefined
1072
+ if (lerna !== undefined) notes.push(lernaNote(lerna))
1073
+ rootBinBuilds(projects, metas, root?.packageJson ?? outside)
1074
+ breakBuildCycles(projects, metas)
1075
+ pruneOrphanPersistentNotes(projects, PERSISTENT_TODO)
1076
+ return {
1077
+ headerNotes: [
1078
+ // Where to add one is the TODO's to say, when there is one: an Nx repo
1079
+ // whose only script is `codegen` read "`build` carries a TODO" over
1080
+ // "0 TODOs" (the first-five-minutes walk, 2026-09-28).
1081
+ 'each script became a task with its command verbatim; caching needs declared inputs and outputs, so no task got a cache block',
1082
+ ],
1083
+ projects,
1084
+ extraFiles: [],
1085
+ notes,
1086
+ }
1087
+ }
1088
+
1089
+ const DEP_FIELDS = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']
1090
+
1091
+ const depsOf = (manifest: object | undefined): Set<string> =>
1092
+ new Set(
1093
+ DEP_FIELDS.flatMap((f) => {
1094
+ const deps = (manifest as Record<string, unknown> | undefined)?.[f]
1095
+ return typeof deps === 'object' && deps !== null ? Object.keys(deps) : []
1096
+ }),
1097
+ )
1098
+
1099
+ /** The bin names a manifest declares: `bin: './cli.js'` is named after the package. */
1100
+ function binsOf(manifest: object, name: string): string[] {
1101
+ const bin = (manifest as Record<string, unknown>)['bin']
1102
+ if (typeof bin === 'string') return [name.replace(/^@[^/]+\//, '')]
1103
+ return typeof bin === 'object' && bin !== null ? Object.keys(bin) : []
1104
+ }
1105
+
1106
+ /**
1107
+ * A member's script that runs a workspace package's bin it gets only
1108
+ * through the root's dependencies (vite's playgrounds run `vite` from the
1109
+ * root's `vite: workspace:*`) has no `^build` edge to that package: 64 of
1110
+ * 70 playground builds ran before vite's own (Growth's trial 4, F9). Each
1111
+ * such task waits on that package's build too.
1112
+ */
1113
+ function rootBinBuilds(
1114
+ projects: GeneratedProject[],
1115
+ metas: readonly PackageMeta[],
1116
+ rootManifest: object | undefined,
1117
+ ): void {
1118
+ const fromRoot = depsOf(rootManifest)
1119
+ if (fromRoot.size === 0) return
1120
+ const builds = new Set(
1121
+ projects
1122
+ .filter((p) => p.tasks.some((t) => t.name === 'build' && t.task !== null))
1123
+ .map((p) => p.name),
1124
+ )
1125
+ const owner = new Map<string, string>()
1126
+ for (const m of metas)
1127
+ if (fromRoot.has(m.name) && builds.has(m.name))
1128
+ for (const b of binsOf(m.packageJson, m.name)) owner.set(b, m.name)
1129
+ if (owner.size === 0) return
1130
+ const metaOf = new Map(metas.map((m) => [m.name, m]))
1131
+ for (const p of projects) {
1132
+ const own = depsOf(metaOf.get(p.name)?.packageJson)
1133
+ for (const t of p.tasks) {
1134
+ const command = (t.task?.['exec'] as { command?: unknown } | undefined)?.command
1135
+ if (typeof command !== 'string') continue
1136
+ const needs = new Set(
1137
+ command
1138
+ .split(/[\s;&|()]+/)
1139
+ .map((w) => owner.get(w))
1140
+ .filter((n): n is string => n !== undefined && n !== p.name && !own.has(n)),
1141
+ )
1142
+ if (needs.size === 0) continue
1143
+ const deps = Array.isArray(t.task!['dependsOn']) ? (t.task!['dependsOn'] as string[]) : []
1144
+ t.task!['dependsOn'] = [...deps, ...[...needs].sort().map((n) => `${n}#build`)]
1145
+ }
1146
+ }
1147
+ }
1148
+
1149
+ /**
1150
+ * pnpm sorts a dependency cycle away and builds anyway; vx's `^build`
1151
+ * refuses it, so the config init wrote failed its first run (nuxt:
1152
+ * `@nuxt/nitro-server` devDepends on `nuxt`, which depends on it; vitest's
1153
+ * browser packages). A package in a cycle of builds waits, instead of on
1154
+ * `^build`, on each build outside its cycle that `^build` reaches (through
1155
+ * packages with none, as core walks it), with a TODO to order the cycle.
1156
+ */
1157
+ function breakBuildCycles(projects: GeneratedProject[], metas: readonly PackageMeta[]): void {
1158
+ const builds = new Set(
1159
+ projects
1160
+ .filter((p) => p.tasks.some((t) => t.name === 'build' && t.task !== null))
1161
+ .map((p) => p.name),
1162
+ )
1163
+ if (builds.size < 2) return
1164
+ // The edges the lockfile plugin init declares will give `^build`: the
1165
+ // manager installs what a manifest names, so a key naming a workspace
1166
+ // package is one. Core's graph reads no manifest; this mapper reads
1167
+ // package.json throughout.
1168
+ const names = new Set(metas.map((m) => m.name))
1169
+ const direct = new Map<string, string[]>()
1170
+ for (const m of metas) {
1171
+ const own = new Set<string>()
1172
+ for (const field of [
1173
+ m.packageJson.dependencies,
1174
+ m.packageJson.devDependencies,
1175
+ m.packageJson.optionalDependencies,
1176
+ ]) {
1177
+ if (field === null || typeof field !== 'object') continue
1178
+ for (const key of Object.keys(field)) if (key !== m.name && names.has(key)) own.add(key)
1179
+ }
1180
+ direct.set(m.name, [...own])
1181
+ }
1182
+ const directDeps = (name: string): readonly string[] => direct.get(name) ?? []
1183
+ const holders = new Map<string, string[]>()
1184
+ for (const name of builds) {
1185
+ const found = new Set<string>()
1186
+ const visited = new Set([name])
1187
+ const frontier = [...directDeps(name)]
1188
+ while (frontier.length > 0) {
1189
+ const at = frontier.pop()!
1190
+ if (visited.has(at)) continue
1191
+ visited.add(at)
1192
+ if (builds.has(at)) found.add(at)
1193
+ else frontier.push(...directDeps(at))
1194
+ }
1195
+ holders.set(name, [...found].sort())
1196
+ }
1197
+ for (const cycle of cyclesOf(holders)) {
1198
+ const members = new Set(cycle)
1199
+ for (const p of projects) {
1200
+ if (!members.has(p.name)) continue
1201
+ const at = p.tasks.find(
1202
+ (t) =>
1203
+ Array.isArray(t.task?.['dependsOn']) &&
1204
+ (t.task['dependsOn'] as string[]).includes('^build'),
1205
+ )
1206
+ if (at === undefined) continue
1207
+ const outside = holders
1208
+ .get(p.name)!
1209
+ .filter((h) => !members.has(h))
1210
+ .map((h) => `${h}#build`)
1211
+ at.task!['dependsOn'] = (at.task!['dependsOn'] as string[]).flatMap((d) =>
1212
+ d === '^build' ? outside : [d],
1213
+ )
1214
+ at.todos.push(
1215
+ `its package is in a dependency cycle (${cycle.join(', ')}), where \`^build\` would refuse the run — it waits on the builds outside the cycle; order the ones inside it by hand`,
1216
+ )
1217
+ }
1218
+ }
1219
+ }
1220
+
1221
+ /** The strongly connected components of more than one node (Tarjan), each sorted. */
1222
+ function cyclesOf(edges: ReadonlyMap<string, readonly string[]>): string[][] {
1223
+ const index = new Map<string, number>()
1224
+ const low = new Map<string, number>()
1225
+ const stack: string[] = []
1226
+ const onStack = new Set<string>()
1227
+ const out: string[][] = []
1228
+ const visit = (v: string): void => {
1229
+ index.set(v, index.size)
1230
+ low.set(v, index.get(v)!)
1231
+ stack.push(v)
1232
+ onStack.add(v)
1233
+ for (const w of edges.get(v) ?? []) {
1234
+ if (!index.has(w)) {
1235
+ visit(w)
1236
+ low.set(v, Math.min(low.get(v)!, low.get(w)!))
1237
+ } else if (onStack.has(w)) {
1238
+ low.set(v, Math.min(low.get(v)!, index.get(w)!))
1239
+ }
1240
+ }
1241
+ if (low.get(v) === index.get(v)) {
1242
+ const comp: string[] = []
1243
+ let w: string
1244
+ do {
1245
+ w = stack.pop()!
1246
+ onStack.delete(w)
1247
+ comp.push(w)
1248
+ } while (w !== v)
1249
+ if (comp.length > 1) out.push(comp.sort())
1250
+ }
1251
+ }
1252
+ for (const v of [...edges.keys()].sort()) if (!index.has(v)) visit(v)
1253
+ return out
1254
+ }
1255
+
1256
+ /** The member whose directory holds every other member's, if one does. */
1257
+ function workspaceRootOf(metas: readonly PackageMeta[]): PackageMeta | undefined {
1258
+ const outer = metas.reduce((a, b) => (b.dir.length < a.dir.length ? b : a))
1259
+ const prefix = outer.dir.endsWith(path.sep) ? outer.dir : outer.dir + path.sep
1260
+ return metas.every((m) => m === outer || m.dir.startsWith(prefix)) ? outer : undefined
1261
+ }