@vzn/vx 0.0.639 → 0.0.641

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 (88) hide show
  1. package/README.md +1 -1
  2. package/docs/caching.md +158 -106
  3. package/docs/cli.md +80 -45
  4. package/docs/execution.md +11 -10
  5. package/docs/features.md +9 -8
  6. package/docs/patterns.md +1 -1
  7. package/docs/schema.md +252 -190
  8. package/docs/security.md +5 -4
  9. package/launcher.sh +9 -2
  10. package/package.json +5 -5
  11. package/schemas/show.json +14 -14
  12. package/skills/vx/SKILL.md +4 -3
  13. package/src/bin.ts +8 -6
  14. package/src/cache/archive.ts +248 -41
  15. package/src/cache/cache.ts +46 -19
  16. package/src/cache/file-hashes.ts +7 -12
  17. package/src/cache/fs-clock.ts +62 -0
  18. package/src/cache/git-inputs.ts +2 -2
  19. package/src/cache/index.ts +6 -0
  20. package/src/cache/inputs.ts +228 -111
  21. package/src/cache/key-fold.ts +23 -3
  22. package/src/cache/layer.ts +15 -8
  23. package/src/cache/layered-cache.ts +8 -7
  24. package/src/cache/linked-inputs.ts +87 -0
  25. package/src/cache/scoped-cache.ts +8 -2
  26. package/src/cli/foreign-flags.ts +1 -1
  27. package/src/cli/help.ts +7 -1
  28. package/src/cli/init.ts +23 -11
  29. package/src/cli/last.ts +24 -1
  30. package/src/cli/plugin-templates.ts +2 -2
  31. package/src/cli/select.ts +16 -6
  32. package/src/cli/show.ts +9 -6
  33. package/src/cli/upgrade.ts +132 -21
  34. package/src/cli/watch-filter.ts +23 -12
  35. package/src/cli/watch-fs.ts +2 -1
  36. package/src/cli/watch-judge.ts +49 -5
  37. package/src/cli/watch-set.ts +18 -8
  38. package/src/cli/watch.ts +74 -2
  39. package/src/cli/why.ts +5 -3
  40. package/src/config.ts +19 -15
  41. package/src/exec/executor.ts +4 -4
  42. package/src/graph/dependency-spec.ts +2 -2
  43. package/src/graph/folds.ts +236 -0
  44. package/src/graph/index.ts +8 -1
  45. package/src/graph/scheduler.ts +41 -15
  46. package/src/graph/task-graph.ts +135 -73
  47. package/src/handoff.ts +5 -0
  48. package/src/index.ts +2 -2
  49. package/src/orchestrator/affected-tasks.ts +20 -19
  50. package/src/orchestrator/download-policy.ts +16 -22
  51. package/src/orchestrator/excluded-keys.ts +4 -5
  52. package/src/orchestrator/execute-task.ts +29 -25
  53. package/src/orchestrator/hit-restore.ts +101 -27
  54. package/src/orchestrator/index.ts +1 -0
  55. package/src/orchestrator/keyed-projects.ts +12 -51
  56. package/src/orchestrator/local-shortcircuit.ts +9 -1
  57. package/src/orchestrator/persistent-task.ts +3 -3
  58. package/src/orchestrator/placement.ts +2 -2
  59. package/src/orchestrator/plan.ts +1 -1
  60. package/src/orchestrator/plugin-host.ts +66 -27
  61. package/src/orchestrator/plugin.ts +12 -2
  62. package/src/orchestrator/prepare.ts +12 -5
  63. package/src/orchestrator/projects.ts +7 -10
  64. package/src/orchestrator/run-report.ts +145 -46
  65. package/src/orchestrator/run-steps.ts +57 -0
  66. package/src/orchestrator/run.ts +76 -44
  67. package/src/orchestrator/sandbox-fix.ts +1 -1
  68. package/src/orchestrator/sandbox-request.ts +2 -2
  69. package/src/orchestrator/stable-keys.ts +59 -26
  70. package/src/orchestrator/steps.ts +26 -6
  71. package/src/orchestrator/task-hash.ts +31 -9
  72. package/src/orchestrator/telemetry-host.ts +3 -3
  73. package/src/orchestrator/telemetry.ts +9 -4
  74. package/src/orchestrator/upstream.ts +4 -181
  75. package/src/report/framed-output.ts +1 -1
  76. package/src/report/logger.ts +5 -2
  77. package/src/report/status-line.ts +7 -3
  78. package/src/report/summary.ts +6 -4
  79. package/src/settings.ts +11 -17
  80. package/src/util/index.ts +3 -0
  81. package/src/util/paths.ts +39 -5
  82. package/src/workspace/affected.ts +81 -2
  83. package/src/workspace/config-eval.ts +2 -2
  84. package/src/workspace/config-imports.ts +1 -1
  85. package/src/workspace/config-schema.ts +142 -143
  86. package/src/workspace/lockfile.ts +1 -1
  87. package/src/workspace/migration.ts +19 -3
  88. package/src/workspace/project-loader.ts +5 -4
@@ -6,7 +6,7 @@
6
6
  // index + unstaged — so it captures everything you touched and nothing
7
7
  // the base branch moved on with. Matches Turbo's `[<since>]` semantics.
8
8
 
9
- import { realpath } from 'node:fs/promises'
9
+ import { realpath, stat } from 'node:fs/promises'
10
10
  import path from 'node:path'
11
11
  import {
12
12
  executablePath,
@@ -75,7 +75,7 @@ export interface AffectedArgs {
75
75
  since: string
76
76
  projects: readonly ProjectMeta[]
77
77
  /**
78
- * Which projects declare a `cache.inputs.workspaceFiles` glob matching one of
78
+ * Which projects declare a `inputs.workspaceFiles` glob matching one of
79
79
  * these workspace-relative paths.
80
80
  *
81
81
  * A workspace-anchored glob is the documented escape hatch for inputs
@@ -107,6 +107,12 @@ export interface AffectedArgs {
107
107
  * run then paid again (I-26).
108
108
  */
109
109
  untracked?: () => Promise<readonly string[] | null>
110
+ /**
111
+ * The tracked symlinks, workspace-relative, from the run's enumeration
112
+ * (its index OIDs spell one `120000:`); null or absent spawns `git ls-files
113
+ * -s` here. A changed or untracked link is a changed path already.
114
+ */
115
+ links?: () => Promise<readonly string[] | null>
110
116
  }
111
117
 
112
118
  export interface FingerprintClaims {
@@ -253,6 +259,16 @@ export async function affectedChanges(
253
259
  const changed = [...diffed.paths, ...untracked.filter((s) => !isInstalledPath(s))].filter(
254
260
  (s) => s !== LOCKFILE_NAME,
255
261
  )
262
+ // A symlinked input keys the content behind it (cache/linked-inputs.ts),
263
+ // and the diff names that content's path, never the link's: a link whose
264
+ // target changed is a changed path itself.
265
+ changed.push(
266
+ ...(await linkedChanges(
267
+ args.workspaceRoot,
268
+ (await args.links?.().catch(() => null)) ?? (await trackedLinks(args.workspaceRoot)),
269
+ changed,
270
+ )),
271
+ )
256
272
  // The nested repositories git reports as one path: a gitlink, or an
257
273
  // untracked embedded repository (`dir/`, the only path git prints so).
258
274
  const nested = new Set(diffed.gitlinks)
@@ -503,6 +519,69 @@ async function bytesOrNull(file: string): Promise<Uint8Array | null> {
503
519
  return (await f.exists()) ? await f.bytes() : null
504
520
  }
505
521
 
522
+ /** The symlinks the index lists, workspace-relative. */
523
+ async function trackedLinks(workspaceRoot: string): Promise<string[]> {
524
+ const out: string[] = []
525
+ for (const record of await gitPaths(workspaceRoot, ['ls-files', '-s', '-z'])) {
526
+ if (record.startsWith('120000 ')) out.push(record.slice(record.indexOf('\t') + 1))
527
+ }
528
+ return out
529
+ }
530
+
531
+ /**
532
+ * The links in `links` whose target changed: a file `changed` names, or a
533
+ * directory holding one. A target git cannot diff is changed on every run:
534
+ * one outside the workspace, or git-ignored (or ignored files under a linked
535
+ * directory), since the key reads it whatever git says. A dangling link
536
+ * reads nothing; retargeting it is a change to the link itself.
537
+ */
538
+ async function linkedChanges(
539
+ workspaceRoot: string,
540
+ links: readonly string[],
541
+ changed: readonly string[],
542
+ ): Promise<string[]> {
543
+ if (links.length === 0) return []
544
+ const realRoot = await realpath(workspaceRoot).catch(() => workspaceRoot)
545
+ const out: string[] = []
546
+ const inside: Array<{ link: string; target: string; dir: boolean }> = []
547
+ for (const link of links) {
548
+ let real: string
549
+ let dir: boolean
550
+ try {
551
+ real = await realpath(path.join(workspaceRoot, link))
552
+ dir = (await stat(real)).isDirectory()
553
+ } catch {
554
+ continue
555
+ }
556
+ const rel = path.relative(realRoot, real)
557
+ if (rel.startsWith('..') || path.isAbsolute(rel)) out.push(link)
558
+ else inside.push({ link, target: rel.split(path.sep).join('/'), dir })
559
+ }
560
+ if (inside.length === 0) return out
561
+ const named = inside.map((l) => l.target).filter((t) => t !== '')
562
+ const ignored = await gitPaths(workspaceRoot, [
563
+ 'ls-files',
564
+ '--others',
565
+ '--ignored',
566
+ '--exclude-standard',
567
+ '--directory',
568
+ '-z',
569
+ '--',
570
+ ...(named.length === inside.length ? named : ['.']),
571
+ ])
572
+ const changedSet = new Set(changed)
573
+ const under = (p: string, dir: string): boolean => dir === '' || p.startsWith(`${dir}/`)
574
+ for (const { link, target, dir } of inside) {
575
+ const unseen = ignored.some((i) => {
576
+ const at = i.replace(/\/$/, '')
577
+ return at === target || under(target, at) || (dir && under(at, target))
578
+ })
579
+ const hit = dir ? changed.some((c) => under(c, target)) : changedSet.has(target)
580
+ if (unseen || hit) out.push(link)
581
+ }
582
+ return out
583
+ }
584
+
506
585
  /** Run a NUL-separated path-listing git command from the workspace root. */
507
586
  async function gitPaths(workspaceRoot: string, cmd: string[]): Promise<string[]> {
508
587
  const proc = spawnGit([...cmd], workspaceRoot)
@@ -103,7 +103,7 @@ const nonJsonPaths = ${nonJsonPaths.toString()}
103
103
  throw new Error('process.exit(' + (code ?? '') + ') in a config: ' + ${JSON.stringify(CONFIG_EXIT)})
104
104
  }
105
105
  }
106
- // Bun's resolver reads \\ as a separator, so a config under a\\b is served
106
+ // Bun's resolver reads \\ as a separator and ? as a query, so a config under a\\b or a?b is served
107
107
  // from the path as written (project-loader.ts's LITERAL_QUERY), read now.
108
108
  let literal = false
109
109
  const serveLiteral = () => {
@@ -172,7 +172,7 @@ self.onmessage = async (e) => {
172
172
  if (umaskIn !== null && globalThis.process.umask() !== umaskIn) globalThis.process.umask(umaskIn)
173
173
  }
174
174
  try {
175
- const literalPath = path.includes('\\\\')
175
+ const literalPath = /[\\\\?]/.test(path)
176
176
  if (literalPath) serveLiteral()
177
177
  const ns = await import(literalPath ? path + '?vx-literal' : path)
178
178
  // Awaited as the in-process load's async return flattens it: a Promise
@@ -1,7 +1,7 @@
1
1
  // The third `changed file → project` channel for `--affected`.
2
2
  //
3
3
  // Directory containment answers "which project owns this file", and
4
- // `cache.inputs.workspaceFiles` answers "which project declared it". Neither
4
+ // `inputs.workspaceFiles` answers "which project declared it". Neither
5
5
  // can see the one remaining way a file reaches a task: a project's
6
6
  // `vx.config.*` IMPORTS it. Resolved-config hashing folds the imported values
7
7
  // into the cache key (architecture principle #4), so editing such a file
@@ -567,7 +567,7 @@ export function validateProjectConfig(
567
567
  )
568
568
  }
569
569
  // `VITE_*` passed nothing through and loaded clean (Turbo's
570
- // passThroughEnv expands it), where `cache.inputs.env` refused the
570
+ // passThroughEnv expands it), where `inputs.env` refused the
571
571
  // same text below: one refusal for the class (item 1090).
572
572
  assertNoEnvNegation(passThrough as string[], `${where}.exec.env.passThrough`)
573
573
  const wild = (passThrough as string[]).find((n) => BUN_GLOB_WILDCARDS.test(n))
@@ -691,11 +691,11 @@ export function validateProjectConfig(
691
691
  // so a dependant's `^build` finds it and waits on nothing
692
692
  // (the quickstart). The comment here used to call that case
693
693
  // "almost certainly a config mistake" while the docs taught it.
694
- // A keyed group (`cache` with no `exec`) is a declaration on its own:
694
+ // A keyed group (`inputs` with no `exec`) is a declaration on its own:
695
695
  // its inputs are a key that dependants fold, with no process.
696
- if (dependsOn === undefined && cache === undefined) {
696
+ if (dependsOn === undefined && (task as { inputs?: unknown }).inputs === undefined) {
697
697
  throw new UserError(
698
- `${where}: a task with no \`exec\` must declare \`dependsOn\` or \`cache.inputs\` ` +
698
+ `${where}: a task with no \`exec\` must declare \`dependsOn\` or \`inputs\` ` +
699
699
  `(group tasks exist to chain dependencies or carry a key)`,
700
700
  )
701
701
  }
@@ -712,31 +712,14 @@ export function validateProjectConfig(
712
712
  )
713
713
  }
714
714
  }
715
- if (cache !== undefined) {
716
- if (typeof cache !== 'object' || cache === null) {
717
- throw new UserError(
718
- `${where}.cache must be an object when present — \`cache: { inputs: { files: [...] }, outputs: { files: [...] } }\`, or no \`cache\` for a task that never caches`,
719
- )
720
- }
721
- assertKnownFields(cache, CACHE_FIELDS, `${where}.cache`)
722
- const stability = (cache as { inputStability?: unknown }).inputStability
723
- if (stability !== undefined)
724
- validateInputStability(stability, `${where}.cache.inputStability`)
725
- const inputs = (cache as { inputs?: unknown }).inputs
726
- const outputs = (cache as { outputs?: unknown }).outputs
727
- // A present field of the wrong shape (`outputs: 'dist'`) was told it
728
- // was missing; an array is named in `assertKnownFields`.
729
- if (inputs === undefined || inputs === null) {
730
- throw new UserError(`${where}.cache.inputs is required when \`cache\` is set`)
715
+ const inputs = (task as { inputs?: unknown }).inputs
716
+ if (inputs !== undefined) {
717
+ if (typeof inputs !== 'object' || inputs === null) {
718
+ throw new UserError(`${where}.inputs must be an object — \`inputs: { files: [...] }\``)
731
719
  }
732
- if (typeof inputs !== 'object') {
733
- throw new UserError(
734
- `${where}.cache.inputs must be an object — \`inputs: { files: [...] }\``,
735
- )
736
- }
737
- assertKnownFields(inputs, CACHE_INPUT_FIELDS, `${where}.cache.inputs`)
720
+ assertKnownFields(inputs, INPUT_FIELDS, `${where}.inputs`)
738
721
  if (!Array.isArray((inputs as { files?: unknown }).files)) {
739
- throw new UserError(`${where}.cache.inputs.files must be an array of glob strings`)
722
+ throw new UserError(`${where}.inputs.files must be an array of glob strings`)
740
723
  }
741
724
  const envList = (inputs as { env?: unknown }).env
742
725
  if (envList !== undefined) {
@@ -746,10 +729,10 @@ export function validateProjectConfig(
746
729
  envList.some((s) => !isEnvName(s))
747
730
  ) {
748
731
  throw new UserError(
749
- `${where}.cache.inputs.env must be an array of env var names (non-empty, no '=' or NUL)`,
732
+ `${where}.inputs.env must be an array of env var names (non-empty, no '=' or NUL)`,
750
733
  )
751
734
  }
752
- assertNoEnvNegation(envList as string[], `${where}.cache.inputs.env`)
735
+ assertNoEnvNegation(envList as string[], `${where}.inputs.env`)
753
736
  for (const name of envList as string[]) {
754
737
  // Reject wildcards explicitly so users don't silently miss
755
738
  // env vars they thought they were tracking. Turbo supports
@@ -761,107 +744,35 @@ export function validateProjectConfig(
761
744
  // whole `Bun.Glob` alphabet stays refused here.
762
745
  if (BUN_GLOB_WILDCARDS.test(name)) {
763
746
  throw new UserError(
764
- `${where}.cache.inputs.env: wildcards in env names are not supported ` +
747
+ `${where}.inputs.env: wildcards in env names are not supported ` +
765
748
  `(got "${name}") — list explicit env var names instead`,
766
749
  )
767
750
  }
768
751
  }
769
752
  }
770
- if (outputs === undefined || outputs === null) {
771
- throw new UserError(`${where}.cache.outputs is required when \`cache\` is set`)
772
- }
773
- if (typeof outputs !== 'object') {
774
- throw new UserError(
775
- `${where}.cache.outputs must be an object — \`outputs: { files: [...] }\``,
776
- )
777
- }
778
- assertKnownFields(outputs, CACHE_OUTPUT_FIELDS, `${where}.cache.outputs`)
779
- const clean = (outputs as { clean?: unknown }).clean
780
- if (clean !== undefined && typeof clean !== 'boolean') {
781
- throw new UserError(`${where}.cache.outputs.clean must be true or false`)
782
- }
783
- const outFiles = (outputs as { files?: unknown }).files
784
- if (!Array.isArray(outFiles)) {
785
- throw new UserError(`${where}.cache.outputs.files must be an array of glob strings`)
786
- }
787
- // Reject zero-length strings and absolute paths up front.
788
- // Both reach `resolveOutputs` as undefined behavior; the error
789
- // surfaces deep inside the glob resolver with no line pointing
790
- // at the user's config. Fail loud at load time.
791
- for (const g of outFiles as unknown[]) {
792
- if (typeof g !== 'string' || g.length === 0) {
793
- throw new UserError(`${where}.cache.outputs.files must be an array of non-empty strings`)
794
- }
795
- if (isAbsoluteGlob(g)) {
796
- throw new UserError(
797
- `${where}.cache.outputs.files: absolute paths are not allowed (got "${g}") — ` +
798
- `outputs must be project-relative globs`,
799
- )
800
- }
801
- if (hasParentSegment(g)) {
802
- throw new UserError(
803
- `${where}.cache.outputs.files: '..' path segments are not allowed (got "${g}") — ` +
804
- `outputs must stay within the project (a glob that escapes the project dir would ` +
805
- `let cleanOutputs delete files outside it)`,
806
- )
807
- }
808
- assertPosixGlob(g, `${where}.cache.outputs.files`, 'project')
809
- assertNoForeignToken(g, `${where}.cache.outputs.files`)
810
- if (namesDirItself(g)) {
811
- throw new UserError(
812
- `${where}.cache.outputs.files: "${g}" names the project directory itself and selects nothing — ` +
813
- `name the directory the task writes, such as "dist/**"`,
814
- )
815
- }
816
- // An artifact stores workspace outputs under `workspace-outputs/`,
817
- // and its index read a project output there as one: every hit was
818
- // a "corrupt artifact", dropped and run again.
819
- if (!g.startsWith('!') && normalizeGlob(g).split('/')[0] === 'workspace-outputs') {
820
- throw new UserError(
821
- `${where}.cache.outputs.files: "${g}" is under workspace-outputs/, a name vx's ` +
822
- `artifacts reserve for outputs.workspaceFiles — write the task's files to another directory`,
823
- )
824
- }
825
- // A `!` entry takes a path back from the outputs (A-44): it is not
826
- // cleaned, saved or restored, and it stays an input.
827
- assertNotDoubleNegated(g, `${where}.cache.outputs.files`)
828
- // Taken back by a `!`, the file is never cleaned or restored.
829
- const own = g.startsWith('!')
830
- ? undefined
831
- : ownFilesCovered(g, ownConfig).find((f) => !takenBack(outFiles as unknown[], f))
832
- if (own !== undefined) {
833
- throw new UserError(
834
- `${where}.cache.outputs.files: "${g}" covers the project's own ${own} — vx deletes a ` +
835
- `task's outputs before it runs and restores them on a hit, so the project would lose ` +
836
- `its config. Name the directory the task writes, such as "dist/**", ` +
837
- `or take the file back with "!${own}".`,
838
- )
839
- }
840
- }
841
- assertOutputsNotNegationOnly(outFiles as string[], `${where}.cache.outputs.files`)
842
753
  // Same for inputs.files.
843
754
  for (const g of (inputs as { files: unknown[] }).files) {
844
755
  if (typeof g !== 'string' || g.length === 0) {
845
- throw new UserError(`${where}.cache.inputs.files must be an array of non-empty strings`)
756
+ throw new UserError(`${where}.inputs.files must be an array of non-empty strings`)
846
757
  }
847
758
  if (!g.startsWith('!/') && isAbsoluteGlob(g)) {
848
759
  throw new UserError(
849
- `${where}.cache.inputs.files: absolute paths are not allowed (got "${g}") — ` +
760
+ `${where}.inputs.files: absolute paths are not allowed (got "${g}") — ` +
850
761
  `inputs must be project-relative globs`,
851
762
  )
852
763
  }
853
764
  if (hasParentSegment(g)) {
854
765
  throw new UserError(
855
- `${where}.cache.inputs.files: '..' path segments are not allowed (got "${g}") — ` +
766
+ `${where}.inputs.files: '..' path segments are not allowed (got "${g}") — ` +
856
767
  `inputs must be project-relative (a '..' glob silently matches nothing; ` +
857
- `use cache.inputs.workspaceFiles for workspace-root-relative inputs)`,
768
+ `use inputs.workspaceFiles for workspace-root-relative inputs)`,
858
769
  )
859
770
  }
860
- assertPosixGlob(g, `${where}.cache.inputs.files`, 'project')
861
- assertNoForeignToken(g, `${where}.cache.inputs.files`)
771
+ assertPosixGlob(g, `${where}.inputs.files`, 'project')
772
+ assertNoForeignToken(g, `${where}.inputs.files`)
862
773
  if (namesDirItself(g)) {
863
774
  throw new UserError(
864
- `${where}.cache.inputs.files: "${g}" names the project directory itself and selects nothing — use "**" for everything under it`,
775
+ `${where}.inputs.files: "${g}" names the project directory itself and selects nothing — use "**" for everything under it`,
865
776
  )
866
777
  }
867
778
  // A negated absolute path subtracts nothing (the positive globs are
@@ -869,11 +780,11 @@ export function validateProjectConfig(
869
780
  // `workspaceFiles` refuses both spellings the same way (item 679).
870
781
  if (g.startsWith('!/')) {
871
782
  throw new UserError(
872
- `${where}.cache.inputs.files: absolute paths are not allowed (got "${g}") — ` +
783
+ `${where}.inputs.files: absolute paths are not allowed (got "${g}") — ` +
873
784
  `inputs must be project-relative globs`,
874
785
  )
875
786
  }
876
- assertNotDoubleNegated(g, `${where}.cache.inputs.files`)
787
+ assertNotDoubleNegated(g, `${where}.inputs.files`)
877
788
  }
878
789
  // A list of ONLY negations selects nothing at all. `resolveFiles`
879
790
  // builds the file set from the POSITIVE globs and uses the `!` entries
@@ -885,9 +796,9 @@ export function validateProjectConfig(
885
796
  // "everything except specs", and Turbo makes exactly this a hard config
886
797
  // error for the same reason. Refusing costs nothing: such a config was
887
798
  // already silently broken, so no working key changes.
888
- assertNotNegationOnly((inputs as { files: string[] }).files, `${where}.cache.inputs.files`)
889
- assertNoLiteralTakenBack((inputs as { files: string[] }).files, `${where}.cache.inputs.files`)
890
- // The only CacheInputs field that reached the run unvalidated: a
799
+ assertNotNegationOnly((inputs as { files: string[] }).files, `${where}.inputs.files`)
800
+ assertNoLiteralTakenBack((inputs as { files: string[] }).files, `${where}.inputs.files`)
801
+ // The only InputsConfig field that reached the run unvalidated: a
891
802
  // non-string entry crashes deep in `filterUpstreamHashes` /
892
803
  // `parseDependencySpec` with a raw TypeError naming neither the task
893
804
  // nor the config. Entry SYNTAX is already checked downstream (a
@@ -900,7 +811,7 @@ export function validateProjectConfig(
900
811
  taskFilters.some((s) => typeof s !== 'string' || s.length === 0)
901
812
  ) {
902
813
  throw new UserError(
903
- `${where}.cache.inputs.tasks must be an array of non-empty strings ` +
814
+ `${where}.inputs.tasks must be an array of non-empty strings ` +
904
815
  `(Turbo/Nx micro-syntax: 'name', '^name', 'pkg#name', '*', '^*', '!name')`,
905
816
  )
906
817
  }
@@ -916,7 +827,7 @@ export function validateProjectConfig(
916
827
  list.some((s) => typeof s !== 'string' || s.trim() === '' || s.includes('\0'))
917
828
  ) {
918
829
  throw new UserError(
919
- `${where}.cache.inputs.${field} must be an array of non-empty shell command strings with no NUL`,
830
+ `${where}.inputs.${field} must be an array of non-empty shell command strings with no NUL`,
920
831
  )
921
832
  }
922
833
  }
@@ -925,17 +836,107 @@ export function validateProjectConfig(
925
836
  // never absolute (they're workspace-root-relative by definition).
926
837
  const wsInputs = (inputs as { workspaceFiles?: unknown }).workspaceFiles
927
838
  if (wsInputs !== undefined) {
928
- validateWorkspaceGlobs(wsInputs, `${where}.cache.inputs.workspaceFiles`, true)
839
+ validateWorkspaceGlobs(wsInputs, `${where}.inputs.workspaceFiles`, true)
929
840
  }
930
- const wsOutputs = (outputs as { workspaceFiles?: unknown }).workspaceFiles
931
- if (wsOutputs !== undefined) {
932
- validateWorkspaceGlobs(wsOutputs, `${where}.cache.outputs.workspaceFiles`, false)
841
+ }
842
+ if (cache !== undefined) {
843
+ if (typeof cache !== 'object' || cache === null) {
844
+ throw new UserError(
845
+ `${where}.cache must be an object when present — \`inputs: { files: [...] }, cache: { outputs: { files: [...] } }\`, or no \`cache\` for a task that never caches`,
846
+ )
847
+ }
848
+ assertKnownFields(cache, CACHE_FIELDS, `${where}.cache`)
849
+ const stability = (cache as { inputStability?: unknown }).inputStability
850
+ if (stability !== undefined)
851
+ validateInputStability(stability, `${where}.cache.inputStability`)
852
+ // The key is the task's inputs, and a group runs nothing to cache.
853
+ if (exec === undefined) {
854
+ throw new UserError(
855
+ `${where}: \`cache\` needs \`exec\` — a task with no command produces nothing to cache`,
856
+ )
933
857
  }
934
- if (exec === undefined && (outFiles.length > 0 || wsOutputs !== undefined)) {
858
+ if (inputs === undefined) {
935
859
  throw new UserError(
936
- `${where}.cache.outputs: a task with no \`exec\` writes nothing — \`outputs: { files: [] }\``,
860
+ `${where}: \`cache\` needs \`inputs\` — the key is the task's inputs, \`inputs: { files: [...] }\``,
937
861
  )
938
862
  }
863
+ const outputs = (cache as { outputs?: unknown }).outputs
864
+ if (outputs === undefined || outputs === null) {
865
+ throw new UserError(`${where}.cache.outputs is required when \`cache\` is set`)
866
+ }
867
+ if (typeof outputs !== 'object') {
868
+ throw new UserError(
869
+ `${where}.cache.outputs must be an object — \`outputs: { files: [...] }\``,
870
+ )
871
+ }
872
+ assertKnownFields(outputs, CACHE_OUTPUT_FIELDS, `${where}.cache.outputs`)
873
+ const clean = (outputs as { clean?: unknown }).clean
874
+ if (clean !== undefined && typeof clean !== 'boolean') {
875
+ throw new UserError(`${where}.cache.outputs.clean must be true or false`)
876
+ }
877
+ const outFiles = (outputs as { files?: unknown }).files
878
+ if (!Array.isArray(outFiles)) {
879
+ throw new UserError(`${where}.cache.outputs.files must be an array of glob strings`)
880
+ }
881
+ // Reject zero-length strings and absolute paths up front.
882
+ // Both reach `resolveOutputs` as undefined behavior; the error
883
+ // surfaces deep inside the glob resolver with no line pointing
884
+ // at the user's config. Fail loud at load time.
885
+ for (const g of outFiles as unknown[]) {
886
+ if (typeof g !== 'string' || g.length === 0) {
887
+ throw new UserError(`${where}.cache.outputs.files must be an array of non-empty strings`)
888
+ }
889
+ if (isAbsoluteGlob(g)) {
890
+ throw new UserError(
891
+ `${where}.cache.outputs.files: absolute paths are not allowed (got "${g}") — ` +
892
+ `outputs must be project-relative globs`,
893
+ )
894
+ }
895
+ if (hasParentSegment(g)) {
896
+ throw new UserError(
897
+ `${where}.cache.outputs.files: '..' path segments are not allowed (got "${g}") — ` +
898
+ `outputs must stay within the project (a glob that escapes the project dir would ` +
899
+ `let cleanOutputs delete files outside it)`,
900
+ )
901
+ }
902
+ assertPosixGlob(g, `${where}.cache.outputs.files`, 'project')
903
+ assertNoForeignToken(g, `${where}.cache.outputs.files`)
904
+ if (namesDirItself(g)) {
905
+ throw new UserError(
906
+ `${where}.cache.outputs.files: "${g}" names the project directory itself and selects nothing — ` +
907
+ `name the directory the task writes, such as "dist/**"`,
908
+ )
909
+ }
910
+ // An artifact stores workspace outputs under `workspace-outputs/`,
911
+ // and its index read a project output there as one: every hit was
912
+ // a "corrupt artifact", dropped and run again.
913
+ if (!g.startsWith('!') && normalizeGlob(g).split('/')[0] === 'workspace-outputs') {
914
+ throw new UserError(
915
+ `${where}.cache.outputs.files: "${g}" is under workspace-outputs/, a name vx's ` +
916
+ `artifacts reserve for outputs.workspaceFiles — write the task's files to another directory`,
917
+ )
918
+ }
919
+ // A `!` entry takes a path back from the outputs (A-44): it is not
920
+ // cleaned, saved or restored, and it stays an input.
921
+ assertNotDoubleNegated(g, `${where}.cache.outputs.files`)
922
+ // Taken back by a `!`, the file is never cleaned or restored.
923
+ const own = g.startsWith('!')
924
+ ? undefined
925
+ : ownFilesCovered(g, ownConfig).find((f) => !takenBack(outFiles as unknown[], f))
926
+ if (own !== undefined) {
927
+ throw new UserError(
928
+ `${where}.cache.outputs.files: "${g}" covers the project's own ${own} — vx deletes a ` +
929
+ `task's outputs before it runs and restores them on a hit, so the project would lose ` +
930
+ `its config. Name the directory the task writes, such as "dist/**", ` +
931
+ `or take the file back with "!${own}".`,
932
+ )
933
+ }
934
+ }
935
+ assertOutputsNotNegationOnly(outFiles as string[], `${where}.cache.outputs.files`)
936
+ const wsOutputs = (outputs as { workspaceFiles?: unknown }).workspaceFiles
937
+ if (wsOutputs !== undefined) {
938
+ validateWorkspaceGlobs(wsOutputs, `${where}.cache.outputs.workspaceFiles`, false)
939
+ }
939
940
  }
940
941
  }
941
942
  }
@@ -949,7 +950,7 @@ export function validateProjectConfig(
949
950
  // `tasks` first: `nearest` breaks a tie by order, and `taks` is one edit
950
951
  // from both.
951
952
  const PROJECT_FIELDS = new Set(['name', 'tasks', 'tags'])
952
- const TASK_FIELDS = new Set(['description', 'exec', 'dependsOn', 'cache'])
953
+ const TASK_FIELDS = new Set(['description', 'exec', 'dependsOn', 'inputs', 'cache'])
953
954
  const EXEC_FIELDS = new Set([
954
955
  'command',
955
956
  'env',
@@ -962,8 +963,8 @@ const EXEC_FIELDS = new Set([
962
963
  ])
963
964
  const PERSISTENT_FIELDS = new Set(['readyWhen'])
964
965
  const ENV_FIELDS = new Set(['passThrough', 'define', 'secret'])
965
- const CACHE_FIELDS = new Set(['inputs', 'outputs', 'inputStability'])
966
- const CACHE_INPUT_FIELDS = new Set([
966
+ const CACHE_FIELDS = new Set(['outputs', 'inputStability'])
967
+ const INPUT_FIELDS = new Set([
967
968
  'files',
968
969
  'workspaceFiles',
969
970
  'env',
@@ -983,9 +984,8 @@ const FOREIGN_FIELDS: ReadonlyMap<ReadonlySet<string>, Readonly<Record<string, s
983
984
  [
984
985
  TASK_FIELDS,
985
986
  {
986
- inputs: '`cache.inputs.files`',
987
987
  outputs: '`cache.outputs.files`',
988
- env: '`cache.inputs.env` (to key the task on a variable) or `exec.env` (to pass one)',
988
+ env: '`inputs.env` (to key the task on a variable) or `exec.env` (to pass one)',
989
989
  passThroughEnv: '`exec.env.passThrough`',
990
990
  command: '`exec.command`',
991
991
  cmd: '`exec.command`',
@@ -1027,15 +1027,15 @@ const FOREIGN_FIELDS: ReadonlyMap<ReadonlySet<string>, Readonly<Record<string, s
1027
1027
  [
1028
1028
  CACHE_FIELDS,
1029
1029
  {
1030
- files: '`inputs.files`',
1030
+ files: "the task's `inputs.files`",
1031
1031
  output: '`outputs.files`',
1032
- env: '`inputs.env`',
1032
+ env: "the task's `inputs.env`",
1033
1033
  dependencies: "the task's `dependsOn` (an upstream's key folds into this one)",
1034
1034
  enabled: 'no field: a task with a `cache` block caches, one without runs every time',
1035
1035
  },
1036
1036
  ],
1037
1037
  [
1038
- CACHE_INPUT_FIELDS,
1038
+ INPUT_FIELDS,
1039
1039
  {
1040
1040
  globs: '`files`',
1041
1041
  include: '`files`',
@@ -1065,19 +1065,18 @@ const FOREIGN_FIELDS: ReadonlyMap<ReadonlySet<string>, Readonly<Record<string, s
1065
1065
  scripts:
1066
1066
  "`tasks` (a script is a task's `exec.command`; `vx init` writes them from package.json)",
1067
1067
  pipeline: '`tasks` (`bunx @vzn/vx-migrate` writes them from turbo.json)',
1068
- namedInputs: 'a module the vx.config imports, spread into `cache.inputs.files`',
1068
+ namedInputs: 'a module the vx.config imports, spread into `inputs.files`',
1069
1069
  root: "no field: a project's directory is where its vx.config sits",
1070
- sourceRoot: "a task's `cache.inputs.files`",
1070
+ sourceRoot: "a task's `inputs.files`",
1071
1071
  projectType: 'no field: vx runs every project alike',
1072
1072
  ...Object.fromEntries(
1073
- ['dependsOn', 'cache', 'exec', 'description'].map((k) => [
1073
+ ['dependsOn', 'inputs', 'cache', 'exec', 'description'].map((k) => [
1074
1074
  k,
1075
1075
  `\`tasks.<name>.${k}\`: a project holds tasks, and each task its own \`${k}\``,
1076
1076
  ]),
1077
1077
  ),
1078
- inputs: '`tasks.<name>.cache.inputs.files`',
1079
1078
  outputs: '`tasks.<name>.cache.outputs.files`',
1080
- env: '`tasks.<name>.cache.inputs.env` (to key a task on it) or `tasks.<name>.exec.env`',
1079
+ env: '`tasks.<name>.inputs.env` (to key a task on it) or `tasks.<name>.exec.env`',
1081
1080
  },
1082
1081
  ],
1083
1082
  // Turbo's turbo.json and Nx's nx.json keys, written into .vx/workspace
@@ -1091,9 +1090,9 @@ const FOREIGN_FIELDS: ReadonlyMap<ReadonlySet<string>, Readonly<Record<string, s
1091
1090
  "each package's `vx.config` `tasks` (`bunx @vzn/vx-migrate` writes them from turbo.json)",
1092
1091
  targetDefaults:
1093
1092
  "each package's `vx.config` `tasks`, sharing defaults through an imported module",
1094
- namedInputs: 'a module each `vx.config` imports, spread into `cache.inputs.files`',
1095
- globalEnv: '`cache.inputs.env` on the tasks it keys',
1096
- globalDependencies: '`cache.inputs.workspaceFiles` on the tasks it keys',
1093
+ namedInputs: 'a module each `vx.config` imports, spread into `inputs.files`',
1094
+ globalEnv: '`inputs.env` on the tasks it keys',
1095
+ globalDependencies: '`inputs.workspaceFiles` on the tasks it keys',
1097
1096
  remoteCache:
1098
1097
  'a cache plugin in `plugins` (`turboCache()` or `nxCache()` from `@vzn/vx-migrate`)',
1099
1098
  parallel: '`run.concurrency`',
@@ -1113,7 +1112,7 @@ const FOREIGN_FIELDS: ReadonlyMap<ReadonlySet<string>, Readonly<Record<string, s
1113
1112
  "pnpm-workspace.yaml's or package.json's `catalog`, where the package manager reads it",
1114
1113
  npmClient: "no field: a task's command runs as written, with no package manager in between",
1115
1114
  remote: 'a cache plugin in `plugins`',
1116
- env: '`cache.inputs.env` on the tasks it keys, or `exec.env` on the tasks it passes to',
1115
+ env: '`inputs.env` on the tasks it keys, or `exec.env` on the tasks it passes to',
1117
1116
  },
1118
1117
  ],
1119
1118
  ])
@@ -1367,7 +1366,7 @@ function assertNotDoubleNegated(glob: string, where: string): void {
1367
1366
  }
1368
1367
 
1369
1368
  /**
1370
- * An exact `cache.inputs.tasks` entry that no `dependsOn` entry names would
1369
+ * An exact `inputs.tasks` entry that no `dependsOn` entry names would
1371
1370
  * match nothing at hash time and fold no upstream hash — the task silently
1372
1371
  * decoupled from its dependencies, which is a stale hit waiting for the
1373
1372
  * next upstream change. `['buidl']` for `['build']` is the shape. Patterns,
@@ -1407,7 +1406,7 @@ function assertFilterNamesDeclaredDeps(
1407
1406
  // `where` opens with the config's path; the message names it once.
1408
1407
  const task = where.slice(where.lastIndexOf(': tasks.') + 2)
1409
1408
  throw new UserError(
1410
- `${where}.cache.inputs.tasks: "${raw}" names no task in ${task}.dependsOn ` +
1409
+ `${where}.inputs.tasks: "${raw}" names no task in ${task}.dependsOn ` +
1411
1410
  `(${declared.length === 0 ? 'none declared' : dependsOn!.map((d) => `'${d}'`).join(', ')}) — ` +
1412
1411
  `it would match nothing and fold no upstream hash, decoupling the task from its ` +
1413
1412
  `dependencies. \`name\` is this project's task, \`^name\` its dependencies', ` +
@@ -1450,7 +1449,7 @@ function isEnvName(name: unknown): name is string {
1450
1449
  }
1451
1450
 
1452
1451
  /**
1453
- * A task name `dependsOn`, `cache.inputs.tasks` and the CLI can name. Each
1452
+ * A task name `dependsOn`, `inputs.tasks` and the CLI can name. Each
1454
1453
  * refused shape loaded and could not be referenced: `x#y` read as project
1455
1454
  * `x`, `^gen` as every dependency's `gen`, a `*` as a pattern, `''` ran
1456
1455
  * under `--all` as `a#` and `vx run a#` refused it, and a padded name was
@@ -1460,7 +1459,7 @@ function assertTaskName(name: string, configPath: string): void {
1460
1459
  const why = taskNameProblem(name)
1461
1460
  if (why === null) return
1462
1461
  throw new UserError(
1463
- `${configPath}: task name ${JSON.stringify(name)} ${why} — dependsOn, cache.inputs.tasks and ` +
1462
+ `${configPath}: task name ${JSON.stringify(name)} ${why} — dependsOn, inputs.tasks and ` +
1464
1463
  `the CLI could not name it. Rename the task.`,
1465
1464
  )
1466
1465
  }
@@ -21,7 +21,7 @@ import { validateProjectConfig } from './config-schema.js'
21
21
  import { PROJECT_CONFIG_FILENAMES } from './workspace.js'
22
22
 
23
23
  export const LOCKFILE_NAME = '.vx/projects.lock.json'
24
- export const LOCKFILE_VERSION = 3
24
+ export const LOCKFILE_VERSION = 4
25
25
 
26
26
  /** One project as the lock holds it. */
27
27
  export interface LockedProject {