@vzn/vx 0.0.650 → 0.0.652

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 (146) hide show
  1. package/README.md +7 -23
  2. package/package.json +5 -5
  3. package/schemas/last.json +15 -0
  4. package/schemas/show.json +14 -20
  5. package/src/bin.ts +21 -31
  6. package/src/cache/archive.ts +1 -1
  7. package/src/cache/cache.ts +221 -30
  8. package/src/cache/file-hashes.ts +3 -0
  9. package/src/cache/git-inputs.ts +188 -39
  10. package/src/cache/history-stream.ts +134 -0
  11. package/src/cache/history-writer.ts +76 -0
  12. package/src/cache/index.ts +3 -0
  13. package/src/cache/inputs.ts +77 -14
  14. package/src/cache/layer.ts +17 -6
  15. package/src/cache/layered-cache.ts +4 -0
  16. package/src/cache/linked-inputs.ts +3 -0
  17. package/src/cache/lockfile-memo.ts +5 -3
  18. package/src/cache/output-index.ts +5 -2
  19. package/src/cache/remote-lane.ts +2 -1
  20. package/src/cache/run-history.ts +56 -17
  21. package/src/cache/schema.ts +15 -2
  22. package/src/cli/cache.ts +28 -18
  23. package/src/cli/completions.ts +4 -2
  24. package/src/cli/docs.ts +10 -11
  25. package/src/cli/help.ts +14 -25
  26. package/src/cli/index.ts +28 -9
  27. package/src/cli/info.ts +16 -8
  28. package/src/cli/init.ts +23 -15
  29. package/src/cli/last.ts +246 -204
  30. package/src/cli/lock.ts +55 -5
  31. package/src/cli/plan-format.ts +100 -118
  32. package/src/cli/plugin-commands.ts +8 -2
  33. package/src/cli/plugin-templates.ts +36 -45
  34. package/src/cli/run.ts +46 -27
  35. package/src/cli/select.ts +43 -5
  36. package/src/cli/show.ts +45 -52
  37. package/src/cli/task-verb.ts +22 -0
  38. package/src/cli/upgrade.ts +3 -1
  39. package/src/cli/watch-fs.ts +6 -1
  40. package/src/cli/watch-judge.ts +3 -2
  41. package/src/cli/watch-set.ts +4 -4
  42. package/src/cli/watch.ts +94 -52
  43. package/src/cli/why.ts +123 -89
  44. package/src/cli/workspace-config.ts +2 -1
  45. package/src/config.ts +47 -30
  46. package/src/exec/executor.ts +42 -2
  47. package/src/exec/index.ts +1 -0
  48. package/src/exec/kill-tree.ts +58 -5
  49. package/src/exec/local-executor.ts +1 -1
  50. package/src/exec/pipes.ts +152 -0
  51. package/src/exec/runner.ts +199 -182
  52. package/src/exec/sandbox-binds.ts +1 -1
  53. package/src/exec/sandbox-paths.ts +3 -1
  54. package/src/exec/sandbox-runtime.ts +317 -23
  55. package/src/exec/spawn-off.ts +154 -0
  56. package/src/exec/task-proxy.ts +93 -0
  57. package/src/exec/warm-shell.ts +146 -0
  58. package/src/graph/critical-path.ts +40 -28
  59. package/src/graph/definition.ts +16 -3
  60. package/src/graph/priorities.ts +98 -44
  61. package/src/graph/scheduler.ts +17 -11
  62. package/src/graph/task-graph.ts +51 -24
  63. package/src/handoff.ts +10 -7
  64. package/src/history/failure-mode.ts +24 -1
  65. package/src/history/history.ts +5 -0
  66. package/src/history/index.ts +1 -0
  67. package/src/history/metrics.ts +14 -6
  68. package/src/index.ts +7 -3
  69. package/src/orchestrator/admission.ts +1 -1
  70. package/src/orchestrator/command-windows.ts +1 -1
  71. package/src/orchestrator/deferred-outputs.ts +1 -1
  72. package/src/orchestrator/doctor.ts +1 -1
  73. package/src/orchestrator/download-policy.ts +5 -7
  74. package/src/orchestrator/excluded-keys.ts +1 -1
  75. package/src/orchestrator/execute-task.ts +67 -20
  76. package/src/orchestrator/graph-memo.ts +50 -1
  77. package/src/orchestrator/graph-stage.ts +6 -0
  78. package/src/orchestrator/hit-restore.ts +5 -6
  79. package/src/orchestrator/index.ts +3 -0
  80. package/src/orchestrator/keyed-projects.ts +28 -0
  81. package/src/orchestrator/live-usage.ts +16 -11
  82. package/src/orchestrator/local-shortcircuit.ts +2 -2
  83. package/src/orchestrator/locked-digests.ts +63 -0
  84. package/src/orchestrator/lockfile-claim.ts +108 -29
  85. package/src/orchestrator/miss-save.ts +58 -11
  86. package/src/orchestrator/persistent-task.ts +5 -1
  87. package/src/orchestrator/placement.ts +1 -1
  88. package/src/orchestrator/plan.ts +7 -3
  89. package/src/orchestrator/plugin-host.ts +67 -2
  90. package/src/orchestrator/plugin.ts +53 -2
  91. package/src/orchestrator/prepare.ts +16 -1
  92. package/src/orchestrator/projects.ts +4 -1
  93. package/src/orchestrator/remote-prefetch.ts +1 -1
  94. package/src/orchestrator/run-context.ts +29 -10
  95. package/src/orchestrator/run-failures.ts +62 -13
  96. package/src/orchestrator/run-keys.ts +1 -1
  97. package/src/orchestrator/run-records.ts +88 -59
  98. package/src/orchestrator/run-report.ts +1 -1
  99. package/src/orchestrator/run.ts +145 -41
  100. package/src/orchestrator/sandbox-request.ts +52 -9
  101. package/src/orchestrator/shell-verdict.ts +9 -0
  102. package/src/orchestrator/signals.ts +8 -5
  103. package/src/orchestrator/stable-keys.ts +17 -18
  104. package/src/orchestrator/task-artifacts.ts +1 -1
  105. package/src/orchestrator/task-hash.ts +21 -7
  106. package/src/orchestrator/telemetry.ts +5 -1
  107. package/src/report/events.ts +6 -14
  108. package/src/report/forecast.ts +10 -32
  109. package/src/report/framed-output.ts +283 -175
  110. package/src/report/index.ts +8 -3
  111. package/src/report/logger.ts +74 -39
  112. package/src/report/plain-output.ts +3 -1
  113. package/src/report/status-line.ts +49 -357
  114. package/src/report/summary.ts +105 -136
  115. package/src/settings.ts +8 -8
  116. package/src/ui/ansi.ts +48 -0
  117. package/src/ui/components.ts +506 -0
  118. package/src/ui/index.ts +79 -0
  119. package/src/ui/kit.ts +37 -0
  120. package/src/ui/live-region.ts +363 -0
  121. package/src/{util/refusal-notice.ts → ui/refusal.ts} +7 -5
  122. package/src/ui/terminal.ts +51 -0
  123. package/src/ui/theme.ts +224 -0
  124. package/src/ui/version-line.ts +28 -0
  125. package/src/util/config-frame.ts +191 -14
  126. package/src/util/edit-distance.ts +6 -1
  127. package/src/util/index.ts +2 -15
  128. package/src/util/paths.ts +21 -3
  129. package/src/util/secret-mask.ts +39 -3
  130. package/src/util/settle.ts +33 -1
  131. package/src/util/tail.ts +4 -1
  132. package/src/util/timing.ts +4 -16
  133. package/src/workspace/affected.ts +89 -30
  134. package/src/workspace/bracket-literal.ts +2 -2
  135. package/src/workspace/config-schema.ts +70 -63
  136. package/src/workspace/dependencies-lock.ts +191 -0
  137. package/src/workspace/filter.ts +36 -1
  138. package/src/workspace/index.ts +9 -0
  139. package/src/workspace/lockfile.ts +159 -6
  140. package/src/workspace/migration.ts +16 -9
  141. package/src/workspace/workspace.ts +3 -0
  142. package/src/util/colors.ts +0 -133
  143. package/src/util/report.ts +0 -44
  144. package/src/util/ui.ts +0 -258
  145. /package/src/{report/path-links.ts → ui/links.ts} +0 -0
  146. /package/src/{report → ui}/terminal-signals.ts +0 -0
@@ -18,8 +18,16 @@ import {
18
18
  notAWorkTree,
19
19
  relPosix,
20
20
  UserError,
21
+ xxh3hex,
21
22
  } from '../util/index.js'
22
- import { LOCKFILE_NAME, lockKey } from './lockfile.js'
23
+ import { LOCKFILE_NAME, lockEntries, lockKey } from './lockfile.js'
24
+ import {
25
+ DEPENDENCIES_LOCK_NAME,
26
+ type DependenciesLock,
27
+ dependantsOf,
28
+ recordedDigests,
29
+ parseDependenciesLock,
30
+ } from './dependencies-lock.js'
23
31
  import { configImportOwners, realpathOr } from './config-imports.js'
24
32
  import { WORKSPACE_CONFIG_FILENAMES } from './project-loader.js'
25
33
  import { bunPatchFiles } from './fingerprint.js'
@@ -143,6 +151,10 @@ export interface FingerprintClaims {
143
151
  }): Promise<ReadonlySet<string> | undefined>
144
152
  /** The files a claimed file names that its claimant keys too; absent = it reads none. */
145
153
  extraFiles?(file: string, bytes: Uint8Array): Promise<readonly string[] | undefined>
154
+ /** Each project's digest of a claimed file now (`FingerprintClaim.digests`); absent or undefined = none. */
155
+ digests?(
156
+ file: string,
157
+ ): Promise<{ hash: string; projects: Readonly<Record<string, string>> } | undefined>
146
158
  }
147
159
 
148
160
  /**
@@ -388,6 +400,12 @@ export async function affectedChanges(
388
400
  if (wsConfig !== undefined) return everything(wsConfig)
389
401
  // The workspace file is loaded once, for the extra files below and the
390
402
  // changed root names after them.
403
+ // The base commit's `vx lock` record, read once and only when asked.
404
+ let baseDeps: Promise<DependenciesLock | null> | undefined
405
+ const baseDependencies = (): Promise<DependenciesLock | null> =>
406
+ (baseDeps ??= gitBytesAt(args.workspaceRoot, base, DEPENDENCIES_LOCK_NAME).then(
407
+ parseDependenciesLock,
408
+ ))
391
409
  let claims: FingerprintClaims | undefined
392
410
  const loadClaims = async (): Promise<FingerprintClaims | undefined> =>
393
411
  (claims ??= await args.fingerprintClaims?.())
@@ -446,6 +464,32 @@ export async function affectedChanges(
446
464
  if (patch !== undefined) return everything(patch)
447
465
  }
448
466
  }
467
+ // The projects whose digest of `file` moved, from the base's recorded
468
+ // digests and the claimant's now: the base side is never parsed. Null
469
+ // when the base recorded none for these bytes (no lock, or another
470
+ // hash) or the claimant gives none now: `affected` answers then.
471
+ const recordedMoves = async (
472
+ asked: FingerprintClaims,
473
+ file: string,
474
+ before: Uint8Array | null,
475
+ after: Uint8Array | null,
476
+ ): Promise<ReadonlySet<string> | null> => {
477
+ if (before === null || after === null || asked.digests === undefined) return null
478
+ const base = await baseDependencies()
479
+ const was = base === null ? undefined : recordedDigests(base, file)
480
+ // Base digests that read another file are that file's at the base,
481
+ // which the record does not prove.
482
+ if (was === undefined || was.hash !== xxh3hex(before) || Object.keys(was.extras).length > 0)
483
+ return null
484
+ const now = await asked.digests(file)
485
+ if (now === undefined) return null
486
+ const moved = new Set<string>()
487
+ for (const p of projects) {
488
+ const d = was.of(lockKey(rootDir, p.dir))
489
+ if (d === undefined || d !== now.projects[p.name]) moved.add(p.name)
490
+ }
491
+ return moved
492
+ }
449
493
  const claimedOwned = new Map<string, string>()
450
494
  const rootNamesChanged = changed.filter((p) => !p.includes('/'))
451
495
  for (const file of extrasBy.keys()) {
@@ -455,12 +499,11 @@ export async function affectedChanges(
455
499
  for (const file of rootNamesChanged) {
456
500
  if (claimed === undefined || !claimed.files.has(file)) continue
457
501
  const extras = extrasBy.get(file)
458
- const answer = await claimed.affected({
459
- file,
460
- before: await gitBytesAt(args.workspaceRoot, base, file),
461
- after: await bytesOrNull(path.join(args.workspaceRoot, file)),
462
- ...(extras !== undefined ? { extras } : {}),
463
- })
502
+ const before = await gitBytesAt(args.workspaceRoot, base, file)
503
+ const after = await bytesOrNull(path.join(args.workspaceRoot, file))
504
+ const answer =
505
+ (extras === undefined ? await recordedMoves(claimed, file, before, after) : undefined) ??
506
+ (await claimed.affected({ file, before, after, ...(extras !== undefined ? { extras } : {}) }))
464
507
  const why =
465
508
  extras === undefined || changed.includes(file)
466
509
  ? file
@@ -505,20 +548,8 @@ export async function affectedChanges(
505
548
  if (diffed.paths.includes(LOCKFILE_NAME) || untracked.includes(LOCKFILE_NAME)) {
506
549
  // A side with no lock (or one that will not parse) ran nothing from
507
550
  // it: the paths of the configs say what changed, as before the lock.
508
- const entries = (bytes: Uint8Array | null): Record<string, unknown> | null => {
509
- if (bytes === null) return null
510
- try {
511
- const locked = (JSON.parse(new TextDecoder().decode(bytes)) as { projects?: unknown })
512
- .projects
513
- return typeof locked === 'object' && locked !== null
514
- ? (locked as Record<string, unknown>)
515
- : null
516
- } catch {
517
- return null
518
- }
519
- }
520
- const before = entries(await gitBytesAt(args.workspaceRoot, base, LOCKFILE_NAME))
521
- const after = entries(await bytesOrNull(path.join(args.workspaceRoot, LOCKFILE_NAME)))
551
+ const before = lockEntries(await gitBytesAt(args.workspaceRoot, base, LOCKFILE_NAME))
552
+ const after = lockEntries(await bytesOrNull(path.join(args.workspaceRoot, LOCKFILE_NAME)))
522
553
  const same = (dir: string): boolean =>
523
554
  before === null ||
524
555
  after === null ||
@@ -538,9 +569,23 @@ export async function affectedChanges(
538
569
  const atBase = await gitBlobsAt(args.workspaceRoot, base, markers)
539
570
  // A config the base had in a directory that is no project now: the
540
571
  // project is gone, and with it the edge naming who depended on it,
541
- // while their keys lost an upstream. Today's graph cannot say who.
542
- const gone = await removedProjectConfig(args.workspaceRoot, atBase, projects)
543
- if (gone !== undefined) return everything(gone)
572
+ // while their keys lost an upstream. Today's graph cannot say who; the
573
+ // base's dependencies lock can. Without one, every project.
574
+ // A config that moved, the same bytes under a new project, is no
575
+ // loss: its files select it where it lives.
576
+ const nameAt = new Map(projects.map((p) => [lockKey(rootDir, p.dir), p.name]))
577
+ for (const gone of await removedProjectConfigs(args.workspaceRoot, atBase, projects)) {
578
+ const recorded = await baseDependencies()
579
+ if (recorded === null) return everything(gone)
580
+ const held = dependantsOf(recorded, path.posix.dirname(gone)).flatMap(
581
+ (d) => nameAt.get(d) ?? [],
582
+ )
583
+ for (const name of held) {
584
+ owned.add(name)
585
+ whole.add(name)
586
+ if (!claimedOwned.has(name)) claimedOwned.set(name, gone)
587
+ }
588
+ }
544
589
  for (const name of parentsOfNewNested(args.workspaceRoot, atBase, projects)) {
545
590
  owned.add(name)
546
591
  whole.add(name)
@@ -587,7 +632,12 @@ export async function affectedChanges(
587
632
  }
588
633
 
589
634
  /** What vx writes under `.vx`, which no key reads (cache/inputs.ts `ALWAYS_IGNORE`). */
590
- const VX_OWN: ReadonlySet<string> = new Set([LOCKFILE_NAME, '.vx/.gitattributes', '.vx/.gitignore'])
635
+ const VX_OWN: ReadonlySet<string> = new Set([
636
+ LOCKFILE_NAME,
637
+ DEPENDENCIES_LOCK_NAME,
638
+ '.vx/.gitattributes',
639
+ '.vx/.gitignore',
640
+ ])
591
641
 
592
642
  const CONFIG_NAMES: ReadonlySet<string> = new Set(PROJECT_CONFIG_FILENAMES)
593
643
  /**
@@ -632,22 +682,31 @@ function parentsOfNewNested(
632
682
  return out
633
683
  }
634
684
 
635
- /** The first config in `atBase` the base had whose file and project are both gone now. */
636
- async function removedProjectConfig(
685
+ /** Each config in `atBase` the base had whose file and project are both gone now. */
686
+ async function removedProjectConfigs(
637
687
  workspaceRoot: string,
638
688
  atBase: ReadonlyMap<string, Uint8Array | null>,
639
689
  projects: readonly ProjectMeta[],
640
- ): Promise<string | undefined> {
690
+ ): Promise<string[]> {
641
691
  const dirs = new Set(projects.map((p) => p.dir.normalize('NFC')))
692
+ const gone: string[] = []
693
+ let added: Uint8Array[] | undefined
642
694
  for (const [rel, bytes] of atBase) {
643
695
  // A manifest's directory may never have been a project; a config's was.
644
696
  if (bytes === null || !CONFIG_NAMES.has(path.posix.basename(rel))) continue
645
697
  const dir = path.resolve(workspaceRoot, path.posix.dirname(rel)).normalize('NFC')
646
698
  if (dirs.has(dir)) continue
647
699
  if (await Bun.file(path.join(workspaceRoot, rel)).exists()) continue
648
- return rel
700
+ // Each config the base lacked, read once, only when one went.
701
+ added ??= await Promise.all(
702
+ [...atBase]
703
+ .filter(([r, b]) => b === null && CONFIG_NAMES.has(path.posix.basename(r)))
704
+ .map(([r]) => bytesOrNull(path.join(workspaceRoot, r))),
705
+ ).then((all) => all.filter((b): b is Uint8Array => b !== null))
706
+ if (added!.some((b) => Buffer.from(b).equals(bytes))) continue
707
+ gone.push(rel)
649
708
  }
650
- return undefined
709
+ return gone
651
710
  }
652
711
 
653
712
  /**
@@ -65,8 +65,8 @@ export function checkBracketLiterals(root: string, projects: Iterable<ProjectEnt
65
65
  const lists: [string, readonly string[] | undefined, string][] = [
66
66
  ['inputs.files', t.inputs?.files, p.dir],
67
67
  ['inputs.workspaceFiles', t.inputs?.workspaceFiles, root],
68
- ['cache.outputs.files', t.cache?.outputs?.files, p.dir],
69
- ['cache.outputs.workspaceFiles', t.cache?.outputs?.workspaceFiles, root],
68
+ ['outputs.files', t.outputs?.files, p.dir],
69
+ ['outputs.workspaceFiles', t.outputs?.workspaceFiles, root],
70
70
  ]
71
71
  for (const [field, globs, base] of lists) {
72
72
  for (const glob of globs ?? []) {
@@ -115,7 +115,7 @@ const oneOf = (values: readonly string[]): string =>
115
115
 
116
116
  function validateInputStability(stability: unknown, at: string): void {
117
117
  if (typeof stability !== 'object' || stability === null || Array.isArray(stability)) {
118
- throw new UserError(`${at} must be an object — \`inputStability: { strategy: 'mutable' }\``)
118
+ throw new UserError(`${at} must be an object — \`stability: { strategy: 'mutable' }\``)
119
119
  }
120
120
  assertKnownFields(stability, INPUT_STABILITY_FIELDS, at)
121
121
  const { strategy, severity } = stability as Record<string, unknown>
@@ -128,13 +128,13 @@ function validateInputStability(stability: unknown, at: string): void {
128
128
  }
129
129
 
130
130
  /**
131
- * `VX_CACHE_INPUT_STABILITY_STRATEGY` and `_SEVERITY`, which override every
132
- * task's `cache.inputStability` for the run: each undefined when unset or
131
+ * `VX_INPUTS_STABILITY_STRATEGY` and `_SEVERITY`, which override every
132
+ * task's `inputs.stability` for the run: each undefined when unset or
133
133
  * empty, refused when it names no value.
134
134
  */
135
135
  export function inputStabilityEnv(): InputStability {
136
- const strategy = settingEnv('cache.inputStability.strategy') as InputStability['strategy']
137
- const severity = settingEnv('cache.inputStability.severity') as InputStability['severity']
136
+ const strategy = settingEnv('inputs.stability.strategy') as InputStability['strategy']
137
+ const severity = settingEnv('inputs.stability.severity') as InputStability['severity']
138
138
  return {
139
139
  ...(strategy !== undefined ? { strategy } : {}),
140
140
  ...(severity !== undefined ? { severity } : {}),
@@ -231,6 +231,8 @@ function ruleSelectorProblem(s: string): string | null {
231
231
  // The root's own name, which the filter reads as the root's directory.
232
232
  if (s === ROOT_NAME) return null
233
233
  if (s.startsWith('!')) return "starts with '!', which a filter reads as an exclusion"
234
+ // The filter parser refuses one, which the catch below would read as a walk.
235
+ if (hasControl(s)) return 'holds a control character'
234
236
  const walk = "starts or ends with '...', which a filter reads as a dependency walk"
235
237
  let f: ReturnType<typeof parseFilter>
236
238
  try {
@@ -440,6 +442,7 @@ export function validateWorkspace(config: WorkspaceConfig, configPath: string):
440
442
  files?: unknown
441
443
  affected?: unknown
442
444
  extraFiles?: unknown
445
+ digests?: unknown
443
446
  } | null
444
447
  if (
445
448
  claim === null ||
@@ -447,10 +450,11 @@ export function validateWorkspace(config: WorkspaceConfig, configPath: string):
447
450
  !Array.isArray(claim.files) ||
448
451
  claim.files.length === 0 ||
449
452
  typeof claim.affected !== 'function' ||
450
- (claim.extraFiles !== undefined && typeof claim.extraFiles !== 'function')
453
+ (claim.extraFiles !== undefined && typeof claim.extraFiles !== 'function') ||
454
+ (claim.digests !== undefined && typeof claim.digests !== 'function')
451
455
  ) {
452
456
  throw new UserError(
453
- `${configPath}: \`plugins[${i}].fingerprint\` of plugin '${pkg}' must be { files: [name, …], affected: function, extraFiles?: function }`,
457
+ `${configPath}: \`plugins[${i}].fingerprint\` of plugin '${pkg}' must be { files: [name, …], affected: function, extraFiles?: function, digests?: function }`,
454
458
  )
455
459
  }
456
460
  for (const file of claim.files as unknown[]) {
@@ -588,6 +592,9 @@ export function validateProjectConfig(
588
592
  const exec = (task as { exec?: unknown }).exec
589
593
  const dependsOn = (task as { dependsOn?: unknown }).dependsOn
590
594
  const cache = (task as { cache?: unknown }).cache
595
+ if (cache !== undefined && typeof cache !== 'boolean') {
596
+ throw new UserError(`${where}.cache must be true or false`)
597
+ }
591
598
  if (exec !== undefined) {
592
599
  if (typeof exec !== 'object' || exec === null) {
593
600
  throw new UserError(`${where}.exec must be an object with a \`command\` string`)
@@ -621,9 +628,9 @@ export function validateProjectConfig(
621
628
  // Documented and never held: an uncached 'only' task loaded, and the
622
629
  // REAPI executor refused it at run time for want of described inputs
623
630
  // (item 1001).
624
- if (remote === 'only' && cache === undefined) {
631
+ if (remote === 'only' && cache !== true) {
625
632
  throw new UserError(
626
- `${where}.exec.remote 'only' needs \`cache\`: its inputs are what a worker reproduces ` +
633
+ `${where}.exec.remote 'only' needs \`cache: true\`: its inputs are what a worker reproduces ` +
627
634
  `and its key is the address of its remote record`,
628
635
  )
629
636
  }
@@ -710,7 +717,7 @@ export function validateProjectConfig(
710
717
  throw new UserError(`${where}.exec.interactive must be a boolean (or omitted)`)
711
718
  }
712
719
  if (interactive === true) {
713
- if (cache !== undefined) {
720
+ if (cache === true) {
714
721
  throw new UserError(
715
722
  `${where}: \`cache\` is not allowed on an interactive task — what it does ` +
716
723
  `depends on what is typed, which no key holds`,
@@ -763,7 +770,7 @@ export function validateProjectConfig(
763
770
  )
764
771
  }
765
772
  }
766
- if (cache !== undefined) {
773
+ if (cache === true) {
767
774
  throw new UserError(
768
775
  `${where}: \`cache\` is not allowed on a persistent task — persistent tasks ` +
769
776
  `don't terminate, so there's no exit to cache`,
@@ -941,16 +948,8 @@ export function validateProjectConfig(
941
948
  validateWorkspaceGlobs(wsInputs, `${where}.inputs.workspaceFiles`, true)
942
949
  }
943
950
  }
944
- if (cache !== undefined) {
945
- if (typeof cache !== 'object' || cache === null) {
946
- throw new UserError(
947
- `${where}.cache must be an object when present — \`inputs: { files: [...] }, cache: { outputs: { files: [...] } }\`, or no \`cache\` for a task that never caches`,
948
- )
949
- }
950
- assertKnownFields(cache, CACHE_FIELDS, `${where}.cache`)
951
- const stability = (cache as { inputStability?: unknown }).inputStability
952
- if (stability !== undefined)
953
- validateInputStability(stability, `${where}.cache.inputStability`)
951
+ const outputs = (task as { outputs?: unknown }).outputs
952
+ if (cache === true) {
954
953
  // The key is the task's inputs, and a group runs nothing to cache.
955
954
  if (exec === undefined) {
956
955
  throw new UserError(
@@ -962,23 +961,38 @@ export function validateProjectConfig(
962
961
  `${where}: \`cache\` needs \`inputs\` — the key is the task's inputs, \`inputs: { files: [...] }\``,
963
962
  )
964
963
  }
965
- const outputs = (cache as { outputs?: unknown }).outputs
966
- if (outputs === undefined || outputs === null) {
967
- throw new UserError(`${where}.cache.outputs is required when \`cache\` is set`)
964
+ if (outputs === undefined) {
965
+ throw new UserError(
966
+ `${where}: \`cache\` needs \`outputs\` — what the task produces, \`outputs: { files: [...] }\` (\`[]\` for none)`,
967
+ )
968
+ }
969
+ }
970
+ const stability = (inputs as { stability?: unknown } | undefined)?.stability
971
+ if (stability !== undefined) {
972
+ validateInputStability(stability, `${where}.inputs.stability`)
973
+ if (exec === undefined) {
974
+ throw new UserError(
975
+ `${where}.inputs.stability needs \`exec\` — a task with no command runs nothing that could write its inputs`,
976
+ )
977
+ }
978
+ }
979
+ if (outputs !== undefined) {
980
+ if (outputs === null || typeof outputs !== 'object') {
981
+ throw new UserError(`${where}.outputs must be an object — \`outputs: { files: [...] }\``)
968
982
  }
969
- if (typeof outputs !== 'object') {
983
+ assertKnownFields(outputs, OUTPUT_FIELDS, `${where}.outputs`)
984
+ if (exec === undefined) {
970
985
  throw new UserError(
971
- `${where}.cache.outputs must be an object — \`outputs: { files: [...] }\``,
986
+ `${where}.outputs needs \`exec\` — a task with no command writes nothing`,
972
987
  )
973
988
  }
974
- assertKnownFields(outputs, CACHE_OUTPUT_FIELDS, `${where}.cache.outputs`)
975
989
  const clean = (outputs as { clean?: unknown }).clean
976
990
  if (clean !== undefined && typeof clean !== 'boolean') {
977
- throw new UserError(`${where}.cache.outputs.clean must be true or false`)
991
+ throw new UserError(`${where}.outputs.clean must be true or false`)
978
992
  }
979
993
  const outFiles = (outputs as { files?: unknown }).files
980
994
  if (!Array.isArray(outFiles)) {
981
- throw new UserError(`${where}.cache.outputs.files must be an array of glob strings`)
995
+ throw new UserError(`${where}.outputs.files must be an array of glob strings`)
982
996
  }
983
997
  // Reject zero-length strings and absolute paths up front.
984
998
  // Both reach `resolveOutputs` as undefined behavior; the error
@@ -986,26 +1000,26 @@ export function validateProjectConfig(
986
1000
  // at the user's config. Fail loud at load time.
987
1001
  for (const g of outFiles as unknown[]) {
988
1002
  if (typeof g !== 'string' || g.length === 0) {
989
- throw new UserError(`${where}.cache.outputs.files must be an array of non-empty strings`)
1003
+ throw new UserError(`${where}.outputs.files must be an array of non-empty strings`)
990
1004
  }
991
1005
  if (isAbsoluteGlob(g)) {
992
1006
  throw new UserError(
993
- `${where}.cache.outputs.files: absolute paths are not allowed (got "${g}") — ` +
1007
+ `${where}.outputs.files: absolute paths are not allowed (got "${g}") — ` +
994
1008
  `outputs must be project-relative globs`,
995
1009
  )
996
1010
  }
997
1011
  if (hasParentSegment(g)) {
998
1012
  throw new UserError(
999
- `${where}.cache.outputs.files: '..' path segments are not allowed (got "${g}") — ` +
1013
+ `${where}.outputs.files: '..' path segments are not allowed (got "${g}") — ` +
1000
1014
  `outputs must stay within the project (vx clears a task's outputs before it runs, ` +
1001
1015
  `so a glob that escapes the project would delete files outside it)`,
1002
1016
  )
1003
1017
  }
1004
- assertPosixGlob(g, `${where}.cache.outputs.files`, 'project')
1005
- assertNoForeignToken(g, `${where}.cache.outputs.files`)
1018
+ assertPosixGlob(g, `${where}.outputs.files`, 'project')
1019
+ assertNoForeignToken(g, `${where}.outputs.files`)
1006
1020
  if (namesDirItself(g)) {
1007
1021
  throw new UserError(
1008
- `${where}.cache.outputs.files: "${g}" names the project directory itself and selects nothing — ` +
1022
+ `${where}.outputs.files: "${g}" names the project directory itself and selects nothing — ` +
1009
1023
  `name the directory the task writes, such as "dist/**"`,
1010
1024
  )
1011
1025
  }
@@ -1014,31 +1028,31 @@ export function validateProjectConfig(
1014
1028
  // a "corrupt artifact", dropped and run again.
1015
1029
  if (!g.startsWith('!') && normalizeGlob(g).split('/')[0] === 'workspace-outputs') {
1016
1030
  throw new UserError(
1017
- `${where}.cache.outputs.files: "${g}" is under workspace-outputs/, a name vx's ` +
1031
+ `${where}.outputs.files: "${g}" is under workspace-outputs/, a name vx's ` +
1018
1032
  `artifacts reserve for outputs.workspaceFiles — write the task's files to another directory`,
1019
1033
  )
1020
1034
  }
1021
1035
  // A `!` entry takes a path back from the outputs (A-44): it is not
1022
1036
  // cleaned, saved or restored, and it stays an input.
1023
- assertNotDoubleNegated(g, `${where}.cache.outputs.files`)
1037
+ assertNotDoubleNegated(g, `${where}.outputs.files`)
1024
1038
  // Taken back by a `!`, the file is never cleaned or restored.
1025
1039
  const own = g.startsWith('!')
1026
1040
  ? undefined
1027
1041
  : ownFilesCovered(g, ownConfig).find((f) => !takenBack(outFiles as unknown[], f))
1028
1042
  if (own !== undefined) {
1029
1043
  throw new UserError(
1030
- `${where}.cache.outputs.files: "${g}" covers the project's own ${own} — vx deletes a ` +
1044
+ `${where}.outputs.files: "${g}" covers the project's own ${own} — vx deletes a ` +
1031
1045
  `task's outputs before it runs and restores them on a hit, so the project would lose ` +
1032
1046
  `its config. Name the directory the task writes, such as "dist/**", ` +
1033
1047
  `or take the file back with "!${own}".`,
1034
1048
  )
1035
1049
  }
1036
1050
  }
1037
- assertOutputsNotNegationOnly(outFiles as string[], `${where}.cache.outputs.files`)
1038
- assertNoLiteralTakenBack(outFiles as string[], `${where}.cache.outputs.files`, OUTPUT_LOST)
1051
+ assertOutputsNotNegationOnly(outFiles as string[], `${where}.outputs.files`)
1052
+ assertNoLiteralTakenBack(outFiles as string[], `${where}.outputs.files`, OUTPUT_LOST)
1039
1053
  const wsOutputs = (outputs as { workspaceFiles?: unknown }).workspaceFiles
1040
1054
  if (wsOutputs !== undefined) {
1041
- validateWorkspaceGlobs(wsOutputs, `${where}.cache.outputs.workspaceFiles`, false)
1055
+ validateWorkspaceGlobs(wsOutputs, `${where}.outputs.workspaceFiles`, false)
1042
1056
  }
1043
1057
  }
1044
1058
  }
@@ -1053,7 +1067,7 @@ export function validateProjectConfig(
1053
1067
  // `tasks` first: `nearest` breaks a tie by order, and `taks` is one edit
1054
1068
  // from both.
1055
1069
  const PROJECT_FIELDS = new Set(['name', 'tasks', 'tags'])
1056
- const TASK_FIELDS = new Set(['description', 'exec', 'dependsOn', 'inputs', 'cache'])
1070
+ const TASK_FIELDS = new Set(['description', 'exec', 'dependsOn', 'inputs', 'outputs', 'cache'])
1057
1071
  const EXEC_FIELDS = new Set([
1058
1072
  'command',
1059
1073
  'env',
@@ -1067,7 +1081,6 @@ const EXEC_FIELDS = new Set([
1067
1081
  ])
1068
1082
  const PERSISTENT_FIELDS = new Set(['readyWhen'])
1069
1083
  const ENV_FIELDS = new Set(['passThrough', 'define', 'secret'])
1070
- const CACHE_FIELDS = new Set(['outputs', 'inputStability'])
1071
1084
  const INPUT_FIELDS = new Set([
1072
1085
  'files',
1073
1086
  'workspaceFiles',
@@ -1075,8 +1088,9 @@ const INPUT_FIELDS = new Set([
1075
1088
  'tasks',
1076
1089
  'runtime',
1077
1090
  'workspaceRuntime',
1091
+ 'stability',
1078
1092
  ])
1079
- const CACHE_OUTPUT_FIELDS = new Set(['files', 'workspaceFiles', 'clean'])
1093
+ const OUTPUT_FIELDS = new Set(['files', 'workspaceFiles', 'clean'])
1080
1094
 
1081
1095
  /**
1082
1096
  * Another runner's spelling of a field vx has under another name, by the
@@ -1088,8 +1102,10 @@ const FOREIGN_FIELDS: ReadonlyMap<ReadonlySet<string>, Readonly<Record<string, s
1088
1102
  [
1089
1103
  TASK_FIELDS,
1090
1104
  {
1091
- outputs: '`cache.outputs.files`',
1092
1105
  env: '`inputs.env` (to key the task on a variable) or `exec.env` (to pass one)',
1106
+ // wireit's script keys.
1107
+ output: '`outputs.files`',
1108
+ dependencies: "`dependsOn` (an upstream's key goes into this one)",
1093
1109
  passThroughEnv: '`exec.env.passThrough`',
1094
1110
  command: '`exec.command`',
1095
1111
  cmd: '`exec.command`',
@@ -1126,18 +1142,6 @@ const FOREIGN_FIELDS: ReadonlyMap<ReadonlySet<string>, Readonly<Record<string, s
1126
1142
  shell: 'no field: `command` always runs in a shell',
1127
1143
  },
1128
1144
  ],
1129
- // wireit's `files` / `output` and include-style spellings written into
1130
- // a cache block, refused with no word on where they go (D-118).
1131
- [
1132
- CACHE_FIELDS,
1133
- {
1134
- files: "the task's `inputs.files`",
1135
- output: '`outputs.files`',
1136
- env: "the task's `inputs.env`",
1137
- dependencies: "the task's `dependsOn` (an upstream's key goes into this one)",
1138
- enabled: 'no field: a task with a `cache` block caches, one without runs every time',
1139
- },
1140
- ],
1141
1145
  [
1142
1146
  INPUT_FIELDS,
1143
1147
  {
@@ -1149,7 +1153,7 @@ const FOREIGN_FIELDS: ReadonlyMap<ReadonlySet<string>, Readonly<Record<string, s
1149
1153
  },
1150
1154
  ],
1151
1155
  [
1152
- CACHE_OUTPUT_FIELDS,
1156
+ OUTPUT_FIELDS,
1153
1157
  {
1154
1158
  globs: '`files`',
1155
1159
  include: '`files`',
@@ -1174,12 +1178,11 @@ const FOREIGN_FIELDS: ReadonlyMap<ReadonlySet<string>, Readonly<Record<string, s
1174
1178
  sourceRoot: "a task's `inputs.files`",
1175
1179
  projectType: 'no field: vx runs every project alike',
1176
1180
  ...Object.fromEntries(
1177
- ['dependsOn', 'inputs', 'cache', 'exec', 'description'].map((k) => [
1181
+ ['dependsOn', 'inputs', 'outputs', 'cache', 'exec', 'description'].map((k) => [
1178
1182
  k,
1179
1183
  `\`tasks.<name>.${k}\`: a project holds tasks, and each task its own \`${k}\``,
1180
1184
  ]),
1181
1185
  ),
1182
- outputs: '`tasks.<name>.cache.outputs.files`',
1183
1186
  env: '`tasks.<name>.inputs.env` (to key a task on it) or `tasks.<name>.exec.env`',
1184
1187
  },
1185
1188
  ],
@@ -1250,7 +1253,7 @@ function nestedHome(allowed: ReadonlySet<string>, key: string): string | undefin
1250
1253
  for (const [field, own] of [
1251
1254
  ['exec', EXEC_FIELDS],
1252
1255
  ['inputs', INPUT_FIELDS],
1253
- ['cache', CACHE_FIELDS],
1256
+ ['outputs', OUTPUT_FIELDS],
1254
1257
  ] as const) {
1255
1258
  if (own.has(key)) return `\`${field}.${key}\``
1256
1259
  }
@@ -1536,7 +1539,7 @@ function assertInputGlob(glob: string, where: string): void {
1536
1539
  if (glob.replace(/^!/, '').split('/').includes('.git')) {
1537
1540
  throw new UserError(
1538
1541
  `${where}: "${glob}" names a path under .git, which git never lists, so it keys nothing — ` +
1539
- `inputs are the repository's files`,
1542
+ `inputs are the workspace's files`,
1540
1543
  )
1541
1544
  }
1542
1545
  }
@@ -1697,6 +1700,9 @@ export function taskNameProblem(name: string): string | null {
1697
1700
  function tagProblem(tag: string): string | null {
1698
1701
  if (tag.trim() !== tag) return 'has surrounding whitespace'
1699
1702
  if (tag.includes('*')) return "holds '*', which makes it a pattern"
1703
+ // Refused by the caller in its own words; the filter parser refuses one
1704
+ // too, which the catch below would read as a walk.
1705
+ if (hasControl(tag)) return null
1700
1706
  const walk = "ends in '...', which a filter reads as a dependency walk"
1701
1707
  let f: ReturnType<typeof parseFilter>
1702
1708
  try {
@@ -1826,6 +1832,7 @@ const SANDBOX_FIELDS = new Set([
1826
1832
  'ignore',
1827
1833
  'weakerWhenNested',
1828
1834
  'weakerNetworkIsolation',
1835
+ 'hermetic',
1829
1836
  ])
1830
1837
  const GRANT_PATH_FIELDS = ['read', 'write'] as const
1831
1838
  const GRANT_NAME_FIELDS = ['systemInfo', 'machLookup'] as const
@@ -1869,7 +1876,7 @@ function validateSandbox(sandbox: unknown, where: string): void {
1869
1876
  assertKnownFields(sandbox, SANDBOX_FIELDS, `${where}.sandbox`)
1870
1877
  const obj = sandbox as Record<string, unknown>
1871
1878
 
1872
- for (const flag of ['weakerWhenNested', 'weakerNetworkIsolation']) {
1879
+ for (const flag of ['weakerWhenNested', 'weakerNetworkIsolation', 'hermetic']) {
1873
1880
  if (obj[flag] !== undefined && typeof obj[flag] !== 'boolean') {
1874
1881
  throw new UserError(`${where}.sandbox.${flag} must be a boolean`)
1875
1882
  }