@vzn/vx 0.0.651 → 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 (78) hide show
  1. package/README.md +2 -3
  2. package/package.json +5 -5
  3. package/schemas/show.json +14 -20
  4. package/src/bin.ts +12 -0
  5. package/src/cache/archive.ts +1 -1
  6. package/src/cache/cache.ts +19 -5
  7. package/src/cache/history-stream.ts +134 -0
  8. package/src/cache/history-writer.ts +76 -0
  9. package/src/cache/index.ts +2 -0
  10. package/src/cache/inputs.ts +74 -14
  11. package/src/cache/layer.ts +6 -6
  12. package/src/cache/lockfile-memo.ts +2 -1
  13. package/src/cache/run-history.ts +46 -17
  14. package/src/cache/schema.ts +4 -1
  15. package/src/cli/cache.ts +2 -2
  16. package/src/cli/help.ts +2 -2
  17. package/src/cli/index.ts +24 -0
  18. package/src/cli/info.ts +1 -1
  19. package/src/cli/last.ts +34 -2
  20. package/src/cli/plugin-templates.ts +36 -45
  21. package/src/cli/show.ts +7 -10
  22. package/src/cli/watch-judge.ts +1 -1
  23. package/src/cli/watch-set.ts +4 -4
  24. package/src/cli/watch.ts +11 -8
  25. package/src/config.ts +47 -30
  26. package/src/exec/executor.ts +40 -1
  27. package/src/exec/index.ts +1 -0
  28. package/src/exec/sandbox-paths.ts +3 -1
  29. package/src/exec/sandbox-runtime.ts +314 -29
  30. package/src/exec/task-proxy.ts +93 -0
  31. package/src/graph/definition.ts +16 -3
  32. package/src/graph/scheduler.ts +3 -1
  33. package/src/graph/task-graph.ts +24 -23
  34. package/src/history/failure-mode.ts +24 -1
  35. package/src/history/index.ts +1 -0
  36. package/src/history/metrics.ts +5 -5
  37. package/src/index.ts +4 -2
  38. package/src/orchestrator/admission.ts +1 -1
  39. package/src/orchestrator/command-windows.ts +1 -1
  40. package/src/orchestrator/deferred-outputs.ts +1 -1
  41. package/src/orchestrator/download-policy.ts +5 -7
  42. package/src/orchestrator/excluded-keys.ts +1 -1
  43. package/src/orchestrator/execute-task.ts +51 -18
  44. package/src/orchestrator/hit-restore.ts +5 -6
  45. package/src/orchestrator/keyed-projects.ts +28 -0
  46. package/src/orchestrator/local-shortcircuit.ts +2 -2
  47. package/src/orchestrator/miss-save.ts +27 -8
  48. package/src/orchestrator/persistent-task.ts +2 -0
  49. package/src/orchestrator/placement.ts +1 -1
  50. package/src/orchestrator/plan.ts +2 -2
  51. package/src/orchestrator/plugin-host.ts +6 -2
  52. package/src/orchestrator/plugin.ts +6 -0
  53. package/src/orchestrator/remote-prefetch.ts +1 -1
  54. package/src/orchestrator/run-failures.ts +61 -12
  55. package/src/orchestrator/run-keys.ts +1 -1
  56. package/src/orchestrator/run-records.ts +86 -67
  57. package/src/orchestrator/run-report.ts +1 -1
  58. package/src/orchestrator/run.ts +92 -19
  59. package/src/orchestrator/sandbox-request.ts +50 -7
  60. package/src/orchestrator/shell-verdict.ts +9 -0
  61. package/src/orchestrator/stable-keys.ts +17 -18
  62. package/src/orchestrator/task-artifacts.ts +1 -1
  63. package/src/orchestrator/task-hash.ts +4 -4
  64. package/src/orchestrator/telemetry.ts +5 -1
  65. package/src/report/events.ts +3 -3
  66. package/src/report/framed-output.ts +16 -7
  67. package/src/report/logger.ts +1 -1
  68. package/src/report/plain-output.ts +2 -0
  69. package/src/report/summary.ts +5 -5
  70. package/src/settings.ts +8 -8
  71. package/src/util/config-frame.ts +128 -6
  72. package/src/util/paths.ts +1 -1
  73. package/src/util/settle.ts +2 -1
  74. package/src/util/tail.ts +4 -1
  75. package/src/workspace/bracket-literal.ts +2 -2
  76. package/src/workspace/config-schema.ts +66 -61
  77. package/src/workspace/filter.ts +8 -1
  78. package/src/workspace/migration.ts +12 -6
@@ -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 {
@@ -590,6 +592,9 @@ export function validateProjectConfig(
590
592
  const exec = (task as { exec?: unknown }).exec
591
593
  const dependsOn = (task as { dependsOn?: unknown }).dependsOn
592
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
+ }
593
598
  if (exec !== undefined) {
594
599
  if (typeof exec !== 'object' || exec === null) {
595
600
  throw new UserError(`${where}.exec must be an object with a \`command\` string`)
@@ -623,9 +628,9 @@ export function validateProjectConfig(
623
628
  // Documented and never held: an uncached 'only' task loaded, and the
624
629
  // REAPI executor refused it at run time for want of described inputs
625
630
  // (item 1001).
626
- if (remote === 'only' && cache === undefined) {
631
+ if (remote === 'only' && cache !== true) {
627
632
  throw new UserError(
628
- `${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 ` +
629
634
  `and its key is the address of its remote record`,
630
635
  )
631
636
  }
@@ -712,7 +717,7 @@ export function validateProjectConfig(
712
717
  throw new UserError(`${where}.exec.interactive must be a boolean (or omitted)`)
713
718
  }
714
719
  if (interactive === true) {
715
- if (cache !== undefined) {
720
+ if (cache === true) {
716
721
  throw new UserError(
717
722
  `${where}: \`cache\` is not allowed on an interactive task — what it does ` +
718
723
  `depends on what is typed, which no key holds`,
@@ -765,7 +770,7 @@ export function validateProjectConfig(
765
770
  )
766
771
  }
767
772
  }
768
- if (cache !== undefined) {
773
+ if (cache === true) {
769
774
  throw new UserError(
770
775
  `${where}: \`cache\` is not allowed on a persistent task — persistent tasks ` +
771
776
  `don't terminate, so there's no exit to cache`,
@@ -943,16 +948,8 @@ export function validateProjectConfig(
943
948
  validateWorkspaceGlobs(wsInputs, `${where}.inputs.workspaceFiles`, true)
944
949
  }
945
950
  }
946
- if (cache !== undefined) {
947
- if (typeof cache !== 'object' || cache === null) {
948
- throw new UserError(
949
- `${where}.cache must be an object when present — \`inputs: { files: [...] }, cache: { outputs: { files: [...] } }\`, or no \`cache\` for a task that never caches`,
950
- )
951
- }
952
- assertKnownFields(cache, CACHE_FIELDS, `${where}.cache`)
953
- const stability = (cache as { inputStability?: unknown }).inputStability
954
- if (stability !== undefined)
955
- validateInputStability(stability, `${where}.cache.inputStability`)
951
+ const outputs = (task as { outputs?: unknown }).outputs
952
+ if (cache === true) {
956
953
  // The key is the task's inputs, and a group runs nothing to cache.
957
954
  if (exec === undefined) {
958
955
  throw new UserError(
@@ -964,23 +961,38 @@ export function validateProjectConfig(
964
961
  `${where}: \`cache\` needs \`inputs\` — the key is the task's inputs, \`inputs: { files: [...] }\``,
965
962
  )
966
963
  }
967
- const outputs = (cache as { outputs?: unknown }).outputs
968
- if (outputs === undefined || outputs === null) {
969
- 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: [...] }\``)
970
982
  }
971
- if (typeof outputs !== 'object') {
983
+ assertKnownFields(outputs, OUTPUT_FIELDS, `${where}.outputs`)
984
+ if (exec === undefined) {
972
985
  throw new UserError(
973
- `${where}.cache.outputs must be an object — \`outputs: { files: [...] }\``,
986
+ `${where}.outputs needs \`exec\` — a task with no command writes nothing`,
974
987
  )
975
988
  }
976
- assertKnownFields(outputs, CACHE_OUTPUT_FIELDS, `${where}.cache.outputs`)
977
989
  const clean = (outputs as { clean?: unknown }).clean
978
990
  if (clean !== undefined && typeof clean !== 'boolean') {
979
- throw new UserError(`${where}.cache.outputs.clean must be true or false`)
991
+ throw new UserError(`${where}.outputs.clean must be true or false`)
980
992
  }
981
993
  const outFiles = (outputs as { files?: unknown }).files
982
994
  if (!Array.isArray(outFiles)) {
983
- 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`)
984
996
  }
985
997
  // Reject zero-length strings and absolute paths up front.
986
998
  // Both reach `resolveOutputs` as undefined behavior; the error
@@ -988,26 +1000,26 @@ export function validateProjectConfig(
988
1000
  // at the user's config. Fail loud at load time.
989
1001
  for (const g of outFiles as unknown[]) {
990
1002
  if (typeof g !== 'string' || g.length === 0) {
991
- 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`)
992
1004
  }
993
1005
  if (isAbsoluteGlob(g)) {
994
1006
  throw new UserError(
995
- `${where}.cache.outputs.files: absolute paths are not allowed (got "${g}") — ` +
1007
+ `${where}.outputs.files: absolute paths are not allowed (got "${g}") — ` +
996
1008
  `outputs must be project-relative globs`,
997
1009
  )
998
1010
  }
999
1011
  if (hasParentSegment(g)) {
1000
1012
  throw new UserError(
1001
- `${where}.cache.outputs.files: '..' path segments are not allowed (got "${g}") — ` +
1013
+ `${where}.outputs.files: '..' path segments are not allowed (got "${g}") — ` +
1002
1014
  `outputs must stay within the project (vx clears a task's outputs before it runs, ` +
1003
1015
  `so a glob that escapes the project would delete files outside it)`,
1004
1016
  )
1005
1017
  }
1006
- assertPosixGlob(g, `${where}.cache.outputs.files`, 'project')
1007
- assertNoForeignToken(g, `${where}.cache.outputs.files`)
1018
+ assertPosixGlob(g, `${where}.outputs.files`, 'project')
1019
+ assertNoForeignToken(g, `${where}.outputs.files`)
1008
1020
  if (namesDirItself(g)) {
1009
1021
  throw new UserError(
1010
- `${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 — ` +
1011
1023
  `name the directory the task writes, such as "dist/**"`,
1012
1024
  )
1013
1025
  }
@@ -1016,31 +1028,31 @@ export function validateProjectConfig(
1016
1028
  // a "corrupt artifact", dropped and run again.
1017
1029
  if (!g.startsWith('!') && normalizeGlob(g).split('/')[0] === 'workspace-outputs') {
1018
1030
  throw new UserError(
1019
- `${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 ` +
1020
1032
  `artifacts reserve for outputs.workspaceFiles — write the task's files to another directory`,
1021
1033
  )
1022
1034
  }
1023
1035
  // A `!` entry takes a path back from the outputs (A-44): it is not
1024
1036
  // cleaned, saved or restored, and it stays an input.
1025
- assertNotDoubleNegated(g, `${where}.cache.outputs.files`)
1037
+ assertNotDoubleNegated(g, `${where}.outputs.files`)
1026
1038
  // Taken back by a `!`, the file is never cleaned or restored.
1027
1039
  const own = g.startsWith('!')
1028
1040
  ? undefined
1029
1041
  : ownFilesCovered(g, ownConfig).find((f) => !takenBack(outFiles as unknown[], f))
1030
1042
  if (own !== undefined) {
1031
1043
  throw new UserError(
1032
- `${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 ` +
1033
1045
  `task's outputs before it runs and restores them on a hit, so the project would lose ` +
1034
1046
  `its config. Name the directory the task writes, such as "dist/**", ` +
1035
1047
  `or take the file back with "!${own}".`,
1036
1048
  )
1037
1049
  }
1038
1050
  }
1039
- assertOutputsNotNegationOnly(outFiles as string[], `${where}.cache.outputs.files`)
1040
- 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)
1041
1053
  const wsOutputs = (outputs as { workspaceFiles?: unknown }).workspaceFiles
1042
1054
  if (wsOutputs !== undefined) {
1043
- validateWorkspaceGlobs(wsOutputs, `${where}.cache.outputs.workspaceFiles`, false)
1055
+ validateWorkspaceGlobs(wsOutputs, `${where}.outputs.workspaceFiles`, false)
1044
1056
  }
1045
1057
  }
1046
1058
  }
@@ -1055,7 +1067,7 @@ export function validateProjectConfig(
1055
1067
  // `tasks` first: `nearest` breaks a tie by order, and `taks` is one edit
1056
1068
  // from both.
1057
1069
  const PROJECT_FIELDS = new Set(['name', 'tasks', 'tags'])
1058
- const TASK_FIELDS = new Set(['description', 'exec', 'dependsOn', 'inputs', 'cache'])
1070
+ const TASK_FIELDS = new Set(['description', 'exec', 'dependsOn', 'inputs', 'outputs', 'cache'])
1059
1071
  const EXEC_FIELDS = new Set([
1060
1072
  'command',
1061
1073
  'env',
@@ -1069,7 +1081,6 @@ const EXEC_FIELDS = new Set([
1069
1081
  ])
1070
1082
  const PERSISTENT_FIELDS = new Set(['readyWhen'])
1071
1083
  const ENV_FIELDS = new Set(['passThrough', 'define', 'secret'])
1072
- const CACHE_FIELDS = new Set(['outputs', 'inputStability'])
1073
1084
  const INPUT_FIELDS = new Set([
1074
1085
  'files',
1075
1086
  'workspaceFiles',
@@ -1077,8 +1088,9 @@ const INPUT_FIELDS = new Set([
1077
1088
  'tasks',
1078
1089
  'runtime',
1079
1090
  'workspaceRuntime',
1091
+ 'stability',
1080
1092
  ])
1081
- const CACHE_OUTPUT_FIELDS = new Set(['files', 'workspaceFiles', 'clean'])
1093
+ const OUTPUT_FIELDS = new Set(['files', 'workspaceFiles', 'clean'])
1082
1094
 
1083
1095
  /**
1084
1096
  * Another runner's spelling of a field vx has under another name, by the
@@ -1090,8 +1102,10 @@ const FOREIGN_FIELDS: ReadonlyMap<ReadonlySet<string>, Readonly<Record<string, s
1090
1102
  [
1091
1103
  TASK_FIELDS,
1092
1104
  {
1093
- outputs: '`cache.outputs.files`',
1094
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)",
1095
1109
  passThroughEnv: '`exec.env.passThrough`',
1096
1110
  command: '`exec.command`',
1097
1111
  cmd: '`exec.command`',
@@ -1128,18 +1142,6 @@ const FOREIGN_FIELDS: ReadonlyMap<ReadonlySet<string>, Readonly<Record<string, s
1128
1142
  shell: 'no field: `command` always runs in a shell',
1129
1143
  },
1130
1144
  ],
1131
- // wireit's `files` / `output` and include-style spellings written into
1132
- // a cache block, refused with no word on where they go (D-118).
1133
- [
1134
- CACHE_FIELDS,
1135
- {
1136
- files: "the task's `inputs.files`",
1137
- output: '`outputs.files`',
1138
- env: "the task's `inputs.env`",
1139
- dependencies: "the task's `dependsOn` (an upstream's key goes into this one)",
1140
- enabled: 'no field: a task with a `cache` block caches, one without runs every time',
1141
- },
1142
- ],
1143
1145
  [
1144
1146
  INPUT_FIELDS,
1145
1147
  {
@@ -1151,7 +1153,7 @@ const FOREIGN_FIELDS: ReadonlyMap<ReadonlySet<string>, Readonly<Record<string, s
1151
1153
  },
1152
1154
  ],
1153
1155
  [
1154
- CACHE_OUTPUT_FIELDS,
1156
+ OUTPUT_FIELDS,
1155
1157
  {
1156
1158
  globs: '`files`',
1157
1159
  include: '`files`',
@@ -1176,12 +1178,11 @@ const FOREIGN_FIELDS: ReadonlyMap<ReadonlySet<string>, Readonly<Record<string, s
1176
1178
  sourceRoot: "a task's `inputs.files`",
1177
1179
  projectType: 'no field: vx runs every project alike',
1178
1180
  ...Object.fromEntries(
1179
- ['dependsOn', 'inputs', 'cache', 'exec', 'description'].map((k) => [
1181
+ ['dependsOn', 'inputs', 'outputs', 'cache', 'exec', 'description'].map((k) => [
1180
1182
  k,
1181
1183
  `\`tasks.<name>.${k}\`: a project holds tasks, and each task its own \`${k}\``,
1182
1184
  ]),
1183
1185
  ),
1184
- outputs: '`tasks.<name>.cache.outputs.files`',
1185
1186
  env: '`tasks.<name>.inputs.env` (to key a task on it) or `tasks.<name>.exec.env`',
1186
1187
  },
1187
1188
  ],
@@ -1252,7 +1253,7 @@ function nestedHome(allowed: ReadonlySet<string>, key: string): string | undefin
1252
1253
  for (const [field, own] of [
1253
1254
  ['exec', EXEC_FIELDS],
1254
1255
  ['inputs', INPUT_FIELDS],
1255
- ['cache', CACHE_FIELDS],
1256
+ ['outputs', OUTPUT_FIELDS],
1256
1257
  ] as const) {
1257
1258
  if (own.has(key)) return `\`${field}.${key}\``
1258
1259
  }
@@ -1538,7 +1539,7 @@ function assertInputGlob(glob: string, where: string): void {
1538
1539
  if (glob.replace(/^!/, '').split('/').includes('.git')) {
1539
1540
  throw new UserError(
1540
1541
  `${where}: "${glob}" names a path under .git, which git never lists, so it keys nothing — ` +
1541
- `inputs are the repository's files`,
1542
+ `inputs are the workspace's files`,
1542
1543
  )
1543
1544
  }
1544
1545
  }
@@ -1699,6 +1700,9 @@ export function taskNameProblem(name: string): string | null {
1699
1700
  function tagProblem(tag: string): string | null {
1700
1701
  if (tag.trim() !== tag) return 'has surrounding whitespace'
1701
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
1702
1706
  const walk = "ends in '...', which a filter reads as a dependency walk"
1703
1707
  let f: ReturnType<typeof parseFilter>
1704
1708
  try {
@@ -1828,6 +1832,7 @@ const SANDBOX_FIELDS = new Set([
1828
1832
  'ignore',
1829
1833
  'weakerWhenNested',
1830
1834
  'weakerNetworkIsolation',
1835
+ 'hermetic',
1831
1836
  ])
1832
1837
  const GRANT_PATH_FIELDS = ['read', 'write'] as const
1833
1838
  const GRANT_NAME_FIELDS = ['systemInfo', 'machLookup'] as const
@@ -1871,7 +1876,7 @@ function validateSandbox(sandbox: unknown, where: string): void {
1871
1876
  assertKnownFields(sandbox, SANDBOX_FIELDS, `${where}.sandbox`)
1872
1877
  const obj = sandbox as Record<string, unknown>
1873
1878
 
1874
- for (const flag of ['weakerWhenNested', 'weakerNetworkIsolation']) {
1879
+ for (const flag of ['weakerWhenNested', 'weakerNetworkIsolation', 'hermetic']) {
1875
1880
  if (obj[flag] !== undefined && typeof obj[flag] !== 'boolean') {
1876
1881
  throw new UserError(`${where}.sandbox.${flag} must be a boolean`)
1877
1882
  }
@@ -22,7 +22,7 @@
22
22
  // given, the base set is "all projects" and excluded packages are removed.
23
23
 
24
24
  import path from 'node:path'
25
- import { BUN_GLOB_WILDCARDS, relPosix, UserError } from '../util/index.js'
25
+ import { BUN_GLOB_WILDCARDS, hasControl, printable, relPosix, UserError } from '../util/index.js'
26
26
  import type { PackageGraph } from './package-graph.js'
27
27
  import { canonicalDir } from './canonical-dir.js'
28
28
  import type { ProjectMeta } from './workspace.js'
@@ -76,6 +76,13 @@ export function parseFilter(
76
76
  /** The filter as typed, when `raw` is the `./` reading of a name (`dirFallback`). */
77
77
  typed = raw,
78
78
  ): ParsedFilter {
79
+ // No project name, path or git ref holds one; echoed raw, a newline split
80
+ // the refusal in two and an escape painted the terminal.
81
+ if (hasControl(raw)) {
82
+ throw new UserError(
83
+ `filter "${printable(raw)}" holds a control character: no project name, path or git ref does`,
84
+ )
85
+ }
79
86
  let s = raw
80
87
  const negate = s.startsWith('!')
81
88
  if (negate) s = s.slice(1)
@@ -42,6 +42,10 @@ export function isStarterConfig(file: string): boolean {
42
42
  }
43
43
  }
44
44
 
45
+ /** A C0 or C1 control, or DEL. */
46
+ // oxlint-disable-next-line no-control-regex
47
+ const CONTROL = /[\x00-\x1f\x7f-\x9f]/g
48
+
45
49
  /**
46
50
  * Escape an arbitrary string into a single-quoted TS literal. Escapes
47
51
  * backslash + quote AND raw newlines/CR — a value with an embedded newline
@@ -55,7 +59,10 @@ export function quoteTsLiteral(s: string): string {
55
59
  .replaceAll('\\', '\\\\')
56
60
  .replaceAll("'", "\\'")
57
61
  .replaceAll('\n', '\\n')
58
- .replaceAll('\r', '\\r')}'`
62
+ .replaceAll('\r', '\\r')
63
+ // Any other control as its escape: a script's raw ESC landed in the
64
+ // file as the byte, and `cat vx.config.ts` retitled the terminal.
65
+ .replace(CONTROL, (c) => `\\x${c.charCodeAt(0).toString(16).padStart(2, '0')}`)}'`
59
66
  }
60
67
 
61
68
  /**
@@ -658,7 +665,7 @@ const EXAMPLE_BODY = `export default {
658
665
  build: {
659
666
  exec: { command: 'tsc -b' },
660
667
  dependsOn: ['^build'],
661
- inputs: { files: ['src/**'] }, cache: { outputs: { files: ['dist/**'] } },
668
+ inputs: { files: ['src/**'] }, outputs: { files: ['dist/**'] }, cache: true,
662
669
  },
663
670
  },
664
671
  }`
@@ -747,7 +754,7 @@ function renderConfigFile(p: GeneratedProject, format: MigrationFormat): string
747
754
  // The suggested block sits in the task, commented: deleting `// `
748
755
  // after setting the real globs caches it, no copying from the TODO.
749
756
  const block =
750
- t.task['cache'] === undefined
757
+ t.task['cache'] !== true
751
758
  ? t.todos.find(isCacheTodo)?.match(/^cache: add `(.+?)`/)?.[1]
752
759
  : undefined
753
760
  lines.push(
@@ -790,11 +797,10 @@ function ownConfigNamed(
790
797
  return {
791
798
  ...p,
792
799
  tasks: p.tasks.map((t) => {
793
- const outputs = (t.task?.['cache'] as { outputs?: { files?: unknown } } | undefined)?.outputs
800
+ const outputs = t.task?.['outputs'] as { files?: unknown } | undefined
794
801
  if (!Array.isArray(outputs?.files) || !outputs.files.includes(planned)) return t
795
- const cache = t.task!['cache'] as Record<string, unknown>
796
802
  const files = outputs.files.map((f: unknown) => (f === planned ? `!${name}` : f))
797
- return { ...t, task: { ...t.task, cache: { ...cache, outputs: { ...outputs, files } } } }
803
+ return { ...t, task: { ...t.task, outputs: { ...outputs, files } } }
798
804
  }),
799
805
  }
800
806
  }