@vzn/vx 0.0.50 → 0.0.51

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 (54) hide show
  1. package/README.md +38 -31
  2. package/package.json +5 -5
  3. package/src/bin.ts +1 -1
  4. package/src/cache/archive.ts +18 -6
  5. package/src/cache/cache.ts +150 -67
  6. package/src/cache/chained-cache.ts +9 -2
  7. package/src/cache/file-hashes.ts +32 -3
  8. package/src/cache/git-inputs.ts +96 -11
  9. package/src/cache/index.ts +1 -0
  10. package/src/cache/layer.ts +28 -3
  11. package/src/cache/layered-cache.ts +7 -2
  12. package/src/cache/output-index.ts +16 -7
  13. package/src/cache/run-history.ts +5 -1
  14. package/src/cache/zstd.ts +40 -8
  15. package/src/cli/cache.ts +35 -9
  16. package/src/cli/completions.ts +10 -7
  17. package/src/cli/help.ts +48 -4
  18. package/src/cli/index.ts +5 -3
  19. package/src/cli/info.ts +1 -1
  20. package/src/cli/plugin-commands.ts +10 -2
  21. package/src/cli/run.ts +1 -0
  22. package/src/cli/select.ts +29 -2
  23. package/src/cli/upgrade.ts +110 -22
  24. package/src/cli/watch.ts +35 -18
  25. package/src/config.ts +18 -5
  26. package/src/exec/executor.ts +3 -2
  27. package/src/exec/index.ts +1 -1
  28. package/src/exec/runner.ts +102 -24
  29. package/src/exec/sandbox-binds.ts +1 -1
  30. package/src/exec/sandbox-runtime.ts +149 -40
  31. package/src/exec/sandbox-violations.ts +42 -10
  32. package/src/graph/scheduler.ts +11 -5
  33. package/src/graph/task-graph.ts +96 -12
  34. package/src/orchestrator/admission.ts +72 -17
  35. package/src/orchestrator/execute-task.ts +39 -25
  36. package/src/orchestrator/hit-restore.ts +7 -1
  37. package/src/orchestrator/miss-save.ts +29 -1
  38. package/src/orchestrator/placement.ts +23 -3
  39. package/src/orchestrator/plugin-host.ts +1 -1
  40. package/src/orchestrator/plugin.ts +9 -1
  41. package/src/orchestrator/prepare.ts +1 -0
  42. package/src/orchestrator/run.ts +75 -12
  43. package/src/orchestrator/sandbox-request.ts +19 -10
  44. package/src/orchestrator/summary.ts +26 -15
  45. package/src/orchestrator/task-hash.ts +7 -2
  46. package/src/orchestrator/telemetry.ts +14 -4
  47. package/src/util/paths.ts +16 -9
  48. package/src/workspace/affected.ts +201 -38
  49. package/src/workspace/config-eval.ts +4 -1
  50. package/src/workspace/config-schema.ts +70 -10
  51. package/src/workspace/fingerprint.ts +6 -0
  52. package/src/workspace/migration.ts +1 -1
  53. package/src/workspace/project-loader.ts +53 -1
  54. package/src/workspace/workspace.ts +54 -14
@@ -326,23 +326,34 @@ export function formatRunSummary(
326
326
  }
327
327
 
328
328
  /**
329
- * Post-summary section naming the tasks a shutdown signal killed. An aborted
330
- * task did no work, so it is in no tally bucket and no history row — but the
331
- * run still exits non-zero, and without this the user reads a red exit over a
332
- * fully green summary that names nothing. The interactive Ctrl-C path never
333
- * gets here (the signal handler exits before any outcome lands), so this only
334
- * ever prints for a run that reached its summary WITH a task killed by some
335
- * other signal — an external `kill`, a supervisor, a self-terminating child.
336
- * Empty when nothing aborted.
329
+ * Post-summary section naming the tasks a shutdown signal killed, and those
330
+ * the stop reached before they ran. An aborted task did no work, so it is in
331
+ * no tally bucket and no history row — but the run still exits non-zero, and
332
+ * without this the user reads a red exit over a fully green summary that
333
+ * names nothing. Empty when nothing aborted.
337
334
  */
338
335
  export function formatAbortedSection(outcomes: readonly TaskOutcome[]): string[] {
339
- const aborted = outcomes.filter((o) => o.status === 'aborted')
340
- if (aborted.length === 0) return []
341
- const lines = [
342
- '',
343
- ` Aborted: ${aborted.length} task${aborted.length === 1 ? '' : 's'} killed by a shutdown signal — not counted above`,
344
- ]
345
- for (const o of aborted) lines.push(` ✗ ${o.node.id} — exit ${o.exitCode}, nothing cached`)
336
+ // The scheduler marks what the stop reached before dispatch `aborted` too,
337
+ // with an invented exit 1: listed as killed, a task that never ran read as
338
+ // one the signal cut short (item 1062). Only a started one carries a
339
+ // wall-clock start. A group is no task here, as in the skipped section.
340
+ const aborted = outcomes.filter((o) => o.status === 'aborted' && !isGroupTask(o.node))
341
+ const killed = aborted.filter((o) => o.wallclockStartNs !== undefined)
342
+ const unstarted = aborted.filter((o) => o.wallclockStartNs === undefined)
343
+ const tasks = (n: number): string => `${n} task${n === 1 ? '' : 's'}`
344
+ const lines: string[] = []
345
+ if (killed.length > 0) {
346
+ lines.push(
347
+ '',
348
+ ` Aborted: ${tasks(killed.length)} killed by a shutdown signal — not counted above`,
349
+ )
350
+ for (const o of killed) lines.push(` ✗ ${o.node.id} — exit ${o.exitCode}, nothing cached`)
351
+ }
352
+ if (unstarted.length > 0) {
353
+ const they = unstarted.length === 1 ? 'it' : 'they'
354
+ lines.push('', ` Not started: ${tasks(unstarted.length)} the run stopped before ${they} ran`)
355
+ for (const o of unstarted) lines.push(` · ${o.node.id}`)
356
+ }
346
357
  return lines
347
358
  }
348
359
 
@@ -6,6 +6,7 @@ import {
6
6
  type CacheKeyInput,
7
7
  type CacheLayer,
8
8
  FILE_HASH_RACY_MS,
9
+ racyWindowMs,
9
10
  resolveInputs,
10
11
  type GitFilesCache,
11
12
  WORKSPACE_OUTPUT_PREFIX,
@@ -222,8 +223,12 @@ export async function movedInput(
222
223
  } catch {
223
224
  return f.path
224
225
  }
225
- if (commandFrom !== undefined && ctimeMs >= commandFrom) return f.path
226
- if (ctimeMs >= f.since - FILE_HASH_RACY_MS) suspects.push(f)
226
+ // A whole-second stamp may stand for any write in its second, one
227
+ // during the command included (`racyWindowMs`).
228
+ if (commandFrom !== undefined && ctimeMs >= commandFrom - racyWindowMs(ctimeMs, 0)) {
229
+ return f.path
230
+ }
231
+ if (ctimeMs >= f.since - racyWindowMs(ctimeMs, FILE_HASH_RACY_MS)) suspects.push(f)
227
232
  }
228
233
  for (const f of suspects) {
229
234
  const now = await cache.hashFile(f.path).catch(() => undefined)
@@ -107,7 +107,7 @@ export interface RunContextRecord {
107
107
  /** ULID, shared by every record in one `vx run`. */
108
108
  runId: string
109
109
  vxVersion: string
110
- /** The invocation command line (process.argv-derived). */
110
+ /** The invocation command line (process.argv-derived), what follows `--` counted, not quoted. */
111
111
  command: string
112
112
  requestedTasks: readonly string[]
113
113
  /** Compact cache-policy flags, e.g. `'lR,lW,rR,rW'`. */
@@ -318,8 +318,14 @@ export interface TelemetrySink {
318
318
  * Losing a slow sink's telemetry is strictly better than the alternative:
319
319
  * `run()` never returns, so the cache never closes and `vx` exits 0 on a
320
320
  * failed run.
321
+ *
322
+ * `signal` aborts at that deadline. Abandoning a flush does not end its
323
+ * I/O: a request still in flight keeps the event loop, and so the
324
+ * process, alive after the run has returned — a hanging GitHub API held
325
+ * `vx run` open until the CI job's own timeout (item 1055). A sink passes
326
+ * the signal to its `fetch` (or closes its socket on it).
321
327
  */
322
- flush?(): Promise<void>
328
+ flush?(signal: AbortSignal): Promise<void>
323
329
  }
324
330
 
325
331
  /** Read-only context a sink is created with. No mutable run handle — the
@@ -496,12 +502,13 @@ export function createTelemetrySource(args: {
496
502
  // pending — Bun exits 0 and a FAILED run reports green, with the cache's
497
503
  // accessed_at bumps and every later plugin's teardown lost with it.
498
504
  // Sinks race concurrently, so each still gets the whole budget.
505
+ const deadline = new AbortController()
499
506
  const settled = await settleWithin(
500
507
  Promise.all(
501
508
  sinks.map(async (sink) => {
502
509
  if (disabled.has(sink) || sink.flush === undefined) return
503
510
  try {
504
- await sink.flush()
511
+ await sink.flush(deadline.signal)
505
512
  } catch (err) {
506
513
  // A flush failure can never break the run — but it still costs
507
514
  // this sink its export, so say so rather than dropping it.
@@ -513,7 +520,10 @@ export function createTelemetrySource(args: {
513
520
  ),
514
521
  ms,
515
522
  )
516
- if (!settled) warn?.(`[vx] telemetry flush timed out after ${ms}ms; buffered records lost`)
523
+ if (!settled) {
524
+ deadline.abort(new Error(`telemetry flush deadline (${ms}ms)`))
525
+ warn?.(`[vx] telemetry flush timed out after ${ms}ms; buffered records lost`)
526
+ }
517
527
  },
518
528
  }
519
529
  }
package/src/util/paths.ts CHANGED
@@ -69,21 +69,28 @@ function prefixOf(glob: string, wildcards: RegExp): string {
69
69
 
70
70
  /**
71
71
  * The directories a task's declared outputs cover WHOLE — every glob is
72
- * `<dir>/**` with a plain, non-root, non-escaping `<dir>` — or `null` when
73
- * any glob is shaped otherwise. The directory-mtime short-circuit on a warm
74
- * hit (`Cache.outputDirsCurrent`) is sound only for whole subtrees: with
75
- * every directory under `<dir>` recorded, a file added or removed anywhere
76
- * the glob could see bumps a recorded directory's mtime (its parent, or a
77
- * new directory whose creation bumped a recorded ancestor). A root-anchored
78
- * `**\/*.js` has no such closed set, so it keeps the walk.
72
+ * `<dir>/**` or a bare literal (which `asTrees` reads as the path or the
73
+ * tree under it), with a plain, non-root, non-escaping `<dir>` — or `null`
74
+ * when any glob is shaped otherwise. The directory-mtime short-circuit on a
75
+ * warm hit (`Cache.outputDirsCurrent`) is sound only for whole subtrees:
76
+ * with every directory under `<dir>` recorded, a file added or removed
77
+ * anywhere the glob could see bumps a recorded directory's mtime (its
78
+ * parent, or a new directory whose creation bumped a recorded ancestor). A
79
+ * root-anchored `**\/*.js` has no such closed set, so it keeps the walk.
80
+ * A literal that names a FILE is no directory: the snapshot refuses it
81
+ * whole (`recordOutputDirs`) and that task keeps the walk too, so the
82
+ * literal costs nothing it did not cost before; one that names a directory
83
+ * gets the same stats a `<dir>/**` does (Next 27: an nx() output file
84
+ * had to be `<dir>/**` to keep the short-circuit, and saved nothing).
79
85
  */
80
86
  export function wholeSubtreePrefixes(globs: readonly string[]): string[] | null {
81
87
  if (globs.length === 0) return null
82
88
  const out: string[] = []
83
89
  for (const g of globs.map(normalizeGlob)) {
84
90
  const m = /^([^*?{}!]+?)\/\*\*$/.exec(g)
85
- if (m === null) return null
86
- const dir = m[1]!.replace(/\/+$/, '')
91
+ const whole = m?.[1] ?? (isLiteralPattern(g) && !g.startsWith('!') ? g : null)
92
+ if (whole === null) return null
93
+ const dir = whole.replace(/\/+$/, '')
87
94
  if (dir === '' || dir === '.' || dir.startsWith('/') || dir.split('/').includes('..'))
88
95
  return null
89
96
  out.push(dir)
@@ -21,6 +21,7 @@ import { configImportOwners } from './config-imports.js'
21
21
  import { configImports } from './config-cache.js'
22
22
  import { WORKSPACE_CONFIG_FILENAMES } from './project-loader.js'
23
23
  import { WORKSPACE_FINGERPRINT_FILES } from './fingerprint.js'
24
+ import { buildPackageGraph } from './package-graph.js'
24
25
  import type { PackageJson, ProjectMeta } from './workspace.js'
25
26
 
26
27
  /**
@@ -42,9 +43,19 @@ function spawnGitSync(
42
43
  }
43
44
  }
44
45
 
45
- function spawnGit(args: string[], cwd: string): Bun.Subprocess<'ignore', 'pipe', 'pipe'> {
46
+ function spawnGit(
47
+ args: string[],
48
+ cwd: string,
49
+ stdin?: Uint8Array,
50
+ ): Bun.Subprocess<Uint8Array | 'ignore', 'pipe', 'pipe'> {
46
51
  try {
47
- return Bun.spawn({ cmd: [executablePath('git'), ...args], cwd, stdout: 'pipe', stderr: 'pipe' })
52
+ return Bun.spawn({
53
+ cmd: [executablePath('git'), ...args],
54
+ cwd,
55
+ stdin: stdin ?? 'ignore',
56
+ stdout: 'pipe',
57
+ stderr: 'pipe',
58
+ })
48
59
  } catch (err) {
49
60
  if (isExecutableMissing(err)) throw gitSpawnRefusal(cwd)
50
61
  throw err
@@ -79,6 +90,13 @@ export interface AffectedArgs {
79
90
  * — the common one — never needs it.
80
91
  */
81
92
  fingerprintClaims?: () => Promise<FingerprintClaims>
93
+ /**
94
+ * The cross-project `dependsOn` edges, project → the projects its tasks
95
+ * name. Asked only when a package's name moved or it was removed: a
96
+ * config naming `dependsOn: ['lib#build']` breaks on it, which the
97
+ * package graph cannot see (item 1085).
98
+ */
99
+ taskEdges?: () => Promise<ReadonlyMap<string, readonly string[]>>
82
100
  }
83
101
 
84
102
  export interface FingerprintClaims {
@@ -225,13 +243,28 @@ export async function affectedProjects(args: AffectedArgs): Promise<Set<string>>
225
243
  const owned = projectsContaining(args.workspaceRoot, changed, args.projects)
226
244
  for (const name of claimedOwned) owned.add(name)
227
245
 
228
- // A package the change REMOVED is no project now, so containment maps its
229
- // paths to nothing, and the dependents walk sees only today's graph: a
230
- // `git rm -r pkgs/lib`, or `lib` dropped from `workspaces`, re-keyed and
231
- // broke `app#build` while `--affected` said nothing affected (item 959).
232
- const dependents = await dependentsOfRemoved(args.workspaceRoot, base, changed, args.projects)
233
- if (dependents === undefined) return new Set(args.projects.map((p) => p.name))
234
- for (const name of dependents) owned.add(name)
246
+ // A manifest edit can drop an edge today's graph no longer shows: a
247
+ // package the change REMOVED is no project now (a `git rm -r pkgs/lib`,
248
+ // or `lib` dropped from `workspaces`, item 959), and one whose `version`
249
+ // or `name` moved no longer satisfies what a dependent declares. Either
250
+ // re-keys the dependent through its upstream, while containment maps the
251
+ // change to the package alone and the dependents walk sees only today's
252
+ // graph, so `--affected` left `app#build` out (D-3).
253
+ const manifests = changed.filter((rel) => path.posix.basename(rel) === 'package.json')
254
+ if (manifests.length > 0) {
255
+ const atBase = await gitBlobsAt(args.workspaceRoot, base, manifests)
256
+ const dependents = await dependentsAtBase(
257
+ args.workspaceRoot,
258
+ atBase,
259
+ args.projects,
260
+ args.taskEdges,
261
+ )
262
+ if (dependents === undefined) return new Set(args.projects.map((p) => p.name))
263
+ for (const name of dependents) owned.add(name)
264
+ for (const name of parentsOfNewNested(args.workspaceRoot, atBase, args.projects)) {
265
+ owned.add(name)
266
+ }
267
+ }
235
268
 
236
269
  // THIRD CHANNEL: a project whose `vx.config.*` IMPORTS a changed file.
237
270
  // Resolved-config hashing folds those values into the key, so the same
@@ -254,22 +287,27 @@ export async function affectedProjects(args: AffectedArgs): Promise<Set<string>>
254
287
  }
255
288
 
256
289
  /**
257
- * The projects that depend on a package the change removed: a
258
- * `<dir>/package.json` the base had and the tree does not, read at the base
259
- * for its name. `undefined` (every project) when the root manifest's
260
- * `workspaces` moved: which packages left is a discovery at the base, and
261
- * selection cannot tell cheaply. Only DELETED manifests are read at the
262
- * base, one `git show` each; an edited one stays a project and is owned.
290
+ * The projects whose workspace dependencies at the base differ from
291
+ * today's: the package graph is built again over the changed manifests as
292
+ * the base had them, and every project whose `directDeps` (the edges its
293
+ * key folds) moved is selected. `undefined` (every project) when the root
294
+ * manifest's `workspaces` moved: which packages left is a discovery at the
295
+ * base, and selection cannot tell cheaply. `atBase` holds each changed
296
+ * manifest as the base had it, read in one `git cat-file --batch`.
263
297
  */
264
- async function dependentsOfRemoved(
298
+ async function dependentsAtBase(
265
299
  workspaceRoot: string,
266
- base: string,
267
- changed: readonly string[],
300
+ atBase: ReadonlyMap<string, Uint8Array | null>,
268
301
  projects: readonly ProjectMeta[],
302
+ taskEdges: AffectedArgs['taskEdges'],
269
303
  ): Promise<Set<string> | undefined> {
270
- const out = new Set<string>()
271
- const removed = new Set<string>()
272
- for (const rel of changed) {
304
+ const byDir = new Map<string, ProjectMeta>()
305
+ for (const p of projects) byDir.set(p.dir, p)
306
+ const nameNow = new Map(projects.map((p) => [p.dir, p.name]))
307
+ // Base names no project holds today: a removed or renamed package.
308
+ const gone = new Set<string>()
309
+ const changedDirs = new Set<string>()
310
+ for (const rel of atBase.keys()) {
273
311
  if (rel === 'package.json') {
274
312
  const workspacesOf = (bytes: Uint8Array | null): string => {
275
313
  if (bytes === null) return ''
@@ -281,37 +319,141 @@ async function dependentsOfRemoved(
281
319
  return 'unreadable'
282
320
  }
283
321
  }
284
- const before = workspacesOf(await gitBytesAt(workspaceRoot, base, rel))
322
+ const before = workspacesOf(atBase.get(rel) ?? null)
285
323
  const after = workspacesOf(await bytesOrNull(path.join(workspaceRoot, rel)))
286
324
  if (before !== after) return undefined
287
- continue
288
325
  }
289
- if (path.posix.basename(rel) !== 'package.json') continue
290
- if ((await bytesOrNull(path.join(workspaceRoot, rel))) !== null) continue
291
- const bytes = await gitBytesAt(workspaceRoot, base, rel)
292
- if (bytes === null) continue
326
+ const dir = path.resolve(workspaceRoot, path.posix.dirname(rel))
327
+ changedDirs.add(dir)
328
+ byDir.delete(dir)
329
+ const bytes = atBase.get(rel)
330
+ if (bytes === undefined || bytes === null) continue
331
+ let pkg: PackageJson
293
332
  try {
294
- const name = (JSON.parse(new TextDecoder().decode(bytes)) as PackageJson).name
295
- if (typeof name === 'string' && name !== '') removed.add(name)
333
+ pkg = JSON.parse(new TextDecoder().decode(bytes)) as PackageJson
296
334
  } catch {
297
335
  // A manifest that did not parse at the base named no package.
336
+ continue
298
337
  }
338
+ if (
339
+ pkg === null ||
340
+ typeof pkg !== 'object' ||
341
+ typeof pkg.name !== 'string' ||
342
+ pkg.name === ''
343
+ ) {
344
+ continue
345
+ }
346
+ byDir.set(dir, { name: pkg.name, dir, packageJson: pkg, configPath: null })
347
+ if (nameNow.get(dir) !== pkg.name) gone.add(pkg.name)
299
348
  }
300
- if (removed.size === 0) return out
349
+ const now = buildPackageGraph([...projects])
350
+ const then = buildPackageGraph([...byDir.values()])
351
+ const out = new Set<string>()
301
352
  for (const p of projects) {
302
- const pkg = p.packageJson
303
- for (const deps of [
304
- pkg.dependencies,
305
- pkg.devDependencies,
306
- pkg.peerDependencies,
307
- pkg.optionalDependencies,
308
- ]) {
309
- if (deps !== undefined && Object.keys(deps).some((d) => removed.has(d))) out.add(p.name)
353
+ // A project whose own manifest changed is owned by containment.
354
+ if (changedDirs.has(p.dir)) continue
355
+ const a = now.directDeps(p.name)
356
+ const b = then.directDeps(p.name)
357
+ if (a.length !== b.length || a.some((d, i) => d !== b[i])) out.add(p.name)
358
+ }
359
+ if (gone.size > 0 && taskEdges !== undefined) {
360
+ for (const [project, targets] of await taskEdges()) {
361
+ if (targets.some((t) => gone.has(t))) out.add(project)
310
362
  }
311
363
  }
312
364
  return out
313
365
  }
314
366
 
367
+ /**
368
+ * The projects a NEW nested project took files from. A project's inputs
369
+ * stop at every project below it, so a `package.json` that makes an
370
+ * existing directory a project re-keys the project above it — its
371
+ * `**` lost the directory's files — while containment maps the change to
372
+ * the new project alone, and `--affected` left the re-keyed parent out.
373
+ * "New" is judged at the base: no manifest there, or one with no name,
374
+ * which discovery skips. `atBase` holds each changed manifest there.
375
+ */
376
+ function parentsOfNewNested(
377
+ workspaceRoot: string,
378
+ atBase: ReadonlyMap<string, Uint8Array | null>,
379
+ projects: readonly ProjectMeta[],
380
+ ): Set<string> {
381
+ const out = new Set<string>()
382
+ const dirToName = new Map<string, string>()
383
+ for (const p of projects) dirToName.set(p.dir, p.name)
384
+ for (const [rel, bytes] of atBase) {
385
+ const dir = path.resolve(workspaceRoot, path.posix.dirname(rel))
386
+ if (!dirToName.has(dir)) continue
387
+ let parent: string | undefined
388
+ for (let d = path.dirname(dir); parent === undefined; d = path.dirname(d)) {
389
+ parent = dirToName.get(d)
390
+ if (path.dirname(d) === d) break
391
+ }
392
+ if (parent === undefined || out.has(parent)) continue
393
+ let named = false
394
+ if (bytes !== null) {
395
+ try {
396
+ const name = (JSON.parse(new TextDecoder().decode(bytes)) as PackageJson).name
397
+ named = typeof name === 'string' && name !== ''
398
+ } catch {
399
+ // A manifest that did not parse at the base named no project.
400
+ }
401
+ }
402
+ if (!named) out.add(parent)
403
+ }
404
+ return out
405
+ }
406
+
407
+ /**
408
+ * Each of `files` (workspace-root-relative) as `ref` has it, in one `git
409
+ * cat-file --batch`: null for a file the ref does not have. `./` anchors
410
+ * each path to the cwd, the workspace root, as `gitBytesAt` does.
411
+ */
412
+ async function gitBlobsAt(
413
+ workspaceRoot: string,
414
+ ref: string,
415
+ files: readonly string[],
416
+ ): Promise<Map<string, Uint8Array | null>> {
417
+ const blobs = new Map<string, Uint8Array | null>()
418
+ // The batch reads one path per line; a name holding a newline asks alone.
419
+ const batched: string[] = []
420
+ for (const file of files) {
421
+ if (file.includes('\n')) blobs.set(file, await gitBytesAt(workspaceRoot, ref, file))
422
+ else batched.push(file)
423
+ }
424
+ if (batched.length === 0) return blobs
425
+ const proc = spawnGit(
426
+ ['cat-file', '--batch'],
427
+ workspaceRoot,
428
+ new TextEncoder().encode(batched.map((f) => `${ref}:./${f}\n`).join('')),
429
+ )
430
+ const [out, stderr, exit] = await Promise.all([
431
+ new Response(proc.stdout).bytes(),
432
+ new Response(proc.stderr).text(),
433
+ proc.exited,
434
+ ])
435
+ if (exit !== 0) {
436
+ throw new UserError(`git cat-file --batch failed (exit ${exit}): ${stderr.trim()}`)
437
+ }
438
+ let at = 0
439
+ for (const file of batched) {
440
+ const eol = out.indexOf(0x0a, at)
441
+ if (eol === -1) throw new UserError(`git cat-file --batch ended before ${ref}:./${file}`)
442
+ const header = new TextDecoder().decode(out.subarray(at, eol))
443
+ at = eol + 1
444
+ // `<oid> <type> <size>`, or `<spec> missing` / `<spec> ambiguous`.
445
+ const m = /^[0-9a-f]{40,64} (\w+) (\d+)$/.exec(header)
446
+ if (m === null) {
447
+ blobs.set(file, null)
448
+ continue
449
+ }
450
+ const size = Number(m[2])
451
+ blobs.set(file, m[1] === 'blob' ? out.slice(at, at + size) : null)
452
+ at += size + 1
453
+ }
454
+ return blobs
455
+ }
456
+
315
457
  /**
316
458
  * Whether `changed` holds the workspace config or a file it imports by
317
459
  * relative specifier. Its plugins' `config` and `project` stages shape
@@ -505,6 +647,14 @@ function isDirectory(abs: string): boolean {
505
647
  }
506
648
  }
507
649
 
650
+ function realpathOr(p: string): string {
651
+ try {
652
+ return realpathSync(p)
653
+ } catch {
654
+ return p
655
+ }
656
+ }
657
+
508
658
  function projectsContaining(
509
659
  workspaceRoot: string,
510
660
  changedRelPaths: readonly string[],
@@ -519,6 +669,19 @@ function projectsContaining(
519
669
  // pays for.
520
670
  const dirToName = new Map<string, string>()
521
671
  for (const p of projects) dirToName.set(p.dir, p.name)
672
+ // A member linked in from elsewhere in the tree (`pkgs/b -> ../ext/b`) is
673
+ // indexed by its link, and git reports its files at their real place
674
+ // (`ext/b/src/a.txt`), which resolved to no project: an edit there
675
+ // selected nothing (item 1079). Its real place under the root is indexed
676
+ // too. One realpath per member, and only on an --affected run.
677
+ const realRoot = realpathOr(workspaceRoot)
678
+ for (const p of projects) {
679
+ const real = realpathOr(p.dir)
680
+ const rel = path.relative(realRoot, real)
681
+ if (rel.startsWith('..') || path.isAbsolute(rel)) continue
682
+ const spelled = path.resolve(workspaceRoot, rel)
683
+ if (spelled !== p.dir && !dirToName.has(spelled)) dirToName.set(spelled, p.name)
684
+ }
522
685
  const owned = new Set<string>()
523
686
  for (const rel of changedRelPaths) {
524
687
  let dir = path.resolve(workspaceRoot, rel)
@@ -45,7 +45,10 @@ self.onmessage = async (e) => {
45
45
  const { id, path } = e.data
46
46
  try {
47
47
  const ns = await import(path)
48
- const mod = ns?.default
48
+ // Awaited as the in-process load's async return flattens it: a Promise
49
+ // default loaded on a run and was refused as "an instance of Promise"
50
+ // on every later evaluation in the process (D-5).
51
+ const mod = await ns?.default
49
52
  const isObject = mod !== null && typeof mod === 'object'
50
53
  const nonJson = isObject ? nonJsonPaths(mod) : []
51
54
  postMessage({
@@ -282,9 +282,16 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
282
282
  const sandbox = (exec as { sandbox?: unknown }).sandbox
283
283
  if (sandbox !== undefined) validateSandbox(sandbox, `${where}.exec`)
284
284
  const command = (exec as { command?: unknown }).command
285
- if (typeof command !== 'string' || command.length === 0) {
285
+ // Whitespace alone ran as a shell no-op, green and cached (item 1099).
286
+ if (typeof command !== 'string' || command.trim().length === 0) {
286
287
  throw new UserError(`${where}.exec.command must be a non-empty string`)
287
288
  }
289
+ // No argv can carry a NUL: the spawn refused it and the task failed
290
+ // as exit 127, "not on this task's PATH", with the NUL printed as a
291
+ // space (D-4), as an env name with one did before item 999.
292
+ if (command.includes('\0')) {
293
+ throw new UserError(`${where}.exec.command holds a NUL, which no command line can carry`)
294
+ }
288
295
  const timeout = (exec as { timeout?: unknown }).timeout
289
296
  if (timeout !== undefined) {
290
297
  if (typeof timeout !== 'number' || !Number.isInteger(timeout) || timeout <= 0) {
@@ -329,6 +336,16 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
329
336
  `${where}.exec.env.passThrough must be an array of env var names (non-empty, no '=' or NUL)`,
330
337
  )
331
338
  }
339
+ // `VITE_*` passed nothing through and loaded clean (Turbo's
340
+ // passThroughEnv expands it), where `cache.inputs.env` refused the
341
+ // same text below: one refusal for the class (item 1090).
342
+ const wild = (passThrough as string[]).find((n) => BUN_GLOB_WILDCARDS.test(n))
343
+ if (wild !== undefined) {
344
+ throw new UserError(
345
+ `${where}.exec.env.passThrough: wildcards in env names are not supported ` +
346
+ `(got "${wild}") — list explicit env var names instead`,
347
+ )
348
+ }
332
349
  }
333
350
  const define = (env as { define?: unknown }).define
334
351
  if (define !== undefined) {
@@ -582,9 +599,12 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
582
599
  for (const field of ['runtime', 'workspaceRuntime'] as const) {
583
600
  const list = (inputs as Record<string, unknown>)[field]
584
601
  if (list !== undefined) {
585
- if (!Array.isArray(list) || list.some((s) => typeof s !== 'string' || s.length === 0)) {
602
+ if (
603
+ !Array.isArray(list) ||
604
+ list.some((s) => typeof s !== 'string' || s.length === 0 || s.includes('\0'))
605
+ ) {
586
606
  throw new UserError(
587
- `${where}.cache.inputs.${field} must be an array of non-empty shell command strings`,
607
+ `${where}.cache.inputs.${field} must be an array of non-empty shell command strings with no NUL`,
588
608
  )
589
609
  }
590
610
  }
@@ -633,6 +653,40 @@ const CACHE_INPUT_FIELDS = new Set([
633
653
  ])
634
654
  const CACHE_OUTPUT_FIELDS = new Set(['files', 'workspaceFiles'])
635
655
 
656
+ /** A field a release took out of the schema, and what took its place. */
657
+ interface Removal {
658
+ /** The vx version whose schema first refused it. */
659
+ removedIn: string
660
+ /** What to write instead, as a sentence fragment after "use". */
661
+ use: string
662
+ }
663
+
664
+ /**
665
+ * Fields the schema once accepted, by the level that held them (keyed by
666
+ * that level's field set). A removed field is refused naming its
667
+ * replacement and the version that removed it, never as an unknown field a
668
+ * reader must research (docs/design/versioning-1.0.md § Deprecation). An
669
+ * entry stays for good: a config written against an old release meets it
670
+ * whenever it upgrades.
671
+ */
672
+ export const REMOVED_FIELDS: ReadonlyMap<
673
+ ReadonlySet<string>,
674
+ Readonly<Record<string, Removal>>
675
+ > = new Map([
676
+ [
677
+ EXEC_FIELDS,
678
+ {
679
+ // Reservations left core with the `admit` seam (item 157).
680
+ resources: {
681
+ removedIn: '0.0.19',
682
+ use:
683
+ "`@vzn/vx-schedule-history`, which learns each task's reservation from its run " +
684
+ "history (declare one by hand with its `reservations: { 'pkg#task': { cpus, memory } }`)",
685
+ },
686
+ },
687
+ ],
688
+ ])
689
+
636
690
  function assertKnownFields(value: object, allowed: ReadonlySet<string>, where: string): void {
637
691
  // `typeof [] === 'object'`, so an array reaches here and its indices read
638
692
  // as fields: `outputs: ['dist/**']` (Turbo's spelling) was refused as
@@ -645,6 +699,12 @@ function assertKnownFields(value: object, allowed: ReadonlySet<string>, where: s
645
699
  }
646
700
  for (const key of Object.keys(value)) {
647
701
  if (!allowed.has(key)) {
702
+ const removal = REMOVED_FIELDS.get(allowed)?.[key]
703
+ if (removal !== undefined) {
704
+ throw new UserError(
705
+ `${where} has field "${key}", which vx ${removal.removedIn} removed — use ${removal.use}`,
706
+ )
707
+ }
648
708
  // The nearest accepted spelling first: the list says what the level
649
709
  // takes, the hint says which one was meant.
650
710
  const near = nearest(key, allowed)
@@ -934,6 +994,7 @@ const GRANT_FIELDS = new Set<string>([
934
994
  'unixSockets',
935
995
  ])
936
996
  const DENY_FIELDS = new Set(['network'])
997
+ const IGNORE_FIELDS = new Set(['read', 'write', 'systemInfo', 'network'])
937
998
 
938
999
  function assertStringArray(v: unknown, where: string): void {
939
1000
  if (!Array.isArray(v) || v.some((s) => typeof s !== 'string' || s.length === 0)) {
@@ -1021,17 +1082,16 @@ function validateSandbox(sandbox: unknown, where: string): void {
1021
1082
  if (typeof ignore !== 'object' || ignore === null || Array.isArray(ignore)) {
1022
1083
  throw new UserError(`${where}.sandbox.ignore must be an object`)
1023
1084
  }
1024
- assertKnownFields(ignore, GRANT_FIELDS, `${where}.sandbox.ignore`)
1085
+ // Only these reach the report's filter (`matchesIgnore`): a denial is
1086
+ // classed read, write, systemInfo or network, and nothing else. Every
1087
+ // grant name loaded here once, so `ignore.machLookup` silenced nothing
1088
+ // and said so nowhere (D-4).
1089
+ assertKnownFields(ignore, IGNORE_FIELDS, `${where}.sandbox.ignore`)
1025
1090
  const g = ignore as Record<string, unknown>
1026
1091
  // Patterns, not prefixes — `ignore` is vx's own filter over lines it
1027
1092
  // already holds, so a glob costs nothing and works on every platform.
1028
- for (const f of [...GRANT_PATH_FIELDS, ...GRANT_NAME_FIELDS, 'network', 'unixSockets']) {
1093
+ for (const f of IGNORE_FIELDS) {
1029
1094
  if (g[f] !== undefined) assertStringArray(g[f], `${where}.sandbox.ignore.${f}`)
1030
1095
  }
1031
- for (const f of GRANT_BOOL_FIELDS) {
1032
- if (g[f] !== undefined) {
1033
- throw new UserError(`${where}.sandbox.ignore.${f} is a flag, not something to ignore`)
1034
- }
1035
- }
1036
1096
  }
1037
1097
  }
@@ -30,6 +30,12 @@ export const WORKSPACE_FINGERPRINT_FILES = [
30
30
  // (turborepo#12635). pnpm's catalogs live in pnpm-workspace.yaml, and
31
31
  // its lockfile records each importer's resolved version besides.
32
32
  '.yarnrc.yml',
33
+ // Install settings no lockfile records: bun's `linker = "isolated"` and
34
+ // pnpm's `node-linker` / hoisting lay `node_modules` out anew under a
35
+ // byte-identical lockfile, and a build that imported an undeclared
36
+ // hoisted package kept its green hit where it now fails (D-7).
37
+ '.npmrc',
38
+ 'bunfig.toml',
33
39
  ]
34
40
 
35
41
  // DELIBERATELY ABSENT: `vx.workspace.{ts,mts,js,mjs}`. Everything it can
@@ -101,7 +101,7 @@ export interface MigrationPlan {
101
101
  projects: GeneratedProject[]
102
102
  /** extra root-relative files (e.g. the preset) */
103
103
  extraFiles: { relPath: string; contents: string }[]
104
- /** trailing report lines (e.g. implicit Nx deps) */
104
+ /** trailing report lines (e.g. what a turbo.json holds that vx has no place for) */
105
105
  notes: string[]
106
106
  }
107
107