@vzn/vx 0.0.18 → 0.0.21

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 (121) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +99 -65
  3. package/package.json +10 -20
  4. package/src/bin.ts +12 -2
  5. package/src/cache/archive.ts +120 -18
  6. package/src/cache/cache.ts +313 -1324
  7. package/src/cache/chained-cache.ts +0 -8
  8. package/src/cache/config-evals.ts +89 -0
  9. package/src/cache/file-hashes.ts +262 -0
  10. package/src/cache/git-inputs.ts +637 -0
  11. package/src/cache/index.ts +16 -7
  12. package/src/cache/inputs.ts +246 -733
  13. package/src/cache/layer.ts +604 -0
  14. package/src/cache/layered-cache.ts +12 -17
  15. package/src/cache/output-index.ts +224 -0
  16. package/src/cache/policy.ts +84 -0
  17. package/src/cache/run-history.ts +147 -0
  18. package/src/cache/zstd.ts +160 -0
  19. package/src/cli/cache.ts +39 -11
  20. package/src/cli/completions.ts +104 -0
  21. package/src/cli/core-alias.ts +25 -0
  22. package/src/cli/help.ts +64 -36
  23. package/src/cli/index.ts +54 -15
  24. package/src/cli/info.ts +120 -91
  25. package/src/cli/init.ts +75 -0
  26. package/src/cli/last.ts +38 -6
  27. package/src/cli/lock.ts +2 -1
  28. package/src/cli/plan-format.ts +14 -3
  29. package/src/cli/plugin-commands.ts +15 -8
  30. package/src/cli/run.ts +84 -302
  31. package/src/cli/select.ts +332 -0
  32. package/src/cli/show.ts +144 -69
  33. package/src/cli/upgrade.ts +2 -1
  34. package/src/cli/watch.ts +647 -97
  35. package/src/cli/why.ts +44 -14
  36. package/src/cli/workspace-config.ts +126 -0
  37. package/src/config.ts +156 -136
  38. package/src/exec/env.ts +11 -0
  39. package/src/exec/executor.ts +11 -26
  40. package/src/exec/index.ts +6 -2
  41. package/src/{plugins/local-executor/index.ts → exec/local-executor.ts} +12 -16
  42. package/src/exec/runner.ts +57 -18
  43. package/src/exec/sandbox-binds.ts +182 -0
  44. package/src/exec/sandbox-paths.ts +50 -0
  45. package/src/exec/sandbox-runtime.ts +582 -370
  46. package/src/exec/sandbox-violations.ts +280 -0
  47. package/src/graph/index.ts +1 -9
  48. package/src/graph/scheduler.ts +158 -203
  49. package/src/graph/task-graph.ts +2 -2
  50. package/src/index.ts +97 -102
  51. package/src/orchestrator/admission.ts +137 -0
  52. package/src/orchestrator/deferred-outputs.ts +3 -0
  53. package/src/orchestrator/doctor.ts +255 -0
  54. package/src/orchestrator/events.ts +42 -8
  55. package/src/orchestrator/execute-task.ts +145 -554
  56. package/src/orchestrator/failure-mode.ts +169 -28
  57. package/src/orchestrator/framed-output.ts +41 -36
  58. package/src/orchestrator/history.ts +118 -98
  59. package/src/orchestrator/hit-restore.ts +221 -0
  60. package/src/orchestrator/index.ts +35 -4
  61. package/src/orchestrator/lockfile-claim.ts +281 -0
  62. package/src/orchestrator/logger.ts +13 -22
  63. package/src/orchestrator/metrics.ts +53 -20
  64. package/src/orchestrator/miss-save.ts +169 -0
  65. package/src/orchestrator/options.ts +34 -31
  66. package/src/orchestrator/persistent.ts +79 -0
  67. package/src/orchestrator/placement.ts +182 -0
  68. package/src/orchestrator/plugin-host.ts +195 -38
  69. package/src/orchestrator/plugin.ts +162 -9
  70. package/src/orchestrator/prepare.ts +77 -132
  71. package/src/orchestrator/projects.ts +259 -0
  72. package/src/orchestrator/remote-prefetch.ts +1 -1
  73. package/src/orchestrator/run-artifacts.ts +25 -3
  74. package/src/orchestrator/run-context.ts +1 -1
  75. package/src/orchestrator/run-records.ts +149 -0
  76. package/src/orchestrator/run-report.ts +2 -2
  77. package/src/orchestrator/run.ts +246 -597
  78. package/src/orchestrator/sandbox-request.ts +243 -0
  79. package/src/orchestrator/save-lane.ts +47 -0
  80. package/src/orchestrator/signals.ts +111 -0
  81. package/src/orchestrator/stable-keys.ts +60 -3
  82. package/src/orchestrator/status-line.ts +2 -1
  83. package/src/orchestrator/summary.ts +68 -25
  84. package/src/orchestrator/task-hash.ts +20 -18
  85. package/src/orchestrator/task-log-buffer.ts +8 -11
  86. package/src/orchestrator/telemetry-host.ts +11 -1
  87. package/src/orchestrator/telemetry.ts +3 -17
  88. package/src/util/cgroup.ts +160 -0
  89. package/src/util/edit-distance.ts +49 -0
  90. package/src/util/index.ts +11 -3
  91. package/src/util/paths.ts +23 -2
  92. package/src/util/settle.ts +19 -0
  93. package/src/util/verbs.ts +39 -0
  94. package/src/workspace/affected.ts +111 -11
  95. package/src/workspace/config-cache.ts +11 -2
  96. package/src/workspace/config-eval.ts +11 -4
  97. package/src/workspace/config-imports.ts +5 -4
  98. package/src/workspace/config-schema.ts +837 -0
  99. package/src/workspace/filter.ts +43 -6
  100. package/src/workspace/fingerprint.ts +37 -3
  101. package/src/workspace/index.ts +30 -3
  102. package/src/workspace/lockfile.ts +12 -7
  103. package/src/{cli → workspace}/migrate-scripts.ts +26 -19
  104. package/src/{cli/migrate.ts → workspace/migration.ts} +133 -124
  105. package/src/workspace/package-graph.ts +98 -28
  106. package/src/workspace/project-loader.ts +76 -746
  107. package/src/workspace/workspace.ts +98 -13
  108. package/plugins/local-cache/index.ts +0 -2
  109. package/plugins/local-executor/index.ts +0 -2
  110. package/plugins/schedule-history/index.ts +0 -2
  111. package/src/cache/cas-backend.ts +0 -115
  112. package/src/cache/digest.ts +0 -32
  113. package/src/cli/migrate-emit.ts +0 -21
  114. package/src/cli/migrate-nx.ts +0 -473
  115. package/src/cli/migrate-turbo.ts +0 -401
  116. package/src/cli/prune.ts +0 -291
  117. package/src/orchestrator/missing-plugin.ts +0 -9
  118. package/src/orchestrator/resources.ts +0 -45
  119. package/src/orchestrator/verify.ts +0 -174
  120. package/src/plugins/local-cache/index.ts +0 -11
  121. package/src/plugins/schedule-history/index.ts +0 -109
@@ -1,42 +1,47 @@
1
- // `vx migrate [--dry] [--force]` — generate per-package vx.config.ts
2
- // from an existing Turbo or Nx setup. Source auto-detect: turbo.json →
3
- // Turbo; .nx/workspace-data/project-graph.json → Nx (the resolved
4
- // snapshot). Mappers live in migrate-turbo.ts / migrate-nx.ts and
5
- // return an IR; this file owns detection, TS emission, the overwrite
6
- // guard, and the final report.
1
+ // The migration seam: what any adoption tool needs once it has a plan —
2
+ // the plan's shape, TS emission, the overwrite guard, the writes and the
3
+ // report. `vx init` (package.json scripts, in core) and `@vzn/vx-migrate`
4
+ // (Turbo, Nx) share it, so a generated config reads the same whichever
5
+ // tool wrote it. Core knows no source format here: a mapper returns a
6
+ // `MigrationPlan` and this file does the rest.
7
7
 
8
8
  import path from 'node:path'
9
9
  import { relPosix, UserError } from '../util/index.js'
10
- import { findWorkspaceRoot, listProjects, loadWorkspace } from '../workspace/index.js'
11
- import { quote } from './migrate-emit.js'
12
- import { migrateNx } from './migrate-nx.js'
13
- import { migrateScripts } from './migrate-scripts.js'
14
- import { migrateTurbo } from './migrate-turbo.js'
10
+ import type { ProjectMeta } from './workspace.js'
15
11
 
16
- export interface MigrateArgs {
17
- dry: boolean
18
- force: boolean
19
- from?: 'turbo' | 'nx' | 'scripts'
20
- error?: string
12
+ /**
13
+ * Escape an arbitrary string into a single-quoted TS literal. Escapes
14
+ * backslash + quote AND raw newlines/CR — a value with an embedded newline
15
+ * (legal JSON, e.g. a script `"echo a\necho b"`, or a glob with a `'`) would
16
+ * otherwise splice into a single-quoted literal as an unterminated / malformed
17
+ * string that fails to load (generated files must round-trip through the
18
+ * loader).
19
+ */
20
+ export function quoteTsLiteral(s: string): string {
21
+ return `'${s
22
+ .replaceAll('\\', '\\\\')
23
+ .replaceAll("'", "\\'")
24
+ .replaceAll('\n', '\\n')
25
+ .replaceAll('\r', '\\r')}'`
21
26
  }
22
27
 
23
- export function parseMigrateArgs(args: readonly string[]): MigrateArgs {
24
- const out: MigrateArgs = { dry: false, force: false }
25
- for (let i = 0; i < args.length; i++) {
26
- const a = args[i]
27
- if (a === '--dry') out.dry = true
28
- else if (a === '--force') out.force = true
29
- else if (a === '--from' || a?.startsWith('--from=')) {
30
- const v = a === '--from' ? args[++i] : a.slice('--from='.length)
31
- if (v !== 'turbo' && v !== 'nx' && v !== 'scripts') {
32
- return { ...out, error: `--from must be turbo, nx or scripts` }
33
- }
34
- out.from = v
35
- } else if (a?.startsWith('-')) return { ...out, error: `unknown flag: ${a}` }
36
- else return { ...out, error: `unexpected argument: ${a}` }
37
- }
38
- return out
39
- }
28
+ /**
29
+ * Task names that conventionally never exit. Every mapper guesses the same
30
+ * way from a name — a source that SAYS a task is long-running (turbo's
31
+ * `persistent: true`, an Nx dev-server executor) is believed instead.
32
+ */
33
+ export const PERSISTENT_TASK_NAMES: ReadonlySet<string> = new Set([
34
+ 'dev',
35
+ 'start',
36
+ 'serve',
37
+ 'watch',
38
+ 'preview',
39
+ ])
40
+
41
+ /** The one wording every mapper emits for a task it made persistent. */
42
+ export const PERSISTENT_TODO =
43
+ 'persistent task — set persistent.readyWhen (regex matched against output) so ' +
44
+ 'dependents unblock on readiness, and consider exec.timeout to bound the wait'
40
45
 
41
46
  /** Verbatim TS expression spliced into a generated array (preset spreads). */
42
47
  export interface RawExpr {
@@ -71,65 +76,47 @@ export interface MigrationPlan {
71
76
  notes: string[]
72
77
  }
73
78
 
74
- export async function migrateCmd(
75
- args: readonly string[],
76
- opts: { init?: boolean } = {},
77
- ): Promise<number> {
78
- const parsed = parseMigrateArgs(args)
79
- if (parsed.error) {
80
- process.stderr.write(`vx migrate: ${parsed.error}\n`)
81
- return 1
82
- }
83
- const root = await findWorkspaceRoot(process.cwd())
84
- const metas = await listProjects(await loadWorkspace(root))
79
+ export interface ApplyMigrationArgs {
80
+ root: string
81
+ metas: readonly ProjectMeta[]
82
+ plan: MigrationPlan
83
+ /** Named in the report and the generated header, e.g. `turbo.json`. */
84
+ source: string
85
+ /** The command the user typed, e.g. `vx init` — named in the report. */
86
+ verb: string
87
+ dry: boolean
88
+ force: boolean
89
+ /**
90
+ * `vx init` on a workspace with no scripts still has a job (the workspace
91
+ * file, and a worked example); a migration with nothing to convert is an
92
+ * error. Default false.
93
+ */
94
+ init?: boolean
95
+ /** Report lines printed under the source line (e.g. "turbo.json found and not read"). */
96
+ notes?: readonly string[]
97
+ /**
98
+ * `ts` (default) writes `vx.config.ts` with the type-only import and
99
+ * `satisfies`; `mjs` writes `vx.config.mjs` — the same object, untyped —
100
+ * for a package whose own `tsc --build` includes every `.ts` under it
101
+ * and compiled the config into its dist (TanStack/query, 2026-09-11).
102
+ */
103
+ format?: MigrationFormat
104
+ }
85
105
 
86
- const hasTurbo = await Bun.file(path.join(root, 'turbo.json')).exists()
87
- const graphRel = path.join('.nx', 'workspace-data', 'project-graph.json')
88
- const hasGraph = await Bun.file(path.join(root, graphRel)).exists()
89
- const hasNxJson = await Bun.file(path.join(root, 'nx.json')).exists()
106
+ export type MigrationFormat = 'ts' | 'mjs'
90
107
 
91
- // Evaluating teams routinely have both runners checked in — never
92
- // ask anyone to delete anything; --from disambiguates.
93
- if (parsed.from === undefined && hasTurbo && (hasGraph || hasNxJson)) {
94
- throw new UserError(
95
- 'both turbo.json and an nx workspace are present — pass --from turbo or --from nx',
96
- )
97
- }
98
- if (parsed.from === 'turbo' && !hasTurbo) {
99
- throw new UserError('--from turbo, but no turbo.json at the workspace root')
100
- }
101
-
102
- let source: string
103
- let plan: MigrationPlan
104
- if (parsed.from === 'scripts') {
105
- source = 'package.json scripts'
106
- plan = migrateScripts(metas)
107
- } else if (parsed.from === 'nx' || (parsed.from === undefined && !hasTurbo)) {
108
- if (hasGraph) {
109
- source = '.nx/workspace-data/project-graph.json'
110
- plan = await migrateNx(root, metas)
111
- } else if (hasNxJson || parsed.from === 'nx') {
112
- // Modern Nx stores the graph in SQLite — the JSON snapshot only
113
- // exists when exported explicitly.
114
- throw new UserError(
115
- 'no resolved Nx graph found — export one with ' +
116
- '`nx graph --file=.nx/workspace-data/project-graph.json`, then re-run vx migrate',
117
- )
118
- } else {
119
- // A workspace from nowhere: the scripts are the source (`vx init`).
120
- source = 'package.json scripts'
121
- plan = migrateScripts(metas)
122
- }
123
- } else {
124
- source = 'turbo.json'
125
- plan = await migrateTurbo(root, metas)
126
- }
127
- // `vx init` on a workspace with no scripts still has a job: the
128
- // workspace file, which every run needs, and a worked example of the
129
- // config the user writes next. `vx migrate` with nothing to convert is
130
- // an error: the user asked for a conversion that has no input.
108
+ /**
109
+ * Render a plan to files, refuse to overwrite without `force`, write (or
110
+ * print, under `dry`), and report. Returns the process exit code.
111
+ */
112
+ export async function applyMigration(args: ApplyMigrationArgs): Promise<number> {
113
+ const { root, metas, plan, source, verb, dry, force } = args
114
+ const init = args.init === true
115
+ const format: MigrationFormat = args.format ?? 'ts'
116
+ const configName = `vx.config.${format}`
117
+ const workspaceName = `vx.workspace.${format}`
131
118
  const empty = plan.projects.length === 0
132
- if (empty && opts.init !== true) {
119
+ if (empty && !init) {
133
120
  throw new UserError(
134
121
  `nothing to migrate: no ${source === 'package.json scripts' ? 'package.json scripts in any workspace member' : 'tasks in ' + source}`,
135
122
  )
@@ -138,8 +125,12 @@ export async function migrateCmd(
138
125
  const files: { relPath: string; abs: string; contents: string }[] = []
139
126
  for (const p of plan.projects) {
140
127
  if (p.tasks.length === 0) continue
141
- const abs = path.join(p.dir, 'vx.config.ts')
142
- files.push({ relPath: relPosix(root, abs), abs, contents: renderConfigFile(source, p) })
128
+ const abs = path.join(p.dir, configName)
129
+ files.push({
130
+ relPath: relPosix(root, abs),
131
+ abs,
132
+ contents: renderConfigFile(source, p, verb, format),
133
+ })
143
134
  }
144
135
  for (const f of plan.extraFiles) {
145
136
  files.push({ relPath: f.relPath, abs: path.join(root, f.relPath), contents: f.contents })
@@ -155,11 +146,11 @@ export async function migrateCmd(
155
146
  )
156
147
  ).some(Boolean)
157
148
  if (!hasWorkspaceFile) {
158
- const abs = path.join(root, 'vx.workspace.ts')
159
- files.push({ relPath: relPosix(root, abs), abs, contents: WORKSPACE_FILE })
149
+ const abs = path.join(root, workspaceName)
150
+ files.push({ relPath: relPosix(root, abs), abs, contents: workspaceFile(format) })
160
151
  }
161
152
 
162
- if (!parsed.dry && !parsed.force) {
153
+ if (!dry && !force) {
163
154
  const conflicts = new Set<string>()
164
155
  // A discovered project with ANY existing vx config (.ts/.mjs/.js) — refuse
165
156
  // so we never shadow a hand-written config with a fresh .ts.
@@ -183,7 +174,7 @@ export async function migrateCmd(
183
174
  }
184
175
  }
185
176
 
186
- if (parsed.dry) {
177
+ if (dry) {
187
178
  for (const f of files) {
188
179
  process.stdout.write(`── ${f.relPath} ──\n${f.contents}\n`)
189
180
  }
@@ -202,20 +193,22 @@ export async function migrateCmd(
202
193
  const report: string[] = []
203
194
  if (empty) {
204
195
  report.push(
205
- 'vx init: no package.json scripts to turn into tasks.',
196
+ `${verb}: no package.json scripts to turn into tasks.`,
206
197
  hasWorkspaceFile
207
- ? 'vx.workspace.ts already exists.'
208
- : parsed.dry
209
- ? 'would write vx.workspace.ts (dry run, nothing written).'
210
- : 'wrote vx.workspace.ts.',
211
- 'Declare tasks in a vx.config.ts beside a package.json — your own command, for example:',
198
+ ? `${workspaceName} already exists.`
199
+ : dry
200
+ ? `would write ${workspaceName} (dry run, nothing written).`
201
+ : `wrote ${workspaceName}.`,
202
+ `Declare tasks in a ${configName} beside a package.json — your own command, for example:`,
212
203
  '',
213
- ...EXAMPLE_CONFIG.trimEnd()
204
+ ...exampleConfig(format)
205
+ .trimEnd()
214
206
  .split('\n')
215
207
  .map((l) => ` ${l}`),
216
208
  )
217
209
  } else {
218
- report.push(`vx migrate: ${source} → vx.config.ts`)
210
+ report.push(`${verb}: ${source} → ${configName}`)
211
+ for (const n of args.notes ?? []) report.push(`note: ${n}`)
219
212
  for (const n of plan.headerNotes) report.push(`note: ${n}`)
220
213
  report.push(
221
214
  '',
@@ -224,7 +217,7 @@ export async function migrateCmd(
224
217
  )
225
218
  for (const line of todoList) report.push(` ${line}`)
226
219
  report.push(...plan.notes)
227
- report.push(parsed.dry ? 'files (dry run, nothing written):' : 'files written:')
220
+ report.push(dry ? 'files (dry run, nothing written):' : 'files written:')
228
221
  for (const f of files) report.push(` ${f.relPath}`)
229
222
  }
230
223
  const firstTask =
@@ -236,9 +229,7 @@ export async function migrateCmd(
236
229
  return 0
237
230
  }
238
231
 
239
- const EXAMPLE_CONFIG = `import type { ProjectConfig } from '@vzn/vx'
240
-
241
- export default {
232
+ const EXAMPLE_BODY = `export default {
242
233
  tasks: {
243
234
  build: {
244
235
  exec: { command: 'tsc -b' },
@@ -246,19 +237,31 @@ export default {
246
237
  cache: { inputs: { files: ['src/**'] }, outputs: { files: ['dist/**'] } },
247
238
  },
248
239
  },
249
- } satisfies ProjectConfig
250
- `
240
+ }`
241
+
242
+ function exampleConfig(format: MigrationFormat): string {
243
+ return format === 'ts'
244
+ ? `import type { ProjectConfig } from '@vzn/vx'\n\n${EXAMPLE_BODY} satisfies ProjectConfig\n`
245
+ : `${EXAMPLE_BODY}\n`
246
+ }
251
247
 
252
248
  // ─── TS emission ──────────────────────────────────────────────────────
253
249
 
254
- const WORKSPACE_FILE = `import { defineWorkspace } from '@vzn/vx'
255
- import { localExecutorPlugin } from '@vzn/vx/plugins/local-executor'
256
- import { localCachePlugin } from '@vzn/vx/plugins/local-cache'
250
+ // Type-only import + `satisfies`, like the project configs: a runtime
251
+ // `import { defineWorkspace } from '@vzn/vx'` loads a SECOND copy of core
252
+ // into every run — measured 2026-09-09 at ~17 ms on a two-package
253
+ // workspace, a fifth of the whole run — for an identity function.
254
+ const WORKSPACE_BODY = `// Plugins are consulted in this order; running here and caching in
255
+ // .vx/cache are the floor under all of them, so an empty list is a
256
+ // complete workspace. Add a remote cache, a remote executor or telemetry
257
+ // as one entry each — those imports are the runtime ones.
258
+ export default { plugins: [] }`
257
259
 
258
- // Nothing runs that is not declared here. Put a remote executor or cache
259
- // plugin BEFORE the local one to prefer it.
260
- export default defineWorkspace({ plugins: [localExecutorPlugin(), localCachePlugin()] })
261
- `
260
+ function workspaceFile(format: MigrationFormat): string {
261
+ return format === 'ts'
262
+ ? `import type { WorkspaceConfig } from '@vzn/vx'\n\n${WORKSPACE_BODY} satisfies WorkspaceConfig\n`
263
+ : `${WORKSPACE_BODY}\n`
264
+ }
262
265
 
263
266
  const IDENT = /^[A-Za-z_$][\w$]*$/
264
267
 
@@ -272,34 +275,40 @@ function renderValue(v: unknown, indent: string): string {
272
275
  // with a raw stack mid-write. Mappers no longer produce a null, but a
273
276
  // literal is a readable thing to leave behind if one ever does.
274
277
  if (v === null) return 'null'
275
- if (typeof v === 'string') return quote(v)
278
+ if (typeof v === 'string') return quoteTsLiteral(v)
276
279
  if (typeof v === 'number' || typeof v === 'boolean') return String(v)
277
280
  if (Array.isArray(v)) return `[${v.map((x) => renderValue(x, indent)).join(', ')}]`
278
281
  const entries = Object.entries(v as Record<string, unknown>).filter(([, x]) => x !== undefined)
279
282
  if (entries.length === 0) return '{}'
280
283
  const inner = `${indent} `
281
284
  const body = entries.map(
282
- ([k, x]) => `${inner}${IDENT.test(k) ? k : quote(k)}: ${renderValue(x, inner)},`,
285
+ ([k, x]) => `${inner}${IDENT.test(k) ? k : quoteTsLiteral(k)}: ${renderValue(x, inner)},`,
283
286
  )
284
287
  return `{\n${body.join('\n')}\n${indent}}`
285
288
  }
286
289
 
287
- function renderConfigFile(source: string, p: GeneratedProject): string {
290
+ function renderConfigFile(
291
+ source: string,
292
+ p: GeneratedProject,
293
+ verb: string,
294
+ format: MigrationFormat,
295
+ ): string {
288
296
  // A type-only import: the editor type-checks against the installed
289
297
  // package, and Bun erases it, so the config loads in a workspace that
290
- // runs the vx binary without the package installed.
298
+ // runs the vx binary without the package installed. `mjs` is the same
299
+ // object with nothing to erase.
291
300
  const lines: string[] = [
292
- `// Generated by \`vx migrate\` from ${source}. Review the TODO(vx-migrate) comments.`,
293
- "import type { ProjectConfig } from '@vzn/vx'",
301
+ `// Generated by \`${verb}\` from ${source}. Review the TODO(vx-migrate) comments.`,
294
302
  ]
303
+ if (format === 'ts') lines.push("import type { ProjectConfig } from '@vzn/vx'")
295
304
  if (p.importLines.length > 0) lines.push(...p.importLines)
296
305
  lines.push('', 'export default {', ' tasks: {')
297
306
  for (const t of p.tasks) {
298
307
  for (const todo of t.todos) lines.push(` // TODO(vx-migrate): ${todo}`)
299
308
  if (t.task === null) continue // skipped target — the TODO above explains
300
- const key = IDENT.test(t.name) ? t.name : quote(t.name)
309
+ const key = IDENT.test(t.name) ? t.name : quoteTsLiteral(t.name)
301
310
  lines.push(` ${key}: ${renderValue(t.task, ' ')},`)
302
311
  }
303
- lines.push(' },', '} satisfies ProjectConfig', '')
312
+ lines.push(' },', format === 'ts' ? '} satisfies ProjectConfig' : '}', '')
304
313
  return lines.join('\n')
305
314
  }
@@ -1,40 +1,108 @@
1
1
  import type { ProjectMeta } from './workspace.js'
2
2
 
3
3
  export interface PackageGraph {
4
- /** Immediate workspace deps (sorted). */
4
+ /** Immediate workspace deps the package builds AFTER (sorted): every bucket but peers. */
5
5
  directDeps: (name: string) => string[]
6
- /** All transitive workspace deps for each project. */
6
+ /** All transitive workspace deps a change can reach the package through — peers included. */
7
7
  transitiveDeps: (name: string) => string[]
8
- /** All transitive workspace dependents (packages that depend on the named one). */
8
+ /** All transitive workspace dependents (packages a change here can reach) — peers included. */
9
9
  transitiveDependents: (name: string) => string[]
10
10
  }
11
11
 
12
- export function buildPackageGraph(projects: ProjectMeta[]): PackageGraph {
12
+ /**
13
+ * `taskEdges`: project → the projects its tasks name in a cross-project
14
+ * `dependsOn` (`e2e` → `app` from `dependsOn: ['app#build']`). A dependent
15
+ * the manifest does not know but the task graph does; without it
16
+ * `--filter '...app'` never selected `e2e`, and a CI that runs "what
17
+ * changed and everything depending on it" silently left it out
18
+ * (2026-09-10). The `^task` walk still reads `directDeps`, which carries
19
+ * both — a task edge IS a dependency.
20
+ */
21
+ export function buildPackageGraph(
22
+ projects: ProjectMeta[],
23
+ taskEdges?: ReadonlyMap<string, readonly string[]>,
24
+ ): PackageGraph {
13
25
  const byName = new Map<string, ProjectMeta>()
14
26
  for (const p of projects) byName.set(p.name, p)
15
27
 
28
+ // Two adjacencies. ORDER (`directDeps`, the `^task` walk) is what the
29
+ // package imports at build time: dependencies, devDependencies,
30
+ // optionalDependencies, the task edges, and a workspace peer that does
31
+ // not close a cycle. REACH (the transitive closures `--filter pkg...`
32
+ // and `--affected` read) is ORDER plus every workspace peer.
33
+ //
34
+ // A peer on a workspace sibling is an import that resolves to that
35
+ // sibling's build: every package manager links or hoists it (pnpm's
36
+ // `linkWorkspacePackages`, the hoisted root of bun/npm/yarn), and
37
+ // TanStack/router's `router-devtools-core` peers on `router-core` and
38
+ // type-checks against its `dist` — Nx orders `^build` on the peer,
39
+ // vx built the devtools first and failed (2026-09-11). But peers are
40
+ // the one bucket that routinely cycles (medusa's test-utils peers on
41
+ // medusa, which dev-depends on it through analytics; Turbo, which
42
+ // reads no peers, runs it; an unconditional order edge made `^build`
43
+ // a task cycle, 2026-09-11), so a peer edge that would close a cycle
44
+ // through the order graph is reach only: the consumer above provides
45
+ // that peer. Peers are tried in (package, peer) name order, so which
46
+ // edge of a two-peer cycle stays is stable across runs.
16
47
  const directDeps = new Map<string, string[]>()
17
- for (const p of projects) {
18
- const seen = new Set<string>()
19
- for (const field of [
20
- 'dependencies',
21
- 'devDependencies',
22
- 'peerDependencies',
23
- 'optionalDependencies',
24
- ] as const) {
48
+ const reachDeps = new Map<string, string[]>()
49
+ const order = new Map<string, Set<string>>()
50
+ const peers = new Map<string, Set<string>>()
51
+ const sorted = [...projects].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
52
+ for (const p of sorted) {
53
+ const own = new Set<string>()
54
+ for (const name of taskEdges?.get(p.name) ?? []) {
55
+ if (name !== p.name && byName.has(name)) own.add(name)
56
+ }
57
+ const add = (
58
+ field: 'dependencies' | 'devDependencies' | 'peerDependencies' | 'optionalDependencies',
59
+ into: Set<string>,
60
+ ) => {
25
61
  const obj = p.packageJson[field]
26
- if (!obj) continue
62
+ if (!obj) return
27
63
  for (const name of Object.keys(obj)) {
28
- if (name === p.name) continue
29
- if (byName.has(name)) seen.add(name)
64
+ if (name !== p.name && byName.has(name)) into.add(name)
65
+ }
66
+ }
67
+ add('dependencies', own)
68
+ add('devDependencies', own)
69
+ add('optionalDependencies', own)
70
+ order.set(p.name, own)
71
+ const peer = new Set<string>()
72
+ add('peerDependencies', peer)
73
+ peers.set(p.name, peer)
74
+ }
75
+ // `to` reaches `from` through the order edges so far ⇒ from → to
76
+ // would close a cycle.
77
+ const reaches = (start: string, goal: string): boolean => {
78
+ const seen = new Set<string>([start])
79
+ const stack = [start]
80
+ while (stack.length > 0) {
81
+ for (const next of order.get(stack.pop()!) ?? []) {
82
+ if (next === goal) return true
83
+ if (!seen.has(next)) {
84
+ seen.add(next)
85
+ stack.push(next)
86
+ }
30
87
  }
31
88
  }
32
- directDeps.set(p.name, [...seen].sort())
89
+ return false
90
+ }
91
+ for (const p of sorted) {
92
+ const own = order.get(p.name)!
93
+ for (const peer of [...peers.get(p.name)!].sort()) {
94
+ if (!own.has(peer) && !reaches(peer, p.name)) own.add(peer)
95
+ }
96
+ }
97
+ for (const p of sorted) {
98
+ const own = order.get(p.name)!
99
+ directDeps.set(p.name, [...own].sort())
100
+ reachDeps.set(p.name, [...new Set([...own, ...peers.get(p.name)!])].sort())
33
101
  }
34
102
 
35
103
  // Reverse adjacency: who declares X as a workspace dep.
36
104
  const directDependents = new Map<string, string[]>()
37
- for (const [name, deps] of directDeps) {
105
+ for (const [name, deps] of reachDeps) {
38
106
  for (const d of deps) {
39
107
  const arr = directDependents.get(d)
40
108
  if (arr) arr.push(name)
@@ -110,22 +178,24 @@ export function buildPackageGraph(projects: ProjectMeta[]): PackageGraph {
110
178
  return [...seen].sort()
111
179
  }
112
180
 
181
+ // The closures are built on the FIRST query, not here: an unscoped run
182
+ // seeds every project and never asks for a transitive set, and neither
183
+ // does a `^task` walk (it reads `directDeps`). Building both closures
184
+ // eagerly was 12 ms of a 240 ms warm run at 1000 projects × 30 deps
185
+ // (profiled 2026-09-09) for answers nobody read.
113
186
  function makeAccessor(edges: Map<string, string[]>): (name: string) => string[] {
114
- const closures = bitsetClosures(edges)
115
187
  const memo = new Map<string, string[]>()
116
- if (closures === null) {
117
- return (name) => {
118
- const cached = memo.get(name)
119
- if (cached) return cached
120
- const result = reachableFrom(name, edges)
121
- memo.set(name, result)
122
- return result
123
- }
124
- }
188
+ let closures: Uint32Array | null | undefined
125
189
  const words = (names.length + 31) >>> 5
126
190
  return (name) => {
127
191
  const cached = memo.get(name)
128
192
  if (cached) return cached
193
+ if (closures === undefined) closures = bitsetClosures(edges)
194
+ if (closures === null) {
195
+ const result = reachableFrom(name, edges)
196
+ memo.set(name, result)
197
+ return result
198
+ }
129
199
  const i = index.get(name)
130
200
  if (i === undefined) return []
131
201
  const base = i * words
@@ -145,7 +215,7 @@ export function buildPackageGraph(projects: ProjectMeta[]): PackageGraph {
145
215
 
146
216
  return {
147
217
  directDeps: (name) => directDeps.get(name) ?? [],
148
- transitiveDeps: makeAccessor(directDeps),
218
+ transitiveDeps: makeAccessor(reachDeps),
149
219
  transitiveDependents: makeAccessor(directDependents),
150
220
  }
151
221
  }