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