@vzn/vx 0.0.638 → 0.0.640

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 (134) hide show
  1. package/README.md +1 -1
  2. package/docs/caching.md +152 -95
  3. package/docs/cli.md +225 -372
  4. package/docs/execution.md +22 -47
  5. package/docs/features.md +14 -16
  6. package/docs/patterns.md +6 -8
  7. package/docs/schema.md +268 -215
  8. package/docs/security.md +1 -1
  9. package/package.json +5 -5
  10. package/schemas/last.json +7 -4
  11. package/schemas/show.json +14 -14
  12. package/skills/vx/SKILL.md +10 -7
  13. package/src/cache/cache.ts +44 -11
  14. package/src/cache/file-hashes.ts +7 -12
  15. package/src/cache/fs-clock.ts +62 -0
  16. package/src/cache/git-inputs.ts +8 -2
  17. package/src/cache/index.ts +5 -2
  18. package/src/cache/inputs.ts +154 -83
  19. package/src/cache/key-fold.ts +23 -3
  20. package/src/cache/layer.ts +33 -18
  21. package/src/cache/layered-cache.ts +108 -315
  22. package/src/cache/linked-inputs.ts +87 -0
  23. package/src/cache/plugin-cache.ts +2 -10
  24. package/src/cache/remote-lane.ts +383 -0
  25. package/src/cache/run-history.ts +24 -8
  26. package/src/cache/run-store.ts +4 -4
  27. package/src/cache/schema.ts +6 -4
  28. package/src/cache/scoped-cache.ts +7 -0
  29. package/src/cli/completions.ts +2 -2
  30. package/src/cli/foreign-flags.ts +11 -23
  31. package/src/cli/help.ts +6 -31
  32. package/src/cli/info.ts +2 -1
  33. package/src/cli/init.ts +23 -11
  34. package/src/cli/last.ts +30 -4
  35. package/src/cli/plan-format.ts +5 -58
  36. package/src/cli/plugin-commands.ts +2 -2
  37. package/src/cli/plugin-templates.ts +2 -2
  38. package/src/cli/run.ts +23 -185
  39. package/src/cli/select.ts +16 -6
  40. package/src/cli/show.ts +15 -6
  41. package/src/cli/upgrade.ts +215 -55
  42. package/src/cli/watch-cycle.ts +2 -1
  43. package/src/cli/watch-filter.ts +22 -6
  44. package/src/cli/watch-judge.ts +49 -5
  45. package/src/cli/watch-set.ts +18 -8
  46. package/src/cli/watch.ts +103 -16
  47. package/src/cli/why.ts +9 -7
  48. package/src/config.ts +19 -21
  49. package/src/exec/executor.ts +5 -5
  50. package/src/exec/sandbox-runtime.ts +1 -1
  51. package/src/exec/sandbox-violations.ts +3 -1
  52. package/src/graph/dependency-spec.ts +2 -2
  53. package/src/graph/folds.ts +236 -0
  54. package/src/graph/index.ts +17 -2
  55. package/src/graph/priorities.ts +1 -1
  56. package/src/graph/scheduler.ts +45 -31
  57. package/src/graph/task-graph.ts +190 -58
  58. package/src/graph/task-status.ts +95 -0
  59. package/src/{orchestrator → history}/failure-mode.ts +1 -1
  60. package/src/{orchestrator → history}/history.ts +1 -1
  61. package/src/history/index.ts +48 -0
  62. package/src/{orchestrator → history}/metrics.ts +7 -20
  63. package/src/{orchestrator → history}/miss-reason.ts +1 -1
  64. package/src/index.ts +11 -29
  65. package/src/orchestrator/affected-tasks.ts +20 -19
  66. package/src/orchestrator/doctor.ts +1 -1
  67. package/src/orchestrator/download-policy.ts +16 -22
  68. package/src/orchestrator/excluded-keys.ts +4 -5
  69. package/src/orchestrator/execute-args.ts +80 -0
  70. package/src/orchestrator/execute-task.ts +50 -411
  71. package/src/orchestrator/fingerprint-watch.ts +2 -2
  72. package/src/orchestrator/index.ts +2 -73
  73. package/src/orchestrator/keyed-projects.ts +12 -51
  74. package/src/orchestrator/local-shortcircuit.ts +1 -1
  75. package/src/orchestrator/miss-save.ts +33 -3
  76. package/src/orchestrator/options.ts +1 -36
  77. package/src/orchestrator/output-host.ts +4 -3
  78. package/src/orchestrator/persistent-task.ts +321 -0
  79. package/src/orchestrator/persistent.ts +130 -3
  80. package/src/orchestrator/placement.ts +5 -5
  81. package/src/orchestrator/plan.ts +19 -6
  82. package/src/orchestrator/plugin-host.ts +129 -69
  83. package/src/orchestrator/plugin.ts +18 -8
  84. package/src/orchestrator/prepare.ts +40 -34
  85. package/src/orchestrator/projects.ts +7 -10
  86. package/src/orchestrator/remote-prefetch.ts +14 -1
  87. package/src/orchestrator/run-artifacts.ts +5 -177
  88. package/src/orchestrator/run-context.ts +55 -0
  89. package/src/orchestrator/run-failures.ts +15 -3
  90. package/src/orchestrator/run-records.ts +35 -3
  91. package/src/orchestrator/run-report.ts +151 -171
  92. package/src/orchestrator/run-settings.ts +1 -16
  93. package/src/orchestrator/run-steps.ts +57 -0
  94. package/src/orchestrator/run-stop.ts +153 -0
  95. package/src/orchestrator/run.ts +293 -604
  96. package/src/orchestrator/sandbox-fix.ts +1 -17
  97. package/src/orchestrator/sandbox-request.ts +2 -2
  98. package/src/orchestrator/signals.ts +14 -6
  99. package/src/orchestrator/stable-keys.ts +164 -27
  100. package/src/orchestrator/steps.ts +70 -0
  101. package/src/orchestrator/task-hash.ts +34 -25
  102. package/src/orchestrator/task-log-buffer.ts +1 -1
  103. package/src/orchestrator/task-process.ts +94 -0
  104. package/src/orchestrator/telemetry-host.ts +4 -4
  105. package/src/orchestrator/telemetry.ts +14 -86
  106. package/src/orchestrator/upstream.ts +4 -181
  107. package/src/{orchestrator → report}/events.ts +41 -8
  108. package/src/{orchestrator → report}/forecast.ts +35 -2
  109. package/src/{orchestrator → report}/framed-output.ts +40 -34
  110. package/src/report/index.ts +36 -0
  111. package/src/{orchestrator → report}/logger.ts +158 -20
  112. package/src/{orchestrator → report}/status-line.ts +88 -7
  113. package/src/{orchestrator → report}/summary.ts +126 -35
  114. package/src/report/tally.ts +83 -0
  115. package/src/settings.ts +12 -39
  116. package/src/util/colors.ts +44 -4
  117. package/src/util/git-ignored.ts +143 -0
  118. package/src/util/index.ts +19 -4
  119. package/src/util/paths.ts +1 -1
  120. package/src/util/report.ts +13 -5
  121. package/src/util/settle.ts +14 -39
  122. package/src/util/tail.ts +1 -1
  123. package/src/util/ui.ts +17 -6
  124. package/src/workspace/affected.ts +81 -2
  125. package/src/workspace/config-imports.ts +1 -1
  126. package/src/workspace/config-schema.ts +142 -143
  127. package/src/workspace/lockfile.ts +1 -1
  128. package/src/workspace/migration.ts +19 -3
  129. package/schemas/summary.json +0 -133
  130. package/src/cache/chained-cache.ts +0 -280
  131. package/src/orchestrator/tally.ts +0 -112
  132. /package/src/{orchestrator → report}/path-links.ts +0 -0
  133. /package/src/{orchestrator → report}/plain-output.ts +0 -0
  134. /package/src/{orchestrator → report}/terminal-signals.ts +0 -0
@@ -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 {
@@ -67,7 +67,7 @@ const QUOTE_ARGS =
67
67
  `vx_r="$vx_r\${vx_s%%"$vx_q"*}'\\''"; vx_s=\${vx_s#*"$vx_q"};; *) break;; esac; done; ` +
68
68
  `vx_a="$vx_a '$vx_r$vx_s'"; done`
69
69
 
70
- const isCacheTodo = (todo: string): boolean => todo.startsWith('cache: add `cache: {')
70
+ const isCacheTodo = (todo: string): boolean => todo.startsWith('cache: add `inputs: {')
71
71
 
72
72
  /** The one wording every mapper emits for a task it made persistent. */
73
73
  export const PERSISTENT_TODO =
@@ -426,7 +426,7 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
426
426
  }
427
427
 
428
428
  // The lock's own words become a row; a refusal prints above the report.
429
- const lock = dry || args.lock === undefined ? undefined : await args.lock(root)
429
+ const lock = dry || args.lock === undefined ? undefined : lockAnswer(await args.lock(root))
430
430
  if (lock !== undefined && lock.code !== 0) process.stderr.write(lock.said)
431
431
 
432
432
  const rows: ui.FieldRow[] = [['from', source]]
@@ -488,6 +488,22 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
488
488
  return 0
489
489
  }
490
490
 
491
+ /**
492
+ * The lock step's answer, checked: the tool calling this seam is another
493
+ * package and may be another version (vx-migrate 0.0.637's answered its
494
+ * exit code, which crashed the report).
495
+ */
496
+ function lockAnswer(answer: unknown): { code: number; said: string } {
497
+ const a = answer as { code?: unknown; said?: unknown } | null
498
+ if (
499
+ typeof a === 'object' &&
500
+ a !== null &&
501
+ typeof a.code === 'number' &&
502
+ typeof a.said === 'string'
503
+ )
504
+ return { code: a.code, said: a.said }
505
+ return { code: 1, said: 'vx lock: the migration tool answered no result: run `vx lock`\n' }
506
+ }
491
507
  /**
492
508
  * What to do about written files .gitignore drops. A `!file` line cures a
493
509
  * file, but git re-includes nothing inside an ignored directory: an
@@ -624,7 +640,7 @@ const EXAMPLE_BODY = `export default {
624
640
  build: {
625
641
  exec: { command: 'tsc -b' },
626
642
  dependsOn: ['^build'],
627
- cache: { inputs: { files: ['src/**'] }, outputs: { files: ['dist/**'] } },
643
+ inputs: { files: ['src/**'] }, cache: { outputs: { files: ['dist/**'] } },
628
644
  },
629
645
  },
630
646
  }`