@vzn/vx 0.0.21 → 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 (123) hide show
  1. package/README.md +21 -18
  2. package/package.json +6 -6
  3. package/src/bin.ts +52 -3
  4. package/src/cache/archive.ts +170 -21
  5. package/src/cache/cache.ts +667 -288
  6. package/src/cache/chained-cache.ts +14 -2
  7. package/src/cache/config-evals.ts +29 -6
  8. package/src/cache/file-hashes.ts +63 -35
  9. package/src/cache/git-inputs.ts +398 -69
  10. package/src/cache/index.ts +8 -1
  11. package/src/cache/inputs.ts +403 -102
  12. package/src/cache/key-fold.ts +232 -0
  13. package/src/cache/layer.ts +81 -11
  14. package/src/cache/layered-cache.ts +199 -32
  15. package/src/cache/output-index.ts +109 -10
  16. package/src/cache/run-history.ts +8 -3
  17. package/src/cache/zstd.ts +53 -30
  18. package/src/cli/cache.ts +51 -21
  19. package/src/cli/format.ts +4 -15
  20. package/src/cli/help.ts +13 -3
  21. package/src/cli/index.ts +35 -9
  22. package/src/cli/info.ts +49 -7
  23. package/src/cli/init.ts +17 -5
  24. package/src/cli/last.ts +96 -21
  25. package/src/cli/lock.ts +34 -17
  26. package/src/cli/plan-format.ts +4 -4
  27. package/src/cli/plugin-commands.ts +20 -5
  28. package/src/cli/run.ts +55 -12
  29. package/src/cli/select.ts +23 -13
  30. package/src/cli/show.ts +9 -4
  31. package/src/cli/upgrade.ts +134 -14
  32. package/src/cli/watch.ts +611 -102
  33. package/src/cli/why.ts +8 -16
  34. package/src/cli/workspace-config.ts +21 -9
  35. package/src/config.ts +46 -9
  36. package/src/exec/executor.ts +51 -5
  37. package/src/exec/index.ts +10 -0
  38. package/src/exec/kill-tree.ts +281 -0
  39. package/src/exec/local-executor.ts +1 -1
  40. package/src/exec/runner.ts +327 -82
  41. package/src/exec/sandbox-binds.ts +34 -5
  42. package/src/exec/sandbox-paths.ts +22 -2
  43. package/src/exec/sandbox-runtime.ts +627 -102
  44. package/src/exec/sandbox-violations.ts +36 -23
  45. package/src/graph/dependency-spec.ts +14 -14
  46. package/src/graph/index.ts +12 -2
  47. package/src/graph/priorities.ts +130 -0
  48. package/src/graph/scheduler.ts +182 -134
  49. package/src/graph/task-graph.ts +552 -188
  50. package/src/index.ts +16 -2
  51. package/src/orchestrator/admission.ts +41 -17
  52. package/src/orchestrator/colors.ts +8 -3
  53. package/src/orchestrator/deferred-outputs.ts +22 -7
  54. package/src/orchestrator/doctor.ts +122 -14
  55. package/src/orchestrator/events.ts +71 -2
  56. package/src/orchestrator/excluded-keys.ts +155 -0
  57. package/src/orchestrator/execute-task.ts +331 -37
  58. package/src/orchestrator/failure-recap.ts +121 -0
  59. package/src/orchestrator/fingerprint-watch.ts +89 -0
  60. package/src/orchestrator/framed-output.ts +81 -14
  61. package/src/orchestrator/history.ts +6 -7
  62. package/src/orchestrator/hit-restore.ts +82 -18
  63. package/src/orchestrator/index.ts +7 -1
  64. package/src/orchestrator/keyed-projects.ts +89 -0
  65. package/src/orchestrator/local-shortcircuit.ts +83 -23
  66. package/src/orchestrator/lockfile-claim.ts +152 -32
  67. package/src/orchestrator/logger.ts +116 -20
  68. package/src/orchestrator/metrics.ts +156 -60
  69. package/src/orchestrator/miss-save.ts +106 -43
  70. package/src/orchestrator/options.ts +30 -4
  71. package/src/orchestrator/persistent.ts +41 -21
  72. package/src/orchestrator/placement.ts +55 -25
  73. package/src/orchestrator/plan.ts +24 -11
  74. package/src/orchestrator/plugin-host.ts +17 -23
  75. package/src/orchestrator/plugin.ts +31 -8
  76. package/src/orchestrator/prepare.ts +99 -13
  77. package/src/orchestrator/projects.ts +19 -8
  78. package/src/orchestrator/remote-prefetch.ts +21 -1
  79. package/src/orchestrator/run-artifacts.ts +9 -0
  80. package/src/orchestrator/run-context.ts +19 -4
  81. package/src/orchestrator/run-lock.ts +349 -0
  82. package/src/orchestrator/run-records.ts +6 -23
  83. package/src/orchestrator/run-report.ts +4 -2
  84. package/src/orchestrator/run.ts +264 -57
  85. package/src/orchestrator/sandbox-request.ts +388 -62
  86. package/src/orchestrator/save-lane.ts +4 -7
  87. package/src/orchestrator/shell-verdict.ts +117 -0
  88. package/src/orchestrator/signals.ts +86 -30
  89. package/src/orchestrator/stable-keys.ts +202 -10
  90. package/src/orchestrator/status-line.ts +33 -1
  91. package/src/orchestrator/summary.ts +49 -2
  92. package/src/orchestrator/task-hash.ts +87 -7
  93. package/src/orchestrator/task-log-buffer.ts +7 -7
  94. package/src/orchestrator/telemetry-host.ts +16 -7
  95. package/src/orchestrator/telemetry.ts +69 -28
  96. package/src/orchestrator/upstream.ts +91 -45
  97. package/src/util/bun-version.ts +43 -0
  98. package/src/util/errors.ts +86 -0
  99. package/src/util/hash.ts +10 -1
  100. package/src/util/index.ts +32 -3
  101. package/src/util/paths.ts +147 -5
  102. package/src/util/procfs.ts +20 -0
  103. package/src/util/settle.ts +4 -2
  104. package/src/util/size.ts +33 -0
  105. package/src/util/task-id.ts +20 -0
  106. package/src/util/timing.ts +11 -1
  107. package/src/util/which.ts +48 -0
  108. package/src/workspace/affected.ts +272 -64
  109. package/src/workspace/config-cache.ts +262 -32
  110. package/src/workspace/config-eval.ts +49 -12
  111. package/src/workspace/config-imports.ts +126 -5
  112. package/src/workspace/config-schema.ts +237 -41
  113. package/src/workspace/filter.ts +33 -18
  114. package/src/workspace/fingerprint.ts +29 -11
  115. package/src/workspace/index.ts +7 -0
  116. package/src/workspace/json-data.ts +90 -0
  117. package/src/workspace/load-reads.ts +32 -0
  118. package/src/workspace/lockfile.ts +33 -11
  119. package/src/workspace/migrate-scripts.ts +60 -7
  120. package/src/workspace/migration.ts +40 -1
  121. package/src/workspace/package-graph.ts +154 -16
  122. package/src/workspace/project-loader.ts +227 -77
  123. package/src/workspace/workspace.ts +173 -23
@@ -25,12 +25,119 @@
25
25
  // closure, and the containment channel already selects the project that
26
26
  // owns it.
27
27
 
28
+ import { existsSync } from 'node:fs'
28
29
  import { realpath } from 'node:fs/promises'
30
+ import { builtinModules } from 'node:module'
29
31
  import path from 'node:path'
30
32
  import type { ProjectMeta } from './workspace.js'
31
33
 
34
+ const BUILTINS = new Set(builtinModules)
35
+
36
+ /**
37
+ * One transpiler per loader, made on first use: constructing one costs
38
+ * 42 µs against 12 µs for the scan itself (measured 2026-09-16), and a
39
+ * fresh one per config put 1000 cold evaluations 130 ms behind.
40
+ */
41
+ const transpilers: Partial<Record<'ts' | 'js', Bun.Transpiler>> = {}
42
+ function scanner(loader: 'ts' | 'js'): Bun.Transpiler {
43
+ return (transpilers[loader] ??= new Bun.Transpiler({ loader }))
44
+ }
45
+
46
+ /**
47
+ * Whether Bun's own parser finds an ESM `export` in `source` — the one
48
+ * syntax that makes Bun evaluate a file as a module whatever else it says.
49
+ * Without one, a file that touches `module`, `exports`, `require`, `this`
50
+ * or `__dirname` at the top level is CommonJS to Bun. False on source the
51
+ * parser refuses: the evaluation names that error.
52
+ */
53
+ export function hasEsmExport(source: string, loader: 'ts' | 'js'): boolean {
54
+ try {
55
+ return scanner(loader).scan(source).exports.length > 0
56
+ } catch {
57
+ return false
58
+ }
59
+ }
60
+
61
+ /** The package a bare specifier names: `@scope/name` or the first segment. */
62
+ function packageOf(spec: string): string {
63
+ const parts = spec.split('/')
64
+ return spec.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0]!
65
+ }
66
+
67
+ const SPECIFIER =
68
+ /\b(?:from|import)\s*\(?\s*["']([^"'./][^"']*)["']|\brequire\s*\(\s*["']([^"'./][^"']*)["']/g
69
+
70
+ /** Whether a specifier is one the walk would have to look up at all. */
71
+ function needsLookup(spec: string): boolean {
72
+ if (spec.includes(':') || BUILTINS.has(spec)) return false
73
+ return !(spec === '@vzn/vx' || spec.startsWith('@vzn/vx/'))
74
+ }
75
+
76
+ /**
77
+ * A textual pass before the transpiler's: its scan costs ~50 µs per config
78
+ * in situ (1000 cold evaluations: 50 ms, measured 2026-09-16), and most
79
+ * configs import `@vzn/vx` and nothing else. Every static specifier is a
80
+ * quoted string after `from`, `import` or `require(`; a candidate found
81
+ * here is checked exactly by the scan (a comment or a type-only import is
82
+ * the scan's to drop), and a source with no candidate skips it.
83
+ */
84
+ function hasBareCandidate(source: string): boolean {
85
+ SPECIFIER.lastIndex = 0
86
+ for (let m = SPECIFIER.exec(source); m !== null; m = SPECIFIER.exec(source)) {
87
+ if (needsLookup(m[1] ?? m[2] ?? '')) return true
88
+ }
89
+ return false
90
+ }
91
+
92
+ /**
93
+ * The bare specifiers of `source` that no `node_modules/<package>` above
94
+ * `fromDir` provides — the imports Bun would AUTO-INSTALL from the npm
95
+ * registry rather than fail, when no `node_modules` exists anywhere above
96
+ * (measured 2026-09-16: sixteen registry connections and 150 ms before
97
+ * "cannot find"; with one present, 0 and 1 ms). A config is evaluated in
98
+ * the user's process with the user's network: a fresh clone before its
99
+ * install, or a typo, must be a refusal, never a download. Builtins
100
+ * (`node:fs`, `fs`, `bun:sqlite`) and `@vzn/vx` (served by the core alias
101
+ * inside the compiled binary) are never in the list; relative and absolute
102
+ * specifiers resolve by path and are the loader's to refuse.
103
+ */
104
+ export function unprovidedBareImports(
105
+ source: string,
106
+ fromDir: string,
107
+ loader: 'ts' | 'js',
108
+ ): string[] {
109
+ if (!hasBareCandidate(source)) return []
110
+ let specifiers: string[]
111
+ try {
112
+ specifiers = scanner(loader)
113
+ .scanImports(source)
114
+ .map((i) => i.path)
115
+ } catch {
116
+ return [] // unparseable: the evaluation names the syntax error
117
+ }
118
+ const out: string[] = []
119
+ for (const spec of specifiers) {
120
+ if (spec.startsWith('.') || spec.startsWith('/') || !needsLookup(spec)) continue
121
+ const pkg = packageOf(spec)
122
+ let dir = path.resolve(fromDir)
123
+ let provided = false
124
+ for (;;) {
125
+ if (existsSync(path.join(dir, 'node_modules', pkg))) {
126
+ provided = true
127
+ break
128
+ }
129
+ const up = path.dirname(dir)
130
+ if (up === dir) break
131
+ dir = up
132
+ }
133
+ if (!provided && !out.includes(spec)) out.push(spec)
134
+ }
135
+ return out
136
+ }
137
+
32
138
  /**
33
- * Absolute resolved targets of the RELATIVE specifiers in `source`.
139
+ * Absolute resolved targets of the RELATIVE specifiers in `source`; one that
140
+ * does not resolve contributes the paths it names.
34
141
  *
35
142
  * Paths come back realpath'd, because `Bun.resolveSync` realpaths them — see
36
143
  * the caller, which realpaths everything it compares against for exactly that
@@ -40,7 +147,9 @@ import type { ProjectMeta } from './workspace.js'
40
147
  function scanLocalImports(source: string, fromDir: string, loader: 'ts' | 'js'): string[] {
41
148
  let specifiers: string[]
42
149
  try {
43
- specifiers = new Bun.Transpiler({ loader }).scanImports(source).map((i) => i.path)
150
+ specifiers = scanner(loader)
151
+ .scanImports(source)
152
+ .map((i) => i.path)
44
153
  } catch {
45
154
  return [] // unparseable source contributes no edges
46
155
  }
@@ -50,9 +159,18 @@ function scanLocalImports(source: string, fromDir: string, loader: 'ts' | 'js'):
50
159
  try {
51
160
  out.push(Bun.resolveSync(spec, fromDir))
52
161
  } catch {
53
- // Unresolvable (deleted, typo, extensionless miss) — a config that will
54
- // not load cannot be shown to import anything, and failing selection
55
- // over it would break a working build.
162
+ // Unresolvable: most often the change itself deleted or renamed the
163
+ // target, and the config that imports it is exactly the project the
164
+ // change broke. The edge is the path the specifier names (and, bare
165
+ // of an extension, the files Bun would have tried), so the deleted
166
+ // path in the diff still reaches its importer (item 958). Selection
167
+ // may widen; it is never hashed.
168
+ const named = path.resolve(fromDir, spec)
169
+ out.push(named)
170
+ if (path.extname(spec) === '') {
171
+ for (const ext of RESOLVED_EXTENSIONS)
172
+ out.push(named + ext, path.join(named, 'index' + ext))
173
+ }
56
174
  }
57
175
  }
58
176
  return out
@@ -92,6 +210,9 @@ async function realDirIndex(projects: readonly ProjectMeta[]): Promise<Map<strin
92
210
 
93
211
  const TS_EXT = new Set(['.ts', '.mts', '.cts'])
94
212
 
213
+ /** The extensions Bun tries for a specifier that names none. */
214
+ const RESOLVED_EXTENSIONS = ['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs', '.json']
215
+
95
216
  /** The deepest project containing `file`, or undefined when none does. */
96
217
  function ownerOf(file: string, dirToName: ReadonlyMap<string, string>): string | undefined {
97
218
  let dir = path.dirname(file)
@@ -5,6 +5,7 @@
5
5
  // it accepts, and `validateProjectConfig` is the one boundary the loader,
6
6
  // the lockfile's frozen path and the plugin `project` stage all cross.
7
7
 
8
+ import path from 'node:path'
8
9
  import {
9
10
  PLUGIN_FUNCTION_HOOKS,
10
11
  PLUGIN_HOOKS,
@@ -18,17 +19,67 @@ import {
18
19
  MAX_TIMEOUT_MS,
19
20
  nearest,
20
21
  normalizeGlob,
22
+ parseDuration,
23
+ parseSize,
21
24
  UserError,
25
+ BUN_GLOB_WILDCARDS,
22
26
  } from '../util/index.js'
23
- import { WORKSPACE_FINGERPRINT_FILES } from './fingerprint.js'
27
+ import { nonJsonMessage, nonJsonPaths } from './json-data.js'
24
28
 
25
29
  // Mirrors `WorkspaceConfig` in src/config.ts. Unknown keys are REJECTED for
26
30
  // the same reason the task levels reject them: `plugin: [...]` (singular)
27
31
  // declared no plugins and ran the workspace bare, `cacheDirectory` left the
28
32
  // cache where it was — a config that loads and quietly does nothing it says.
29
- const WORKSPACE_FIELDS = new Set(['concurrency', 'cacheDir', 'timeout', 'plugins'])
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
+ }
30
78
 
31
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.
32
83
  assertKnownFields(config, WORKSPACE_FIELDS, configPath)
33
84
  if (config.concurrency !== undefined) {
34
85
  if (
@@ -53,6 +104,7 @@ export function validateWorkspace(config: WorkspaceConfig, configPath: string):
53
104
  }
54
105
  assertTimeoutInRange(config.timeout, `${configPath}: \`timeout\``)
55
106
  }
107
+ if (config.cacheRetention !== undefined) validateRetention(config.cacheRetention, configPath)
56
108
  if (config.plugins !== undefined) {
57
109
  if (!Array.isArray(config.plugins)) {
58
110
  throw new UserError(`${configPath}: \`plugins\` must be an array of plugin objects`)
@@ -150,11 +202,20 @@ export function validateWorkspace(config: WorkspaceConfig, configPath: string):
150
202
  )
151
203
  }
152
204
  for (const file of claim.files as unknown[]) {
153
- // A name core never folds has nothing to take out; claiming it
154
- // would read as covered while the plugin's material is all there is.
155
- if (typeof file !== 'string' || !WORKSPACE_FINGERPRINT_FILES.includes(file)) {
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
+ ) {
156
217
  throw new UserError(
157
- `${configPath}: plugin '${plug.name}' claims fingerprint file ${JSON.stringify(file)}, which core does not fold — one of ${WORKSPACE_FINGERPRINT_FILES.join(', ')}`,
218
+ `${configPath}: plugin '${plug.name}' claims fingerprint file ${JSON.stringify(file)}, which is not a file name at the workspace root`,
158
219
  )
159
220
  }
160
221
  const owner = fileClaimants.get(file)
@@ -186,6 +247,12 @@ export function validateWorkspace(config: WorkspaceConfig, configPath: string):
186
247
  * back from `vx-lock.json` (a hand-editable file — same boundary).
187
248
  */
188
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))
189
256
  // The top level too: `task:` (singular) loaded as a project with no
190
257
  // tasks and every request against it said "no projects declare".
191
258
  assertKnownFields(config, PROJECT_FIELDS, configPath)
@@ -199,6 +266,7 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
199
266
  }
200
267
  for (const [name, task] of Object.entries(tasks)) {
201
268
  const where = `${configPath}: tasks.${name}`
269
+ assertTaskName(name, configPath)
202
270
  if (!task || typeof task !== 'object') {
203
271
  throw new UserError(`${where} must be an object`)
204
272
  }
@@ -234,6 +302,15 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
234
302
  if (remote !== undefined && typeof remote !== 'boolean' && remote !== 'only') {
235
303
  throw new UserError(`${where}.exec.remote must be a boolean or 'only' (or omitted)`)
236
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
+ }
237
314
  const env = (exec as { env?: unknown }).env
238
315
  if (env !== undefined) {
239
316
  if (typeof env !== 'object' || env === null) {
@@ -247,12 +324,9 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
247
324
  // A non-array here reaches `buildIsolatedEnv`'s `for (const name of
248
325
  // passThrough)` — a number throws "not iterable" mid-run, a string
249
326
  // silently char-iterates. Fail loud at load with a config pointer.
250
- if (
251
- !Array.isArray(passThrough) ||
252
- passThrough.some((n) => typeof n !== 'string' || n.length === 0)
253
- ) {
327
+ if (!Array.isArray(passThrough) || passThrough.some((n) => !isEnvName(n))) {
254
328
  throw new UserError(
255
- `${where}.exec.env.passThrough must be an array of non-empty env var names`,
329
+ `${where}.exec.env.passThrough must be an array of env var names (non-empty, no '=' or NUL)`,
256
330
  )
257
331
  }
258
332
  }
@@ -264,8 +338,13 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
264
338
  )
265
339
  }
266
340
  for (const [k, val] of Object.entries(define as Record<string, unknown>)) {
267
- if (typeof val !== 'string') {
268
- throw new UserError(`${where}.exec.env.define.${k} must be a string`)
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`)
269
348
  }
270
349
  }
271
350
  }
@@ -287,6 +366,17 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
287
366
  if (readyWhen !== undefined && typeof readyWhen !== 'string') {
288
367
  throw new UserError(`${where}.exec.persistent.readyWhen must be a string regex`)
289
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
+ }
290
380
  if (cache !== undefined) {
291
381
  throw new UserError(
292
382
  `${where}: \`cache\` is not allowed on a persistent task — persistent tasks ` +
@@ -301,9 +391,13 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
301
391
  }
302
392
  }
303
393
  } else {
304
- // Group task: no exec, just dependencies. Must declare something to
305
- // depend on, otherwise the task is a literal no-op with nothing to
306
- // chain (almost certainly a config mistake).
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.
307
401
  if (dependsOn === undefined) {
308
402
  throw new UserError(
309
403
  `${where}: a task with no \`exec\` must declare \`dependsOn\` ` +
@@ -346,10 +440,11 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
346
440
  if (envList !== undefined) {
347
441
  if (
348
442
  !Array.isArray(envList) ||
349
- envList.some((s) => typeof s !== 'string' || s.length === 0)
443
+ // A NUL can be in no env name, and the key fold's delimiter is one.
444
+ envList.some((s) => !isEnvName(s))
350
445
  ) {
351
446
  throw new UserError(
352
- `${where}.cache.inputs.env must be an array of non-empty env var names`,
447
+ `${where}.cache.inputs.env must be an array of env var names (non-empty, no '=' or NUL)`,
353
448
  )
354
449
  }
355
450
  for (const name of envList as string[]) {
@@ -359,7 +454,9 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
359
454
  // we add expansion later it'll be additive — until then,
360
455
  // surface the footgun instead of returning '' for the
361
456
  // literal env name `'VERCEL_*'`.
362
- if (/[*?[\]]/.test(name)) {
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)) {
363
460
  throw new UserError(
364
461
  `${where}.cache.inputs.env: wildcards in env names are not supported ` +
365
462
  `(got "${name}") — list explicit env var names instead`,
@@ -398,7 +495,8 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
398
495
  }
399
496
  if (namesDirItself(g)) {
400
497
  throw new UserError(
401
- `${where}.cache.outputs.files: "${g}" names the project directory itself and selects nothing — use "**" for everything under it`,
498
+ `${where}.cache.outputs.files: "${g}" names the project directory itself and selects nothing — ` +
499
+ `name the directory the task writes, such as "dist/**"`,
402
500
  )
403
501
  }
404
502
  if (g.startsWith('!')) {
@@ -408,6 +506,14 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
408
506
  `path beginning with '!' and matches nothing. List the outputs you DO produce.`,
409
507
  )
410
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
+ }
411
517
  }
412
518
  // Same for inputs.files.
413
519
  for (const g of (inputs as { files: unknown[] }).files) {
@@ -432,6 +538,15 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
432
538
  `${where}.cache.inputs.files: "${g}" names the project directory itself and selects nothing — use "**" for everything under it`,
433
539
  )
434
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
+ }
435
550
  assertNotDoubleNegated(g, `${where}.cache.inputs.files`)
436
551
  }
437
552
  // A list of ONLY negations selects nothing at all. `resolveFiles`
@@ -519,6 +634,15 @@ const CACHE_INPUT_FIELDS = new Set([
519
634
  const CACHE_OUTPUT_FIELDS = new Set(['files', 'workspaceFiles'])
520
635
 
521
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
+ }
522
646
  for (const key of Object.keys(value)) {
523
647
  if (!allowed.has(key)) {
524
648
  // The nearest accepted spelling first: the list says what the level
@@ -554,14 +678,16 @@ function assertTimeoutInRange(ms: number, where: string): void {
554
678
  }
555
679
 
556
680
  /**
557
- * True when a glob contains a `..` PATH SEGMENT (`../x`, `a/../b`, `x/..`), which
558
- * escapes its base dir — `foo..bar` / `a..b` inside a filename are fine. A
559
- * leading negation marker is stripped first so `!../x` is caught too. Used to
560
- * keep output globs inside the project and workspace globs inside the workspace
561
- * root: `cleanOutputs` rm()s resolved output paths before every run, and
562
- * `Bun.Glob.scan` follows `..` out of its cwd, so a `..` glob is a data-loss
563
- * vector (delete files outside the project / above the repo root).
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 `**`.
564
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
+
565
691
  /**
566
692
  * `.`, `./`, `././` (with or without a `!`) name the directory itself, which
567
693
  * no matcher expands: the entry selected nothing and said so nowhere.
@@ -572,6 +698,15 @@ function namesDirItself(glob: string): boolean {
572
698
  return g === '' || g === '/'
573
699
  }
574
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
+ */
575
710
  function hasParentSegment(glob: string): boolean {
576
711
  const g = glob.startsWith('!') ? glob.slice(1) : glob
577
712
  return g.split('/').some((seg) => seg === '..')
@@ -616,35 +751,96 @@ function assertNotDoubleNegated(glob: string, where: string): void {
616
751
  * next upstream change. `['buidl']` for `['build']` is the shape. Patterns,
617
752
  * wildcards and negations stay silent (a preset-spread pattern legitimately
618
753
  * matches nothing in some projects; excluding what is absent is harmless);
619
- * an exact name must be named by some `dependsOn` entry, exactly or by that
620
- * entry's own `*` pattern. `[]` is the explicit way to decouple.
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.
621
762
  */
622
763
  function assertFilterNamesDeclaredDeps(
623
764
  filters: readonly string[],
624
765
  dependsOn: readonly string[] | undefined,
625
766
  where: string,
626
767
  ): void {
627
- const taskHalf = (spec: string): string => {
628
- const body = spec.startsWith('^') ? spec.slice(1) : spec
629
- const hash = body.lastIndexOf('#')
630
- return hash === -1 ? body : body.slice(hash + 1)
631
- }
632
- const declared = (dependsOn ?? []).map(taskHalf)
633
- const named = (task: string): boolean =>
634
- declared.some((d) => (d.includes('*') ? taskPatternRegExp(d).test(task) : d === task))
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
+ })
635
779
  for (const raw of filters) {
636
- if (raw.startsWith('!') || raw === '*' || raw === '^*') continue
637
- const task = taskHalf(raw)
638
- if (task.length === 0 || task.includes('*') || named(task)) continue
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
639
785
  throw new UserError(
640
786
  `${where}.cache.inputs.tasks: "${raw}" names no task in ${where}.dependsOn ` +
641
787
  `(${declared.length === 0 ? 'none declared' : dependsOn!.map((d) => `'${d}'`).join(', ')}) — ` +
642
788
  `it would match nothing and fold no upstream hash, decoupling the task from its ` +
643
- `dependencies. Fix the name, or use [] to decouple on purpose.`,
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.`,
644
791
  )
645
792
  }
646
793
  }
647
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
+
648
844
  /** The graph's `*`-only task glob (`compileTaskPattern`), mirrored: `*` is the sole metacharacter. */
649
845
  function taskPatternRegExp(pattern: string): RegExp {
650
846
  const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*')