@vzn/vx 0.0.18 → 0.0.22

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 (149) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +107 -70
  3. package/package.json +10 -20
  4. package/src/bin.ts +64 -5
  5. package/src/cache/archive.ts +279 -28
  6. package/src/cache/cache.ts +952 -1584
  7. package/src/cache/chained-cache.ts +14 -10
  8. package/src/cache/config-evals.ts +112 -0
  9. package/src/cache/file-hashes.ts +290 -0
  10. package/src/cache/git-inputs.ts +966 -0
  11. package/src/cache/index.ts +23 -7
  12. package/src/cache/inputs.ts +553 -739
  13. package/src/cache/key-fold.ts +232 -0
  14. package/src/cache/layer.ts +674 -0
  15. package/src/cache/layered-cache.ts +211 -49
  16. package/src/cache/output-index.ts +323 -0
  17. package/src/cache/policy.ts +84 -0
  18. package/src/cache/run-history.ts +152 -0
  19. package/src/cache/zstd.ts +183 -0
  20. package/src/cli/cache.ts +88 -30
  21. package/src/cli/completions.ts +104 -0
  22. package/src/cli/core-alias.ts +25 -0
  23. package/src/cli/format.ts +4 -15
  24. package/src/cli/help.ts +76 -38
  25. package/src/cli/index.ts +82 -17
  26. package/src/cli/info.ts +162 -91
  27. package/src/cli/init.ts +87 -0
  28. package/src/cli/last.ts +130 -23
  29. package/src/cli/lock.ts +36 -18
  30. package/src/cli/plan-format.ts +18 -7
  31. package/src/cli/plugin-commands.ts +34 -12
  32. package/src/cli/run.ts +138 -313
  33. package/src/cli/select.ts +342 -0
  34. package/src/cli/show.ts +151 -71
  35. package/src/cli/upgrade.ts +136 -15
  36. package/src/cli/watch.ts +1175 -116
  37. package/src/cli/why.ts +51 -29
  38. package/src/cli/workspace-config.ts +138 -0
  39. package/src/config.ts +183 -126
  40. package/src/exec/env.ts +11 -0
  41. package/src/exec/executor.ts +62 -31
  42. package/src/exec/index.ts +16 -2
  43. package/src/exec/kill-tree.ts +281 -0
  44. package/src/exec/local-executor.ts +41 -0
  45. package/src/exec/runner.ts +361 -77
  46. package/src/exec/sandbox-binds.ts +211 -0
  47. package/src/exec/sandbox-paths.ts +70 -0
  48. package/src/exec/sandbox-runtime.ts +1152 -415
  49. package/src/exec/sandbox-violations.ts +293 -0
  50. package/src/graph/dependency-spec.ts +14 -14
  51. package/src/graph/index.ts +6 -4
  52. package/src/graph/priorities.ts +130 -0
  53. package/src/graph/scheduler.ts +332 -329
  54. package/src/graph/task-graph.ts +552 -188
  55. package/src/index.ts +111 -102
  56. package/src/orchestrator/admission.ts +161 -0
  57. package/src/orchestrator/colors.ts +8 -3
  58. package/src/orchestrator/deferred-outputs.ts +25 -7
  59. package/src/orchestrator/doctor.ts +363 -0
  60. package/src/orchestrator/events.ts +111 -8
  61. package/src/orchestrator/excluded-keys.ts +155 -0
  62. package/src/orchestrator/execute-task.ts +450 -565
  63. package/src/orchestrator/failure-mode.ts +169 -28
  64. package/src/orchestrator/failure-recap.ts +121 -0
  65. package/src/orchestrator/fingerprint-watch.ts +89 -0
  66. package/src/orchestrator/framed-output.ts +121 -49
  67. package/src/orchestrator/history.ts +123 -104
  68. package/src/orchestrator/hit-restore.ts +285 -0
  69. package/src/orchestrator/index.ts +42 -5
  70. package/src/orchestrator/keyed-projects.ts +89 -0
  71. package/src/orchestrator/local-shortcircuit.ts +83 -23
  72. package/src/orchestrator/lockfile-claim.ts +401 -0
  73. package/src/orchestrator/logger.ts +128 -41
  74. package/src/orchestrator/metrics.ts +201 -72
  75. package/src/orchestrator/miss-save.ts +232 -0
  76. package/src/orchestrator/options.ts +63 -34
  77. package/src/orchestrator/persistent.ts +99 -0
  78. package/src/orchestrator/placement.ts +212 -0
  79. package/src/orchestrator/plan.ts +24 -11
  80. package/src/orchestrator/plugin-host.ts +200 -49
  81. package/src/orchestrator/plugin.ts +190 -14
  82. package/src/orchestrator/prepare.ts +168 -137
  83. package/src/orchestrator/projects.ts +270 -0
  84. package/src/orchestrator/remote-prefetch.ts +22 -2
  85. package/src/orchestrator/run-artifacts.ts +34 -3
  86. package/src/orchestrator/run-context.ts +20 -5
  87. package/src/orchestrator/run-lock.ts +349 -0
  88. package/src/orchestrator/run-records.ts +132 -0
  89. package/src/orchestrator/run-report.ts +6 -4
  90. package/src/orchestrator/run.ts +467 -611
  91. package/src/orchestrator/sandbox-request.ts +569 -0
  92. package/src/orchestrator/save-lane.ts +44 -0
  93. package/src/orchestrator/shell-verdict.ts +117 -0
  94. package/src/orchestrator/signals.ts +167 -0
  95. package/src/orchestrator/stable-keys.ts +259 -10
  96. package/src/orchestrator/status-line.ts +35 -2
  97. package/src/orchestrator/summary.ts +115 -25
  98. package/src/orchestrator/task-hash.ts +103 -21
  99. package/src/orchestrator/task-log-buffer.ts +15 -18
  100. package/src/orchestrator/telemetry-host.ts +27 -8
  101. package/src/orchestrator/telemetry.ts +71 -44
  102. package/src/orchestrator/upstream.ts +91 -45
  103. package/src/util/bun-version.ts +43 -0
  104. package/src/util/cgroup.ts +160 -0
  105. package/src/util/edit-distance.ts +49 -0
  106. package/src/util/errors.ts +86 -0
  107. package/src/util/hash.ts +10 -1
  108. package/src/util/index.ts +42 -5
  109. package/src/util/paths.ts +168 -5
  110. package/src/util/procfs.ts +20 -0
  111. package/src/util/settle.ts +23 -2
  112. package/src/util/size.ts +33 -0
  113. package/src/util/task-id.ts +20 -0
  114. package/src/util/timing.ts +11 -1
  115. package/src/util/verbs.ts +39 -0
  116. package/src/util/which.ts +48 -0
  117. package/src/workspace/affected.ts +368 -60
  118. package/src/workspace/config-cache.ts +271 -32
  119. package/src/workspace/config-eval.ts +60 -16
  120. package/src/workspace/config-imports.ts +131 -9
  121. package/src/workspace/config-schema.ts +1033 -0
  122. package/src/workspace/filter.ts +69 -17
  123. package/src/workspace/fingerprint.ts +59 -7
  124. package/src/workspace/index.ts +37 -3
  125. package/src/workspace/json-data.ts +90 -0
  126. package/src/workspace/load-reads.ts +32 -0
  127. package/src/workspace/lockfile.ts +41 -14
  128. package/src/workspace/migrate-scripts.ts +187 -0
  129. package/src/workspace/migration.ts +353 -0
  130. package/src/workspace/package-graph.ts +239 -31
  131. package/src/workspace/project-loader.ts +295 -815
  132. package/src/workspace/workspace.ts +266 -31
  133. package/plugins/local-cache/index.ts +0 -2
  134. package/plugins/local-executor/index.ts +0 -2
  135. package/plugins/schedule-history/index.ts +0 -2
  136. package/src/cache/cas-backend.ts +0 -115
  137. package/src/cache/digest.ts +0 -32
  138. package/src/cli/migrate-emit.ts +0 -21
  139. package/src/cli/migrate-nx.ts +0 -473
  140. package/src/cli/migrate-scripts.ts +0 -127
  141. package/src/cli/migrate-turbo.ts +0 -401
  142. package/src/cli/migrate.ts +0 -305
  143. package/src/cli/prune.ts +0 -291
  144. package/src/orchestrator/missing-plugin.ts +0 -9
  145. package/src/orchestrator/resources.ts +0 -45
  146. package/src/orchestrator/verify.ts +0 -174
  147. package/src/plugins/local-cache/index.ts +0 -11
  148. package/src/plugins/local-executor/index.ts +0 -45
  149. package/src/plugins/schedule-history/index.ts +0 -109
@@ -0,0 +1,1033 @@
1
+ // What a config may SAY — the schema validators for `vx.config.*` and
2
+ // `vx.workspace.*`, split from the loader (which decides HOW a file is
3
+ // evaluated) on 2026-09-10. Every object level refuses a key it does not
4
+ // know (tests/schema-unknown-keys), every message names the level and what
5
+ // it accepts, and `validateProjectConfig` is the one boundary the loader,
6
+ // the lockfile's frozen path and the plugin `project` stage all cross.
7
+
8
+ import path from 'node:path'
9
+ import {
10
+ PLUGIN_FUNCTION_HOOKS,
11
+ PLUGIN_HOOKS,
12
+ PLUGIN_PACKAGE,
13
+ type Plugin,
14
+ type ProjectConfig,
15
+ type WorkspaceConfig,
16
+ } from '../config.js'
17
+ import {
18
+ DISPATCHED_VERBS,
19
+ MAX_TIMEOUT_MS,
20
+ nearest,
21
+ normalizeGlob,
22
+ parseDuration,
23
+ parseSize,
24
+ UserError,
25
+ BUN_GLOB_WILDCARDS,
26
+ } from '../util/index.js'
27
+ import { nonJsonMessage, nonJsonPaths } from './json-data.js'
28
+
29
+ // Mirrors `WorkspaceConfig` in src/config.ts. Unknown keys are REJECTED for
30
+ // the same reason the task levels reject them: `plugin: [...]` (singular)
31
+ // declared no plugins and ran the workspace bare, `cacheDirectory` left the
32
+ // cache where it was — a config that loads and quietly does nothing it says.
33
+ const WORKSPACE_FIELDS = new Set([
34
+ 'concurrency',
35
+ 'cacheDir',
36
+ 'timeout',
37
+ 'cacheRetention',
38
+ 'plugins',
39
+ ])
40
+
41
+ const RETENTION_FIELDS = new Set(['olderThan', 'maxSize'])
42
+
43
+ function validateRetention(retention: unknown, configPath: string): void {
44
+ const where = `${configPath}: \`cacheRetention\``
45
+ if (retention === null || typeof retention !== 'object' || Array.isArray(retention)) {
46
+ throw new UserError(`${where} must be { olderThan?: '30d', maxSize?: '10G' }`)
47
+ }
48
+ assertKnownFields(retention as Record<string, unknown>, RETENTION_FIELDS, where)
49
+ const { olderThan, maxSize } = retention as { olderThan?: unknown; maxSize?: unknown }
50
+ // An empty object would load and evict nothing, which reads as a policy.
51
+ if (olderThan === undefined && maxSize === undefined) {
52
+ throw new UserError(`${where} names neither \`olderThan\` nor \`maxSize\``)
53
+ }
54
+ if (
55
+ olderThan !== undefined &&
56
+ (typeof olderThan !== 'string' || parseDuration(olderThan) === null)
57
+ ) {
58
+ throw new UserError(`${where}.olderThan must be a duration like '30d', '12h', '90m' or '45s'`)
59
+ }
60
+ if (maxSize !== undefined && (typeof maxSize !== 'string' || parseSize(maxSize) === null)) {
61
+ throw new UserError(`${where}.maxSize must be a size like '10G', '500MB' or '64KB'`)
62
+ }
63
+ // The bounds `vx cache prune` refuses, for the same reason: each evicts
64
+ // every entry, and here it would do so at the end of every run, the
65
+ // entries that run just saved included, so nothing ever hits (item 969).
66
+ if (olderThan !== undefined && parseDuration(olderThan) === 0) {
67
+ throw new UserError(`${where}.olderThan of 0 evicts every entry after every run`)
68
+ }
69
+ if (maxSize !== undefined && parseSize(maxSize) === 0) {
70
+ throw new UserError(`${where}.maxSize of 0 evicts every entry after every run`)
71
+ }
72
+ if (maxSize !== undefined && /^\d+$/.test(maxSize)) {
73
+ throw new UserError(
74
+ `${where}.maxSize '${maxSize}' reads as ${maxSize} bytes — give a unit (e.g. '${maxSize}M', '${maxSize}G')`,
75
+ )
76
+ }
77
+ }
78
+
79
+ export function validateWorkspace(config: WorkspaceConfig, configPath: string): void {
80
+ // No JSON-data rule here, unlike `validateProjectConfig`: `plugins` holds
81
+ // objects of functions by design, the file is evaluated in-process on
82
+ // every load (never through the config worker), and no key folds its JSON.
83
+ assertKnownFields(config, WORKSPACE_FIELDS, configPath)
84
+ if (config.concurrency !== undefined) {
85
+ if (
86
+ typeof config.concurrency !== 'number' ||
87
+ !Number.isFinite(config.concurrency) ||
88
+ config.concurrency < 1 ||
89
+ !Number.isInteger(config.concurrency)
90
+ ) {
91
+ throw new UserError(`${configPath}: \`concurrency\` must be a positive integer`)
92
+ }
93
+ }
94
+ if (config.cacheDir !== undefined && typeof config.cacheDir !== 'string') {
95
+ throw new UserError(`${configPath}: \`cacheDir\` must be a string`)
96
+ }
97
+ if (config.timeout !== undefined) {
98
+ if (
99
+ typeof config.timeout !== 'number' ||
100
+ !Number.isInteger(config.timeout) ||
101
+ config.timeout <= 0
102
+ ) {
103
+ throw new UserError(`${configPath}: \`timeout\` must be a positive integer (milliseconds)`)
104
+ }
105
+ assertTimeoutInRange(config.timeout, `${configPath}: \`timeout\``)
106
+ }
107
+ if (config.cacheRetention !== undefined) validateRetention(config.cacheRetention, configPath)
108
+ if (config.plugins !== undefined) {
109
+ if (!Array.isArray(config.plugins)) {
110
+ throw new UserError(`${configPath}: \`plugins\` must be an array of plugin objects`)
111
+ }
112
+ const verbOwners = new Map<string, string>()
113
+ const fileClaimants = new Map<string, string>()
114
+ for (const [i, p] of config.plugins.entries()) {
115
+ if (p === null || typeof p !== 'object') {
116
+ throw new UserError(`${configPath}: \`plugins[${i}]\` must be an object`)
117
+ }
118
+ // The loose schema's shape (unknown-typed hooks) plus the one retired
119
+ // key this loader still refuses by name.
120
+ const plug = p as Partial<Plugin> & { name?: unknown; backend?: unknown }
121
+ // A plugin's name is its package name and nothing else. `definePlugin`
122
+ // reads it and stamps it under a registry symbol; a plain object, or
123
+ // one whose `name` was overwritten after the stamp, is refused here —
124
+ // the one boundary every plugin crosses.
125
+ const pkg = (p as Record<symbol, unknown>)[PLUGIN_PACKAGE]
126
+ if (typeof pkg !== 'string' || pkg.length === 0) {
127
+ throw new UserError(
128
+ `${configPath}: \`plugins[${i}]\` must come from definePlugin(import.meta, { … }) — a plugin's name is its package name`,
129
+ )
130
+ }
131
+ if (plug.name !== pkg) {
132
+ throw new UserError(
133
+ `${configPath}: \`plugins[${i}].name\` overrides the package name ('${String(plug.name)}' over '${pkg}') — a plugin's name is its package name; drop the field`,
134
+ )
135
+ }
136
+ // The whole-run `backend` seam was REMOVED on 2026-08-23 with vx
137
+ // cloud: a run always executes in the `vx run` process. Nothing
138
+ // consults `.backend` any more, so leaving it in `caps` below meant a
139
+ // plugin declaring ONLY `backend` — a third-party one written against
140
+ // the old API, say — validated as "contributes a capability" and was
141
+ // then silently ignored. Refuse it by name and point at the seam that
142
+ // replaced it; a silent no-op is the failure this validation exists
143
+ // to prevent.
144
+ if (plug.backend !== undefined) {
145
+ throw new UserError(
146
+ `${configPath}: \`plugins[${i}].backend\` is no longer a capability — the whole-run ` +
147
+ `backend seam was removed in 2026-08. Use \`executor\` to change where a single ` +
148
+ `task's command runs (see docs/architecture.md § plugin capabilities).`,
149
+ )
150
+ }
151
+ for (const cap of PLUGIN_FUNCTION_HOOKS) {
152
+ if (plug[cap] !== undefined && typeof plug[cap] !== 'function') {
153
+ throw new UserError(`${configPath}: \`plugins[${i}].${cap}\` must be a function`)
154
+ }
155
+ }
156
+ if (plug.commands !== undefined) {
157
+ if (plug.commands === null || typeof plug.commands !== 'object') {
158
+ throw new UserError(`${configPath}: \`plugins[${i}].commands\` must be an object`)
159
+ }
160
+ for (const [verb, cmd] of Object.entries(plug.commands as Record<string, unknown>)) {
161
+ const c = cmd as { description?: unknown; run?: unknown } | null
162
+ if (
163
+ c === null ||
164
+ typeof c !== 'object' ||
165
+ typeof c.run !== 'function' ||
166
+ typeof c.description !== 'string'
167
+ ) {
168
+ throw new UserError(
169
+ `${configPath}: \`plugins[${i}].commands.${verb}\` must be { description: string, run: function }`,
170
+ )
171
+ }
172
+ // A verb the dispatcher would never reach is refused, not left
173
+ // dead: core verbs are matched first, and two plugins on one verb
174
+ // would run the first declared and hide the second. Here, in the
175
+ // schema, so every loader of the file — a run, a reading verb, the
176
+ // plugin-verb lookup — refuses it the same way.
177
+ if (DISPATCHED_VERBS.includes(verb)) {
178
+ throw new UserError(
179
+ `${configPath}: plugin '${plug.name}' declares command '${verb}', a core verb — core verbs cannot be shadowed`,
180
+ )
181
+ }
182
+ const owner = verbOwners.get(verb)
183
+ if (owner !== undefined) {
184
+ throw new UserError(
185
+ `${configPath}: plugins '${owner}' and '${plug.name}' both declare command '${verb}' — a verb has one owner`,
186
+ )
187
+ }
188
+ verbOwners.set(verb, plug.name)
189
+ }
190
+ }
191
+ if (plug.fingerprint !== undefined) {
192
+ const claim = plug.fingerprint as { files?: unknown; affected?: unknown } | null
193
+ if (
194
+ claim === null ||
195
+ typeof claim !== 'object' ||
196
+ !Array.isArray(claim.files) ||
197
+ claim.files.length === 0 ||
198
+ typeof claim.affected !== 'function'
199
+ ) {
200
+ throw new UserError(
201
+ `${configPath}: \`plugins[${i}].fingerprint\` must be { files: [name, …], affected: function }`,
202
+ )
203
+ }
204
+ for (const file of claim.files as unknown[]) {
205
+ // A bare name at the workspace root: a lockfile core folds (taken
206
+ // out of the fingerprint), or a root file the plugin's stages read
207
+ // (`turbo.json`: nothing to take out; `--affected` and `vx watch`
208
+ // ask the claimant about it, item 961). A path is refused: the
209
+ // root arm `vx watch` keeps is not recursive.
210
+ if (
211
+ typeof file !== 'string' ||
212
+ file === '' ||
213
+ file === '.' ||
214
+ file === '..' ||
215
+ /[/\\]/.test(file)
216
+ ) {
217
+ throw new UserError(
218
+ `${configPath}: plugin '${plug.name}' claims fingerprint file ${JSON.stringify(file)}, which is not a file name at the workspace root`,
219
+ )
220
+ }
221
+ const owner = fileClaimants.get(file)
222
+ if (owner !== undefined) {
223
+ throw new UserError(
224
+ `${configPath}: plugins '${owner}' and '${plug.name}' both claim fingerprint file '${file}' — a file has one claimant`,
225
+ )
226
+ }
227
+ fileClaimants.set(file, plug.name)
228
+ }
229
+ }
230
+ // A plugin must contribute at least one capability or lifecycle hook
231
+ // — an empty `{ name }` object is a no-op authoring mistake.
232
+ if (PLUGIN_HOOKS.every((hook) => plug[hook] === undefined)) {
233
+ throw new UserError(
234
+ `${configPath}: \`plugins[${i}]\` must contribute at least one of ${PLUGIN_HOOKS.join('/')}`,
235
+ )
236
+ }
237
+ }
238
+ }
239
+ }
240
+
241
+ /**
242
+ * Runtime validation for the user-authored config. TypeScript checks
243
+ * shape at edit-time, but `vx run` may load configs that were never
244
+ * typechecked (plain .js, or TS with errors ignored). Catch the worst
245
+ * shape problems early with a clear message rather than letting them
246
+ * crash deeper in the orchestrator. Also applied to configs loaded
247
+ * back from `vx-lock.json` (a hand-editable file — same boundary).
248
+ */
249
+ export function validateProjectConfig(config: ProjectConfig, configPath: string): void {
250
+ // Before the schema, on the object as it stands: the key folds its JSON,
251
+ // so a value JSON cannot carry is refused here and in the config worker
252
+ // alike (json-data.ts). A plugin's `project` edit and a lock entry cross
253
+ // this line too.
254
+ const [nonJson] = nonJsonPaths(config)
255
+ if (nonJson !== undefined) throw new UserError(nonJsonMessage(configPath, nonJson))
256
+ // The top level too: `task:` (singular) loaded as a project with no
257
+ // tasks and every request against it said "no projects declare".
258
+ assertKnownFields(config, PROJECT_FIELDS, configPath)
259
+ const tasks = config.tasks
260
+ if (tasks === undefined) return
261
+ if (typeof tasks !== 'object' || tasks === null || Array.isArray(tasks)) {
262
+ // An ARRAY of task objects reads as valid to `typeof`, and
263
+ // `Object.entries` then yields a task literally named "0" — a config
264
+ // that loads, runs nothing the author asked for, and never says why.
265
+ throw new UserError(`${configPath}: \`tasks\` must be an object keyed by task name`)
266
+ }
267
+ for (const [name, task] of Object.entries(tasks)) {
268
+ const where = `${configPath}: tasks.${name}`
269
+ assertTaskName(name, configPath)
270
+ if (!task || typeof task !== 'object') {
271
+ throw new UserError(`${where} must be an object`)
272
+ }
273
+ assertKnownFields(task, TASK_FIELDS, where)
274
+ const exec = (task as { exec?: unknown }).exec
275
+ const dependsOn = (task as { dependsOn?: unknown }).dependsOn
276
+ const cache = (task as { cache?: unknown }).cache
277
+ if (exec !== undefined) {
278
+ if (typeof exec !== 'object' || exec === null) {
279
+ throw new UserError(`${where}.exec must be an object with a \`command\` string`)
280
+ }
281
+ assertKnownFields(exec, EXEC_FIELDS, `${where}.exec`)
282
+ const sandbox = (exec as { sandbox?: unknown }).sandbox
283
+ if (sandbox !== undefined) validateSandbox(sandbox, `${where}.exec`)
284
+ const command = (exec as { command?: unknown }).command
285
+ if (typeof command !== 'string' || command.length === 0) {
286
+ throw new UserError(`${where}.exec.command must be a non-empty string`)
287
+ }
288
+ const timeout = (exec as { timeout?: unknown }).timeout
289
+ if (timeout !== undefined) {
290
+ if (typeof timeout !== 'number' || !Number.isInteger(timeout) || timeout <= 0) {
291
+ throw new UserError(`${where}.exec.timeout must be a positive integer (milliseconds)`)
292
+ }
293
+ assertTimeoutInRange(timeout, `${where}.exec.timeout`)
294
+ }
295
+ const retries = (exec as { retries?: unknown }).retries
296
+ if (retries !== undefined) {
297
+ if (typeof retries !== 'number' || !Number.isInteger(retries) || retries < 0) {
298
+ throw new UserError(`${where}.exec.retries must be a non-negative integer`)
299
+ }
300
+ }
301
+ const remote = (exec as { remote?: unknown }).remote
302
+ if (remote !== undefined && typeof remote !== 'boolean' && remote !== 'only') {
303
+ throw new UserError(`${where}.exec.remote must be a boolean or 'only' (or omitted)`)
304
+ }
305
+ // Documented and never held: an uncached 'only' task loaded, and the
306
+ // REAPI executor refused it at run time for want of described inputs
307
+ // (item 1001).
308
+ if (remote === 'only' && cache === undefined) {
309
+ throw new UserError(
310
+ `${where}.exec.remote 'only' needs \`cache\`: its inputs are what a worker reproduces ` +
311
+ `and its key is the address of its remote record`,
312
+ )
313
+ }
314
+ const env = (exec as { env?: unknown }).env
315
+ if (env !== undefined) {
316
+ if (typeof env !== 'object' || env === null) {
317
+ throw new UserError(`${where}.exec.env must be an object (or omitted)`)
318
+ }
319
+ // `env: { set: {...} }` loaded and defined nothing — the one level
320
+ // without this check until 2026-09-10 (tests/schema-unknown-keys).
321
+ assertKnownFields(env, ENV_FIELDS, `${where}.exec.env`)
322
+ const passThrough = (env as { passThrough?: unknown }).passThrough
323
+ if (passThrough !== undefined) {
324
+ // A non-array here reaches `buildIsolatedEnv`'s `for (const name of
325
+ // passThrough)` — a number throws "not iterable" mid-run, a string
326
+ // silently char-iterates. Fail loud at load with a config pointer.
327
+ if (!Array.isArray(passThrough) || passThrough.some((n) => !isEnvName(n))) {
328
+ throw new UserError(
329
+ `${where}.exec.env.passThrough must be an array of env var names (non-empty, no '=' or NUL)`,
330
+ )
331
+ }
332
+ }
333
+ const define = (env as { define?: unknown }).define
334
+ if (define !== undefined) {
335
+ if (typeof define !== 'object' || define === null || Array.isArray(define)) {
336
+ throw new UserError(
337
+ `${where}.exec.env.define must be an object of name:value string pairs`,
338
+ )
339
+ }
340
+ for (const [k, val] of Object.entries(define as Record<string, unknown>)) {
341
+ if (!isEnvName(k)) {
342
+ throw new UserError(
343
+ `${where}.exec.env.define: ${JSON.stringify(k)} is not an env var name (non-empty, no '=' or NUL)`,
344
+ )
345
+ }
346
+ if (typeof val !== 'string' || val.includes('\0')) {
347
+ throw new UserError(`${where}.exec.env.define.${k} must be a string with no NUL`)
348
+ }
349
+ }
350
+ }
351
+ }
352
+ const persistent = (exec as { persistent?: unknown }).persistent
353
+ if (persistent !== undefined) {
354
+ if (typeof persistent !== 'object' || persistent === null) {
355
+ throw new UserError(`${where}.exec.persistent must be an object (or omitted)`)
356
+ }
357
+ // The one nested object that had no unknown-key check while every
358
+ // sibling did — and the failure it let through is the quiet kind:
359
+ // `readWhen` (typo) leaves `readyWhen` undefined, so the task is
360
+ // ready the moment it spawns instead of when its server actually
361
+ // listens, and the dependents that start too early fail in a way
362
+ // that points at the user's code rather than at their config.
363
+
364
+ assertKnownFields(persistent, PERSISTENT_FIELDS, `${where}.exec.persistent`)
365
+ const readyWhen = (persistent as { readyWhen?: unknown }).readyWhen
366
+ if (readyWhen !== undefined && typeof readyWhen !== 'string') {
367
+ throw new UserError(`${where}.exec.persistent.readyWhen must be a string regex`)
368
+ }
369
+ // Compiled here as the runner compiles it: an unbalanced `(` loaded
370
+ // and then failed the task as "internal error" (item 998).
371
+ if (readyWhen !== undefined) {
372
+ try {
373
+ new RegExp(readyWhen)
374
+ } catch (err) {
375
+ throw new UserError(
376
+ `${where}.exec.persistent.readyWhen is not a valid regex (${(err as Error).message})`,
377
+ )
378
+ }
379
+ }
380
+ if (cache !== undefined) {
381
+ throw new UserError(
382
+ `${where}: \`cache\` is not allowed on a persistent task — persistent tasks ` +
383
+ `don't terminate, so there's no exit to cache`,
384
+ )
385
+ }
386
+ if (retries !== undefined) {
387
+ throw new UserError(
388
+ `${where}: \`retries\` is not allowed on a persistent task — persistent tasks ` +
389
+ `don't terminate, so there's no failed exit to retry`,
390
+ )
391
+ }
392
+ }
393
+ } else {
394
+ // Group task: no exec, just dependencies. Only an ABSENT `dependsOn`
395
+ // is refused — a task with neither is a typo, not a declaration. An
396
+ // EMPTY one is deliberate and documented: `build: { dependsOn: [] }`
397
+ // is how a package consumed as source says it has nothing to build,
398
+ // so a dependant's `^build` finds it and waits on nothing
399
+ // (the quickstart). The comment here used to call that case
400
+ // "almost certainly a config mistake" while the docs taught it.
401
+ if (dependsOn === undefined) {
402
+ throw new UserError(
403
+ `${where}: a task with no \`exec\` must declare \`dependsOn\` ` +
404
+ `(group tasks exist to chain dependencies)`,
405
+ )
406
+ }
407
+ if (cache !== undefined) {
408
+ throw new UserError(
409
+ `${where}: \`cache\` requires \`exec\` — a group task has nothing to cache`,
410
+ )
411
+ }
412
+ }
413
+ const description = (task as { description?: unknown }).description
414
+ if (description !== undefined && typeof description !== 'string') {
415
+ throw new UserError(`${where}.description must be a string`)
416
+ }
417
+ if (dependsOn !== undefined) {
418
+ if (!Array.isArray(dependsOn) || dependsOn.some((s) => typeof s !== 'string')) {
419
+ throw new UserError(
420
+ `${where}.dependsOn must be an array of strings ` +
421
+ `(Turbo/Nx micro-syntax: 'name', '^name', 'pkg#name')`,
422
+ )
423
+ }
424
+ }
425
+ if (cache !== undefined) {
426
+ if (typeof cache !== 'object' || cache === null) {
427
+ throw new UserError(`${where}.cache must be an object when present`)
428
+ }
429
+ assertKnownFields(cache, CACHE_FIELDS, `${where}.cache`)
430
+ const inputs = (cache as { inputs?: unknown }).inputs
431
+ const outputs = (cache as { outputs?: unknown }).outputs
432
+ if (!inputs || typeof inputs !== 'object') {
433
+ throw new UserError(`${where}.cache.inputs is required when \`cache\` is set`)
434
+ }
435
+ assertKnownFields(inputs, CACHE_INPUT_FIELDS, `${where}.cache.inputs`)
436
+ if (!Array.isArray((inputs as { files?: unknown }).files)) {
437
+ throw new UserError(`${where}.cache.inputs.files must be an array of glob strings`)
438
+ }
439
+ const envList = (inputs as { env?: unknown }).env
440
+ if (envList !== undefined) {
441
+ if (
442
+ !Array.isArray(envList) ||
443
+ // A NUL can be in no env name, and the key fold's delimiter is one.
444
+ envList.some((s) => !isEnvName(s))
445
+ ) {
446
+ throw new UserError(
447
+ `${where}.cache.inputs.env must be an array of env var names (non-empty, no '=' or NUL)`,
448
+ )
449
+ }
450
+ for (const name of envList as string[]) {
451
+ // Reject wildcards explicitly so users don't silently miss
452
+ // env vars they thought they were tracking. Turbo supports
453
+ // `VERCEL_*` expansion; vx requires the literal names. If
454
+ // we add expansion later it'll be additive — until then,
455
+ // surface the footgun instead of returning '' for the
456
+ // literal env name `'VERCEL_*'`.
457
+ // An env name is no path, so a bracket is no route directory: the
458
+ // whole `Bun.Glob` alphabet stays refused here.
459
+ if (BUN_GLOB_WILDCARDS.test(name)) {
460
+ throw new UserError(
461
+ `${where}.cache.inputs.env: wildcards in env names are not supported ` +
462
+ `(got "${name}") — list explicit env var names instead`,
463
+ )
464
+ }
465
+ }
466
+ }
467
+ if (!outputs || typeof outputs !== 'object') {
468
+ throw new UserError(`${where}.cache.outputs is required when \`cache\` is set`)
469
+ }
470
+ assertKnownFields(outputs, CACHE_OUTPUT_FIELDS, `${where}.cache.outputs`)
471
+ const outFiles = (outputs as { files?: unknown }).files
472
+ if (!Array.isArray(outFiles)) {
473
+ throw new UserError(`${where}.cache.outputs.files must be an array of glob strings`)
474
+ }
475
+ // Reject zero-length strings and absolute paths up front.
476
+ // Both reach `resolveOutputs` as undefined behavior; the error
477
+ // surfaces deep inside the glob resolver with no line pointing
478
+ // at the user's config. Fail loud at load time.
479
+ for (const g of outFiles as unknown[]) {
480
+ if (typeof g !== 'string' || g.length === 0) {
481
+ throw new UserError(`${where}.cache.outputs.files must be an array of non-empty strings`)
482
+ }
483
+ if (g.startsWith('/')) {
484
+ throw new UserError(
485
+ `${where}.cache.outputs.files: absolute paths are not allowed (got "${g}") — ` +
486
+ `outputs must be project-relative globs`,
487
+ )
488
+ }
489
+ if (hasParentSegment(g)) {
490
+ throw new UserError(
491
+ `${where}.cache.outputs.files: '..' path segments are not allowed (got "${g}") — ` +
492
+ `outputs must stay within the project (a glob that escapes the project dir would ` +
493
+ `let cleanOutputs delete files outside it)`,
494
+ )
495
+ }
496
+ if (namesDirItself(g)) {
497
+ throw new UserError(
498
+ `${where}.cache.outputs.files: "${g}" names the project directory itself and selects nothing — ` +
499
+ `name the directory the task writes, such as "dist/**"`,
500
+ )
501
+ }
502
+ if (g.startsWith('!')) {
503
+ throw new UserError(
504
+ `${where}.cache.outputs.files: negation is not supported (got "${g}") — ` +
505
+ `unlike inputs, output globs are never split on '!', so this is read as a literal ` +
506
+ `path beginning with '!' and matches nothing. List the outputs you DO produce.`,
507
+ )
508
+ }
509
+ const own = ownFileCovered(g, configPath)
510
+ if (own !== null) {
511
+ throw new UserError(
512
+ `${where}.cache.outputs.files: "${g}" covers the project's own ${own} — vx deletes a ` +
513
+ `task's outputs before it runs and restores them on a hit, so the project would lose ` +
514
+ `its manifest and config. Name the directory the task writes, such as "dist/**".`,
515
+ )
516
+ }
517
+ }
518
+ // Same for inputs.files.
519
+ for (const g of (inputs as { files: unknown[] }).files) {
520
+ if (typeof g !== 'string' || g.length === 0) {
521
+ throw new UserError(`${where}.cache.inputs.files must be an array of non-empty strings`)
522
+ }
523
+ if (g.startsWith('/')) {
524
+ throw new UserError(
525
+ `${where}.cache.inputs.files: absolute paths are not allowed (got "${g}") — ` +
526
+ `inputs must be project-relative globs`,
527
+ )
528
+ }
529
+ if (hasParentSegment(g)) {
530
+ throw new UserError(
531
+ `${where}.cache.inputs.files: '..' path segments are not allowed (got "${g}") — ` +
532
+ `inputs must be project-relative (a '..' glob silently matches nothing; ` +
533
+ `use cache.inputs.workspaceFiles for workspace-root-relative inputs)`,
534
+ )
535
+ }
536
+ if (namesDirItself(g)) {
537
+ throw new UserError(
538
+ `${where}.cache.inputs.files: "${g}" names the project directory itself and selects nothing — use "**" for everything under it`,
539
+ )
540
+ }
541
+ // A negated absolute path subtracts nothing (the positive globs are
542
+ // project-relative), so it would sit in the config as a silent no-op;
543
+ // `workspaceFiles` refuses both spellings the same way (item 679).
544
+ if (g.startsWith('!/')) {
545
+ throw new UserError(
546
+ `${where}.cache.inputs.files: absolute paths are not allowed (got "${g}") — ` +
547
+ `inputs must be project-relative globs`,
548
+ )
549
+ }
550
+ assertNotDoubleNegated(g, `${where}.cache.inputs.files`)
551
+ }
552
+ // A list of ONLY negations selects nothing at all. `resolveFiles`
553
+ // builds the file set from the POSITIVE globs and uses the `!` entries
554
+ // purely to subtract, so with no positive pattern it returns `[]` — the
555
+ // task folds ZERO file inputs and its key stops moving with its source,
556
+ // which is a stale hit waiting to happen.
557
+ //
558
+ // Every gitignore-trained reader parses `['!**/*.spec.ts']` as
559
+ // "everything except specs", and Turbo makes exactly this a hard config
560
+ // error for the same reason. Refusing costs nothing: such a config was
561
+ // already silently broken, so no working key changes.
562
+ assertNotNegationOnly((inputs as { files: string[] }).files, `${where}.cache.inputs.files`)
563
+ // The only CacheInputs field that reached the run unvalidated: a
564
+ // non-string entry crashes deep in `filterUpstreamHashes` /
565
+ // `parseDependencySpec` with a raw TypeError naming neither the task
566
+ // nor the config. Entry SYNTAX is already checked downstream (a
567
+ // `DependencySpecError` becomes a UserError naming the task) — this
568
+ // only pins the shape.
569
+ const taskFilters = (inputs as { tasks?: unknown }).tasks
570
+ if (taskFilters !== undefined) {
571
+ if (
572
+ !Array.isArray(taskFilters) ||
573
+ taskFilters.some((s) => typeof s !== 'string' || s.length === 0)
574
+ ) {
575
+ throw new UserError(
576
+ `${where}.cache.inputs.tasks must be an array of non-empty strings ` +
577
+ `(Turbo/Nx micro-syntax: 'name', '^name', 'pkg#name', '*', '^*', '!name')`,
578
+ )
579
+ }
580
+ assertFilterNamesDeclaredDeps(taskFilters, task.dependsOn, where)
581
+ }
582
+ for (const field of ['runtime', 'workspaceRuntime'] as const) {
583
+ const list = (inputs as Record<string, unknown>)[field]
584
+ if (list !== undefined) {
585
+ if (!Array.isArray(list) || list.some((s) => typeof s !== 'string' || s.length === 0)) {
586
+ throw new UserError(
587
+ `${where}.cache.inputs.${field} must be an array of non-empty shell command strings`,
588
+ )
589
+ }
590
+ }
591
+ }
592
+ // workspaceFiles mirror the files validation: non-empty strings,
593
+ // never absolute (they're workspace-root-relative by definition).
594
+ const wsInputs = (inputs as { workspaceFiles?: unknown }).workspaceFiles
595
+ if (wsInputs !== undefined) {
596
+ validateWorkspaceGlobs(wsInputs, `${where}.cache.inputs.workspaceFiles`, true)
597
+ }
598
+ const wsOutputs = (outputs as { workspaceFiles?: unknown }).workspaceFiles
599
+ if (wsOutputs !== undefined) {
600
+ validateWorkspaceGlobs(wsOutputs, `${where}.cache.outputs.workspaceFiles`, false)
601
+ }
602
+ }
603
+ }
604
+ }
605
+
606
+ // Known fields per config level, mirroring the interfaces in src/config.ts.
607
+ // Unknown keys are REJECTED rather than silently dropped: a typo in a
608
+ // cache-key field (`workspaceFile`, `task`, `timeoutMs`) is otherwise
609
+ // discarded, so the task hashes as if the field were never written and vx
610
+ // serves a stale artifact — the same reasoning `sandbox` already
611
+ // encodes. A new field must be added here deliberately.
612
+ const PROJECT_FIELDS = new Set(['tasks'])
613
+ const TASK_FIELDS = new Set(['description', 'exec', 'dependsOn', 'cache'])
614
+ const EXEC_FIELDS = new Set([
615
+ 'command',
616
+ 'env',
617
+ 'timeout',
618
+ 'retries',
619
+ 'persistent',
620
+ 'remote',
621
+ 'sandbox',
622
+ ])
623
+ const PERSISTENT_FIELDS = new Set(['readyWhen'])
624
+ const ENV_FIELDS = new Set(['passThrough', 'define'])
625
+ const CACHE_FIELDS = new Set(['inputs', 'outputs'])
626
+ const CACHE_INPUT_FIELDS = new Set([
627
+ 'files',
628
+ 'workspaceFiles',
629
+ 'env',
630
+ 'tasks',
631
+ 'runtime',
632
+ 'workspaceRuntime',
633
+ ])
634
+ const CACHE_OUTPUT_FIELDS = new Set(['files', 'workspaceFiles'])
635
+
636
+ function assertKnownFields(value: object, allowed: ReadonlySet<string>, where: string): void {
637
+ // `typeof [] === 'object'`, so an array reaches here and its indices read
638
+ // as fields: `outputs: ['dist/**']` (Turbo's spelling) was refused as
639
+ // `unknown field "0"`. Name the shape instead, and the one meant.
640
+ if (Array.isArray(value)) {
641
+ throw new UserError(
642
+ `${where} must be an object (fields: ${[...allowed].sort().join(', ')}), not an array` +
643
+ (allowed.has('files') ? ' — did you mean `{ files: [...] }`?' : ''),
644
+ )
645
+ }
646
+ for (const key of Object.keys(value)) {
647
+ if (!allowed.has(key)) {
648
+ // The nearest accepted spelling first: the list says what the level
649
+ // takes, the hint says which one was meant.
650
+ const near = nearest(key, allowed)
651
+ throw new UserError(
652
+ `${where} has unknown field "${key}" (allowed: ${[...allowed].sort().join(', ')})` +
653
+ (near === undefined ? '' : ` — did you mean ${near}?`),
654
+ )
655
+ }
656
+ }
657
+ }
658
+
659
+ /**
660
+ * A millisecond delay past `MAX_TIMEOUT_MS` does not mean "effectively never" —
661
+ * `setTimeout` silently reduces it to 1 ms, so the task is killed the instant it
662
+ * spawns and reported `failed`. That is the exact inverse of the declaration,
663
+ * and the only clue is a `TimeoutOverflowWarning` on stderr.
664
+ *
665
+ * Refused rather than clamped, because this is a value the user WROTE and reads
666
+ * back: silently substituting ~24.8 days for the 317 years they asked for would
667
+ * trade one surprise for a quieter one. Costs nothing — such a config never
668
+ * worked, it killed the task in milliseconds, so refusing reports a state that
669
+ * already existed.
670
+ */
671
+ function assertTimeoutInRange(ms: number, where: string): void {
672
+ if (ms <= MAX_TIMEOUT_MS) return
673
+ throw new UserError(
674
+ `${where}: ${ms} ms exceeds the maximum timer delay (${MAX_TIMEOUT_MS} ms, ~24.8 days). ` +
675
+ `Timers larger than this do NOT mean "no limit" — the platform reduces them to 1 ms, so ` +
676
+ `the task would be killed the moment it starts. Omit \`timeout\` for no limit.`,
677
+ )
678
+ }
679
+
680
+ /**
681
+ * The project file an output glob would take, or null. `outputs: ['**']`
682
+ * loaded, and the clean before the run deleted the project's source,
683
+ * `package.json` and `vx.config` while the run reported success (item
684
+ * 1002); the message for `'.'` had even suggested `**`.
685
+ */
686
+ function ownFileCovered(glob: string, configPath: string): string | null {
687
+ const g = new Bun.Glob(normalizeGlob(glob))
688
+ return ['package.json', path.basename(configPath)].find((f) => g.match(f)) ?? null
689
+ }
690
+
691
+ /**
692
+ * `.`, `./`, `././` (with or without a `!`) name the directory itself, which
693
+ * no matcher expands: the entry selected nothing and said so nowhere.
694
+ * `./src/**` is fine — the resolver strips the `./` (`normalizeGlob`).
695
+ */
696
+ function namesDirItself(glob: string): boolean {
697
+ const g = normalizeGlob(glob.startsWith('!') ? glob.slice(1) : glob)
698
+ return g === '' || g === '/'
699
+ }
700
+
701
+ /**
702
+ * True when a glob contains a `..` PATH SEGMENT (`../x`, `a/../b`, `x/..`), which
703
+ * escapes its base dir — `foo..bar` / `a..b` inside a filename are fine. A
704
+ * leading negation marker is stripped first so `!../x` is caught too. Used to
705
+ * keep output globs inside the project and workspace globs inside the workspace
706
+ * root: `cleanOutputs` rm()s resolved output paths before every run, and
707
+ * `Bun.Glob.scan` follows `..` out of its cwd, so a `..` glob is a data-loss
708
+ * vector (delete files outside the project / above the repo root).
709
+ */
710
+ function hasParentSegment(glob: string): boolean {
711
+ const g = glob.startsWith('!') ? glob.slice(1) : glob
712
+ return g.split('/').some((seg) => seg === '..')
713
+ }
714
+
715
+ /**
716
+ * `!!x` INVERTS the input set instead of double-negating it.
717
+ *
718
+ * The resolver strips ONE `!` and hands the remainder to `new Bun.Glob()` —
719
+ * which applies its OWN leading-`!` negation. So `!!vendor/**` becomes the
720
+ * exclude glob `Glob('!vendor/**')`, and that matches every path EXCEPT
721
+ * vendor's. Everything else is therefore excluded and the task folds ONLY
722
+ * `vendor/**`: its own source drops out of the cache key entirely, which is a
723
+ * permanent stale hit.
724
+ *
725
+ * Neither neighbouring guard catches it — `assertNotNegationOnly` sees the
726
+ * positive glob and is satisfied, and `hasParentSegment` strips one `!` before
727
+ * splitting so `!!../x` shows no `..` segment.
728
+ *
729
+ * Refused rather than reinterpreted: `!!` has no coherent meaning here.
730
+ * Negation in vx is subtraction from what a positive glob matched, not
731
+ * gitignore's ordered re-inclusion, so there is nothing for a second `!` to
732
+ * undo. Costs nothing to refuse — such a config already folds the wrong set,
733
+ * so no working cache key changes.
734
+ */
735
+ function assertNotDoubleNegated(glob: string, where: string): void {
736
+ if (!glob.startsWith('!!')) return
737
+ const inner = glob.slice(2)
738
+ throw new UserError(
739
+ `${where}: '!!' is not a double negation (got "${glob}") — it INVERTS the set. ` +
740
+ `One '!' is stripped and the remainder is compiled as a glob, which applies its own ` +
741
+ `leading-'!' negation, so this excludes everything EXCEPT ${JSON.stringify(inner)} and ` +
742
+ `the task folds only that. Use ${JSON.stringify(`!${inner}`)} to subtract it, or ` +
743
+ `${JSON.stringify(inner)} to include it.`,
744
+ )
745
+ }
746
+
747
+ /**
748
+ * An exact `cache.inputs.tasks` entry that no `dependsOn` entry names would
749
+ * match nothing at hash time and fold no upstream hash — the task silently
750
+ * decoupled from its dependencies, which is a stale hit waiting for the
751
+ * next upstream change. `['buidl']` for `['build']` is the shape. Patterns,
752
+ * wildcards and negations stay silent (a preset-spread pattern legitimately
753
+ * matches nothing in some projects; excluding what is absent is harmless);
754
+ * an exact name must be named by some `dependsOn` entry of a form that can
755
+ * reach the same node, exactly or by that entry's own `*` pattern. The form
756
+ * matters: `build` selects this project's `build` and `^build` only other
757
+ * projects', so `['build']` against `dependsOn: ['^build']` folded nothing
758
+ * and served a stale hit (item 994). A `pkg#task` entry may name this
759
+ * project or a dependency, which the schema cannot tell apart, so it pairs
760
+ * with any form whose task half matches. `[]` is the explicit way to
761
+ * decouple.
762
+ */
763
+ function assertFilterNamesDeclaredDeps(
764
+ filters: readonly string[],
765
+ dependsOn: readonly string[] | undefined,
766
+ where: string,
767
+ ): void {
768
+ const declared = (dependsOn ?? []).map(specForm)
769
+ const matches = (pattern: string, name: string): boolean =>
770
+ pattern.includes('*') ? taskPatternRegExp(pattern).test(name) : pattern === name
771
+ const named = (f: SpecForm): boolean =>
772
+ declared.some((d) => {
773
+ if (!matches(d.task, f.task)) return false
774
+ if (d.form === 'cross' && f.form === 'cross') {
775
+ return f.project.includes('*') || matches(d.project, f.project)
776
+ }
777
+ return d.form === f.form || d.form === 'cross' || f.form === 'cross'
778
+ })
779
+ for (const raw of filters) {
780
+ // `*` and `^*` need no arm of their own: their task half is `*`, which
781
+ // the wildcard skip below takes (item 678).
782
+ if (raw.startsWith('!')) continue
783
+ const f = specForm(raw)
784
+ if (f.task.length === 0 || f.task.includes('*') || named(f)) continue
785
+ throw new UserError(
786
+ `${where}.cache.inputs.tasks: "${raw}" names no task in ${where}.dependsOn ` +
787
+ `(${declared.length === 0 ? 'none declared' : dependsOn!.map((d) => `'${d}'`).join(', ')}) — ` +
788
+ `it would match nothing and fold no upstream hash, decoupling the task from its ` +
789
+ `dependencies. \`name\` is this project's task, \`^name\` its dependencies', ` +
790
+ `\`pkg#name\` one project's. Fix the name, or use [] to decouple on purpose.`,
791
+ )
792
+ }
793
+ }
794
+
795
+ type SpecForm =
796
+ | { form: 'self' | 'deps'; task: string }
797
+ | { form: 'cross'; project: string; task: string }
798
+
799
+ /** A `dependsOn` / filter entry's form, as `parseDependencySpec` splits it. */
800
+ function specForm(spec: string): SpecForm {
801
+ if (spec.startsWith('^')) return { form: 'deps', task: spec.slice(1) }
802
+ const hash = spec.indexOf('#')
803
+ if (hash === -1) return { form: 'self', task: spec }
804
+ return { form: 'cross', project: spec.slice(0, hash), task: spec.slice(hash + 1) }
805
+ }
806
+
807
+ /**
808
+ * A name an environment can hold. An `=` splits at the first one, so
809
+ * `define: { 'A=B': 'x' }` gave the child `A` with the value `B=x`; `''`
810
+ * was dropped; a NUL failed the spawn with a hint about exit 127 (item
811
+ * 999). Refused at load, where the config is named.
812
+ */
813
+ function isEnvName(name: unknown): name is string {
814
+ return typeof name === 'string' && name.length > 0 && !name.includes('=') && !name.includes('\0')
815
+ }
816
+
817
+ /**
818
+ * A task name `dependsOn`, `cache.inputs.tasks` and the CLI can name. Each
819
+ * refused shape loaded and could not be referenced: `x#y` read as project
820
+ * `x`, `^gen` as every dependency's `gen`, a `*` as a pattern, `''` ran
821
+ * under `--all` as `a#` and `vx run a#` refused it, and a padded name was
822
+ * trimmed away by the reader (item 1000).
823
+ */
824
+ function assertTaskName(name: string, configPath: string): void {
825
+ const why =
826
+ name.trim().length === 0
827
+ ? 'is empty'
828
+ : name.trim() !== name
829
+ ? 'has surrounding whitespace'
830
+ : name.includes('#')
831
+ ? "holds '#', which separates a project from its task"
832
+ : name.includes('*')
833
+ ? "holds '*', which makes it a pattern"
834
+ : name.startsWith('^') || name.startsWith('!')
835
+ ? `starts with '${name[0]}', which names dependencies' tasks or negates`
836
+ : null
837
+ if (why === null) return
838
+ throw new UserError(
839
+ `${configPath}: task name ${JSON.stringify(name)} ${why} — dependsOn, cache.inputs.tasks and ` +
840
+ `the CLI could not name it. Rename the task.`,
841
+ )
842
+ }
843
+
844
+ /** The graph's `*`-only task glob (`compileTaskPattern`), mirrored: `*` is the sole metacharacter. */
845
+ function taskPatternRegExp(pattern: string): RegExp {
846
+ const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*')
847
+ return new RegExp(`^${escaped}$`)
848
+ }
849
+
850
+ /**
851
+ * Refuse a non-empty glob list made up ENTIRELY of negations.
852
+ *
853
+ * The resolvers build their file set from the POSITIVE globs and use `!`
854
+ * entries only to subtract, so with no positive pattern they return `[]` —
855
+ * the task folds zero file inputs and its cache key stops moving with its
856
+ * source. That is a stale hit, and it is silent.
857
+ *
858
+ * Refusing is free: such a config was ALREADY selecting nothing, so no
859
+ * working task's key changes and no CACHE_VERSION bump is owed.
860
+ */
861
+ function assertNotNegationOnly(globs: readonly string[], where: string): void {
862
+ if (globs.length === 0) return
863
+ if (globs.some((g) => !g.startsWith('!'))) return
864
+ throw new UserError(
865
+ `${where}: every entry is a negation, which selects NOTHING (got ${JSON.stringify(globs)}) — ` +
866
+ `negations only subtract from the files a positive glob already matched, so this task ` +
867
+ `would fold zero file inputs and its cache key would stop tracking its source. ` +
868
+ `Add the positive glob you meant, e.g. ['**/*', ${JSON.stringify(globs[0])}].`,
869
+ )
870
+ }
871
+
872
+ /**
873
+ * `negation: false` for OUTPUT globs — `resolveOutputs` /
874
+ * `resolveWorkspaceOutputs` never split on `!`, so such an entry is read as a
875
+ * literal path starting with `!` and matches nothing.
876
+ */
877
+ function validateWorkspaceGlobs(v: unknown, where: string, negation: boolean): void {
878
+ if (!Array.isArray(v)) {
879
+ throw new UserError(`${where} must be an array of glob strings`)
880
+ }
881
+ for (const g of v as unknown[]) {
882
+ if (typeof g !== 'string' || g.length === 0) {
883
+ throw new UserError(`${where} must be an array of non-empty strings`)
884
+ }
885
+ if (g.startsWith('/') || g.startsWith('!/')) {
886
+ throw new UserError(
887
+ `${where}: absolute paths are not allowed (got "${g}") — ` +
888
+ `entries are workspace-root-relative globs`,
889
+ )
890
+ }
891
+ if (hasParentSegment(g)) {
892
+ throw new UserError(
893
+ `${where}: '..' path segments are not allowed (got "${g}") — ` +
894
+ `entries are workspace-root-relative and must stay within the workspace root`,
895
+ )
896
+ }
897
+ if (namesDirItself(g)) {
898
+ throw new UserError(
899
+ `${where}: "${g}" names the workspace root itself and selects nothing — use "**" for everything under it`,
900
+ )
901
+ }
902
+ if (!negation && g.startsWith('!')) {
903
+ throw new UserError(
904
+ `${where}: negation is not supported (got "${g}") — ` +
905
+ `unlike inputs, output globs are never split on '!', so this is read as a literal ` +
906
+ `path beginning with '!' and matches nothing. List the outputs you DO produce.`,
907
+ )
908
+ }
909
+ if (negation) assertNotDoubleNegated(g, where)
910
+ }
911
+ if (negation) assertNotNegationOnly(v as string[], where)
912
+ }
913
+
914
+ const SANDBOX_FIELDS = new Set([
915
+ 'allow',
916
+ 'deny',
917
+ 'ignore',
918
+ 'weakerWhenNested',
919
+ 'weakerNetworkIsolation',
920
+ ])
921
+ const GRANT_PATH_FIELDS = ['read', 'write'] as const
922
+ const GRANT_NAME_FIELDS = ['systemInfo', 'machLookup'] as const
923
+ const GRANT_BOOL_FIELDS = ['pty', 'gitConfig'] as const
924
+ const GRANT_FIELDS = new Set<string>([
925
+ ...GRANT_PATH_FIELDS,
926
+ ...GRANT_NAME_FIELDS,
927
+ ...GRANT_BOOL_FIELDS,
928
+ 'localBinding',
929
+ 'network',
930
+ 'unixSockets',
931
+ ])
932
+ const DENY_FIELDS = new Set(['network'])
933
+
934
+ function assertStringArray(v: unknown, where: string): void {
935
+ if (!Array.isArray(v) || v.some((s) => typeof s !== 'string' || s.length === 0)) {
936
+ throw new UserError(`${where} must be an array of non-empty strings`)
937
+ }
938
+ }
939
+
940
+ /**
941
+ * Validate a `sandbox: {...}` block — one capability shape, whatever the
942
+ * platform ends up doing with it. Unknown keys are refused rather than
943
+ * dropped: a silently-ignored `allow.reads` would confine a task more than
944
+ * its author believed, and the failure reads as a broken build.
945
+ */
946
+ function validateSandbox(sandbox: unknown, where: string): void {
947
+ if (typeof sandbox !== 'object' || sandbox === null || Array.isArray(sandbox)) {
948
+ throw new UserError(
949
+ `${where}.sandbox must be an object (e.g. \`{}\` for the baseline, or ` +
950
+ `\`{ allow: { read: [...] } }\`)`,
951
+ )
952
+ }
953
+ assertKnownFields(sandbox, SANDBOX_FIELDS, `${where}.sandbox`)
954
+ const obj = sandbox as Record<string, unknown>
955
+
956
+ for (const flag of ['weakerWhenNested', 'weakerNetworkIsolation']) {
957
+ if (obj[flag] !== undefined && typeof obj[flag] !== 'boolean') {
958
+ throw new UserError(`${where}.sandbox.${flag} must be a boolean`)
959
+ }
960
+ }
961
+
962
+ const allow = obj['allow']
963
+ if (allow !== undefined) {
964
+ if (typeof allow !== 'object' || allow === null || Array.isArray(allow)) {
965
+ throw new UserError(`${where}.sandbox.allow must be an object`)
966
+ }
967
+ assertKnownFields(allow, GRANT_FIELDS, `${where}.sandbox.allow`)
968
+ const g = allow as Record<string, unknown>
969
+ for (const f of GRANT_PATH_FIELDS) {
970
+ // Patterns are allowed here: macOS matches them natively, Linux
971
+ // expands them against the filesystem at resolve time.
972
+ if (g[f] !== undefined) assertStringArray(g[f], `${where}.sandbox.allow.${f}`)
973
+ }
974
+ for (const f of GRANT_NAME_FIELDS) {
975
+ if (g[f] !== undefined) assertStringArray(g[f], `${where}.sandbox.allow.${f}`)
976
+ }
977
+ for (const f of GRANT_BOOL_FIELDS) {
978
+ if (g[f] !== undefined && typeof g[f] !== 'boolean') {
979
+ throw new UserError(`${where}.sandbox.allow.${f} must be a boolean`)
980
+ }
981
+ }
982
+ // `true` (everything) or an explicit list — nothing in between.
983
+ for (const f of ['network', 'unixSockets']) {
984
+ if (g[f] !== undefined && g[f] !== true) {
985
+ assertStringArray(g[f], `${where}.sandbox.allow.${f}`)
986
+ }
987
+ }
988
+ // A boolean, or the ports to bridge out of the sandbox: a non-empty
989
+ // list of integers in the TCP range. An empty list would read as "no
990
+ // port" while granting loopback, which is `true` with extra steps.
991
+ const lb = g['localBinding']
992
+ if (lb !== undefined && typeof lb !== 'boolean') {
993
+ if (
994
+ !Array.isArray(lb) ||
995
+ lb.length === 0 ||
996
+ lb.some((p) => !Number.isInteger(p) || (p as number) < 1 || (p as number) > 65535)
997
+ ) {
998
+ throw new UserError(
999
+ `${where}.sandbox.allow.localBinding must be a boolean or a non-empty list of ports (1–65535)`,
1000
+ )
1001
+ }
1002
+ }
1003
+ }
1004
+
1005
+ const deny = obj['deny']
1006
+ if (deny !== undefined) {
1007
+ if (typeof deny !== 'object' || deny === null || Array.isArray(deny)) {
1008
+ throw new UserError(`${where}.sandbox.deny must be an object`)
1009
+ }
1010
+ assertKnownFields(deny, DENY_FIELDS, `${where}.sandbox.deny`)
1011
+ const d = deny as Record<string, unknown>
1012
+ if (d['network'] !== undefined) assertStringArray(d['network'], `${where}.sandbox.deny.network`)
1013
+ }
1014
+
1015
+ const ignore = obj['ignore']
1016
+ if (ignore !== undefined) {
1017
+ if (typeof ignore !== 'object' || ignore === null || Array.isArray(ignore)) {
1018
+ throw new UserError(`${where}.sandbox.ignore must be an object`)
1019
+ }
1020
+ assertKnownFields(ignore, GRANT_FIELDS, `${where}.sandbox.ignore`)
1021
+ const g = ignore as Record<string, unknown>
1022
+ // Patterns, not prefixes — `ignore` is vx's own filter over lines it
1023
+ // already holds, so a glob costs nothing and works on every platform.
1024
+ for (const f of [...GRANT_PATH_FIELDS, ...GRANT_NAME_FIELDS, 'network', 'unixSockets']) {
1025
+ if (g[f] !== undefined) assertStringArray(g[f], `${where}.sandbox.ignore.${f}`)
1026
+ }
1027
+ for (const f of GRANT_BOOL_FIELDS) {
1028
+ if (g[f] !== undefined) {
1029
+ throw new UserError(`${where}.sandbox.ignore.${f} is a flag, not something to ignore`)
1030
+ }
1031
+ }
1032
+ }
1033
+ }