@vzn/vx 0.0.635 → 0.0.637

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 (141) hide show
  1. package/README.md +47 -19
  2. package/docs/caching.md +222 -210
  3. package/docs/cli.md +699 -664
  4. package/docs/execution.md +67 -75
  5. package/docs/features.md +44 -40
  6. package/docs/patterns.md +31 -32
  7. package/docs/schema.md +418 -202
  8. package/docs/security.md +76 -39
  9. package/launcher.sh +43 -0
  10. package/package.json +5 -5
  11. package/schemas/cache.json +41 -19
  12. package/schemas/info.json +23 -11
  13. package/schemas/last.json +48 -2
  14. package/schemas/lock.json +1 -1
  15. package/schemas/plan.json +2 -2
  16. package/schemas/show.json +13 -4
  17. package/schemas/why.json +15 -1
  18. package/skills/vx/SKILL.md +16 -12
  19. package/src/bin.ts +17 -2
  20. package/src/cache/archive.ts +11 -0
  21. package/src/cache/cache.ts +105 -53
  22. package/src/cache/git-inputs.ts +277 -63
  23. package/src/cache/index.ts +7 -1
  24. package/src/cache/inputs.ts +125 -35
  25. package/src/cache/key-fold.ts +8 -7
  26. package/src/cache/layer.ts +8 -17
  27. package/src/cache/layered-cache.ts +16 -102
  28. package/src/cache/policy.ts +25 -29
  29. package/src/cache/run-history.ts +36 -14
  30. package/src/cache/schema.ts +21 -23
  31. package/src/cache/scoped-cache.ts +224 -0
  32. package/src/cli/cache.ts +150 -28
  33. package/src/cli/completions.ts +2 -2
  34. package/src/cli/core-alias.ts +1 -1
  35. package/src/cli/foreign-flags.ts +13 -12
  36. package/src/cli/help.ts +94 -61
  37. package/src/cli/index.ts +42 -13
  38. package/src/cli/info.ts +38 -23
  39. package/src/cli/init.ts +115 -406
  40. package/src/cli/last.ts +145 -4
  41. package/src/cli/lock.ts +333 -111
  42. package/src/cli/plan-format.ts +2 -2
  43. package/src/cli/plugin-commands.ts +5 -9
  44. package/src/cli/plugin-templates.ts +10 -10
  45. package/src/cli/run.ts +123 -144
  46. package/src/cli/select.ts +33 -39
  47. package/src/cli/show.ts +17 -19
  48. package/src/cli/task-verb.ts +2 -9
  49. package/src/cli/upgrade.ts +402 -145
  50. package/src/cli/watch-cycle.ts +9 -2
  51. package/src/cli/watch-filter.ts +33 -60
  52. package/src/cli/watch-fs.ts +29 -16
  53. package/src/cli/watch-judge.ts +12 -3
  54. package/src/cli/watch-set.ts +16 -42
  55. package/src/cli/watch.ts +188 -215
  56. package/src/cli/why.ts +28 -17
  57. package/src/cli/workspace-config.ts +31 -68
  58. package/src/config.ts +191 -71
  59. package/src/exec/env.ts +1 -31
  60. package/src/exec/executor.ts +1 -5
  61. package/src/exec/kill-tree.ts +2 -1
  62. package/src/exec/sandbox-runtime.ts +12 -1
  63. package/src/exec/sandbox-violations.ts +9 -1
  64. package/src/graph/dependency-spec.ts +1 -1
  65. package/src/graph/index.ts +1 -0
  66. package/src/graph/priorities.ts +1 -1
  67. package/src/graph/scheduler.ts +19 -7
  68. package/src/graph/task-graph.ts +44 -20
  69. package/src/handoff.ts +169 -0
  70. package/src/index.ts +25 -57
  71. package/src/orchestrator/affected-tasks.ts +7 -5
  72. package/src/orchestrator/command-windows.ts +44 -0
  73. package/src/orchestrator/deferred-outputs.ts +12 -10
  74. package/src/orchestrator/doctor.ts +98 -110
  75. package/src/orchestrator/download-policy.ts +3 -3
  76. package/src/orchestrator/events.ts +19 -3
  77. package/src/orchestrator/excluded-keys.ts +0 -2
  78. package/src/orchestrator/execute-task.ts +192 -70
  79. package/src/orchestrator/fingerprint-watch.ts +18 -15
  80. package/src/orchestrator/forecast.ts +10 -2
  81. package/src/orchestrator/framed-output.ts +8 -3
  82. package/src/orchestrator/graph-stage.ts +5 -5
  83. package/src/orchestrator/history.ts +70 -30
  84. package/src/orchestrator/hit-restore.ts +15 -3
  85. package/src/orchestrator/index.ts +18 -4
  86. package/src/orchestrator/local-shortcircuit.ts +2 -1
  87. package/src/orchestrator/lockfile-claim.ts +79 -20
  88. package/src/orchestrator/logger.ts +71 -39
  89. package/src/orchestrator/metrics.ts +71 -6
  90. package/src/orchestrator/miss-save.ts +3 -0
  91. package/src/orchestrator/options.ts +43 -28
  92. package/src/orchestrator/output-host.ts +72 -0
  93. package/src/orchestrator/plan.ts +6 -8
  94. package/src/orchestrator/plugin-host.ts +87 -63
  95. package/src/orchestrator/plugin.ts +94 -60
  96. package/src/orchestrator/prepare.ts +114 -73
  97. package/src/orchestrator/projects.ts +102 -99
  98. package/src/orchestrator/remote-prefetch.ts +2 -1
  99. package/src/orchestrator/run-context.ts +25 -6
  100. package/src/orchestrator/run-failures.ts +17 -8
  101. package/src/orchestrator/run-lock.ts +1 -1
  102. package/src/orchestrator/run-report.ts +9 -9
  103. package/src/orchestrator/run-settings.ts +48 -0
  104. package/src/orchestrator/run.ts +127 -88
  105. package/src/orchestrator/sandbox-fix.ts +39 -20
  106. package/src/orchestrator/sandbox-request.ts +84 -13
  107. package/src/orchestrator/signals.ts +1 -1
  108. package/src/orchestrator/stable-keys.ts +13 -11
  109. package/src/orchestrator/status-line.ts +48 -6
  110. package/src/orchestrator/summary.ts +46 -62
  111. package/src/orchestrator/task-hash.ts +12 -83
  112. package/src/orchestrator/telemetry.ts +3 -3
  113. package/src/settings.ts +417 -0
  114. package/src/util/errors.ts +1 -1
  115. package/src/util/essential-env.ts +31 -0
  116. package/src/util/hide-env.ts +107 -0
  117. package/src/util/index.ts +8 -2
  118. package/src/util/paths.ts +10 -0
  119. package/src/util/settle.ts +49 -30
  120. package/src/util/size.ts +23 -4
  121. package/src/util/timing.ts +3 -2
  122. package/src/util/ulid.ts +1 -1
  123. package/src/util/verbs.ts +0 -15
  124. package/src/workspace/affected.ts +77 -222
  125. package/src/workspace/config-eval.ts +15 -20
  126. package/src/workspace/config-imports.ts +78 -3
  127. package/src/workspace/config-schema.ts +251 -241
  128. package/src/workspace/dependency-rules.ts +57 -0
  129. package/src/workspace/filter.ts +2 -2
  130. package/src/workspace/fingerprint.ts +11 -121
  131. package/src/workspace/index.ts +22 -27
  132. package/src/workspace/load-reads.ts +4 -4
  133. package/src/workspace/lockfile.ts +96 -140
  134. package/src/workspace/migration.ts +242 -146
  135. package/src/workspace/package-graph.ts +30 -216
  136. package/src/workspace/project-loader.ts +80 -285
  137. package/src/workspace/repo-id.ts +2 -1
  138. package/src/workspace/workspace.ts +362 -831
  139. package/src/cache/config-evals.ts +0 -130
  140. package/src/workspace/config-cache.ts +0 -661
  141. package/src/workspace/migrate-scripts.ts +0 -1121
@@ -1,5 +1,5 @@
1
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
2
+ // `.vx/workspace.*`, split from the loader (which decides HOW a file is
3
3
  // evaluated) on 2026-09-10. Every object level refuses a key it does not
4
4
  // know (tests/schema-unknown-keys), every message names the level and what
5
5
  // it accepts, and `validateProjectConfig` is the one boundary the loader,
@@ -10,13 +10,13 @@ import {
10
10
  PLUGIN_FUNCTION_HOOKS,
11
11
  PLUGIN_HOOKS,
12
12
  PLUGIN_PACKAGE,
13
+ type InputStability,
13
14
  type Plugin,
14
15
  type ProjectConfig,
15
16
  type WorkspaceConfig,
16
17
  } from '../config.js'
17
18
  import {
18
19
  CORE_VERBS,
19
- MAX_TIMEOUT_MS,
20
20
  nearest,
21
21
  normalizeGlob,
22
22
  parseDuration,
@@ -28,6 +28,14 @@ import {
28
28
  asTrees,
29
29
  isLiteralPattern,
30
30
  } from '../util/index.js'
31
+ import {
32
+ parseSettingValue,
33
+ setting,
34
+ SETTING_GROUPS,
35
+ SETTINGS,
36
+ settingEnv,
37
+ settingRefusal,
38
+ } from '../settings.js'
31
39
  import { compileNameGlob, parseFilter } from './filter.js'
32
40
  import { nonJsonMessage, nonJsonPaths } from './json-data.js'
33
41
 
@@ -36,54 +44,173 @@ import { nonJsonMessage, nonJsonPaths } from './json-data.js'
36
44
  // declared no plugins and ran the workspace bare, `cacheDirectory` left the
37
45
  // cache where it was — a config that loads and quietly does nothing it says.
38
46
  const CACHE_SCOPE_RULE =
39
- "must be 'trusted', 'read-only', or a scope name of letters, digits and . _ - / @ (at most 128), like 'pr-123'"
47
+ "must be a scope name of letters, digits and . _ - / @ (at most 128), like 'pr-123'"
40
48
 
41
- /** `cacheScope`'s rule, shared with `VX_CACHE_SCOPE`. */
49
+ /** `cache.scope`'s rule, shared with `VX_CACHE_SCOPE`. */
42
50
  export function isCacheScope(v: unknown): v is string {
43
51
  return typeof v === 'string' && /^[\w.@/-]{1,128}$/.test(v)
44
52
  }
45
53
 
46
- /** Why `VX_CACHE_SCOPE` is refused, in `cacheScope`'s words. */
54
+ /** Why a `VX_CACHE_SCOPE` or `--cache-scope` value is refused, in `cache.scope`'s words. */
55
+ export function cacheScopeError(source = 'VX_CACHE_SCOPE'): string {
56
+ return `${source} ${CACHE_SCOPE_RULE}`
57
+ }
58
+
59
+ /** Why `VX_CACHE_SCOPE` is refused, as the run's error. */
47
60
  export function cacheScopeEnvError(): UserError {
48
- return new UserError(`VX_CACHE_SCOPE ${CACHE_SCOPE_RULE}`)
61
+ return new UserError(cacheScopeError())
49
62
  }
50
63
 
51
- const WORKSPACE_FIELDS = new Set([
52
- 'concurrency',
53
- 'cacheDir',
54
- 'timeout',
55
- 'cacheRetention',
56
- 'affectedBase',
57
- 'cacheScope',
58
- 'rules',
59
- 'schedule',
60
- 'plugins',
61
- ])
64
+ const STRATEGIES = ['immutable', 'mutable', 'unchecked'] as const
65
+ const SEVERITIES = ['warn', 'error'] as const
66
+ const INPUT_STABILITY_FIELDS = new Set(['strategy', 'severity'])
67
+ const oneOf = (values: readonly string[]): string =>
68
+ `must be ${values
69
+ .map((v) => `'${v}'`)
70
+ .join(', ')
71
+ .replace(/, ([^,]*)$/, ' or $1')}`
62
72
 
63
- type Schedule = NonNullable<WorkspaceConfig['schedule']>
64
- const SCHEDULES = {
65
- 'most-work': true,
66
- 'critical-path': true,
67
- 'direct-dependents': true,
68
- 'ready-order': true,
69
- } satisfies Record<Schedule, true>
73
+ function validateInputStability(stability: unknown, at: string): void {
74
+ if (typeof stability !== 'object' || stability === null || Array.isArray(stability)) {
75
+ throw new UserError(`${at} must be an object — \`inputStability: { strategy: 'mutable' }\``)
76
+ }
77
+ assertKnownFields(stability, INPUT_STABILITY_FIELDS, at)
78
+ const { strategy, severity } = stability as Record<string, unknown>
79
+ if (strategy !== undefined && !STRATEGIES.includes(strategy as never)) {
80
+ throw new UserError(`${at}.strategy ${oneOf(STRATEGIES)}`)
81
+ }
82
+ if (severity !== undefined && !SEVERITIES.includes(severity as never)) {
83
+ throw new UserError(`${at}.severity ${oneOf(SEVERITIES)}`)
84
+ }
85
+ }
70
86
 
71
- const RULE_FIELDS = new Set(['exclusiveOutputs', 'upfrontKeys'])
87
+ /**
88
+ * `VX_CACHE_INPUT_STABILITY_STRATEGY` and `_SEVERITY`, which override every
89
+ * task's `cache.inputStability` for the run: each undefined when unset or
90
+ * empty, refused when it names no value.
91
+ */
92
+ export function inputStabilityEnv(): InputStability {
93
+ const strategy = settingEnv('cache.inputStability.strategy') as InputStability['strategy']
94
+ const severity = settingEnv('cache.inputStability.severity') as InputStability['severity']
95
+ return {
96
+ ...(strategy !== undefined ? { strategy } : {}),
97
+ ...(severity !== undefined ? { severity } : {}),
98
+ }
99
+ }
72
100
 
73
- function validateRules(rules: unknown, configPath: string): void {
74
- const where = `${configPath}: \`rules\``
75
- if (rules === null || typeof rules !== 'object' || Array.isArray(rules)) {
76
- throw new UserError(`${where} must be { exclusiveOutputs?: boolean; upfrontKeys?: boolean }`)
101
+ const WORKSPACE_FIELDS = new Set<string>([...SETTING_GROUPS, 'plugins'])
102
+
103
+ /**
104
+ * `.vx/workspace`'s settings, as `src/settings.ts` lists them: each group's
105
+ * fields, and the nested objects (`run.rules`) with theirs.
106
+ */
107
+ const GROUP_FIELDS = new Map<string, Set<string>>()
108
+ for (const s of SETTINGS) {
109
+ if (s.config === false) continue
110
+ const parts = s.path.split('.')
111
+ for (let i = 1; i < parts.length; i++) {
112
+ const at = parts.slice(0, i).join('.')
113
+ if (!GROUP_FIELDS.has(at)) GROUP_FIELDS.set(at, new Set())
114
+ GROUP_FIELDS.get(at)!.add(parts[i]!)
77
115
  }
78
- assertKnownFields(rules as Record<string, unknown>, RULE_FIELDS, where)
79
- for (const [name, value] of Object.entries(rules)) {
80
- if (value !== undefined && typeof value !== 'boolean') {
81
- throw new UserError(`${where}.${name} must be true or false`)
116
+ }
117
+ // A list of objects, so no setting: no variable or flag can say it.
118
+ GROUP_FIELDS.get('run.rules')!.add('dependencies')
119
+
120
+ /** A setting's value or the level holding it, held to the schema. */
121
+ function validateSettingsAt(value: unknown, at: string, configPath: string): void {
122
+ const where = `${configPath}: \`${at}\``
123
+ const fields = GROUP_FIELDS.get(at)
124
+ if (fields !== undefined) {
125
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
126
+ if (at === 'cache')
127
+ // Turbo's and Nx's workspace `cache: true` is a task's own `cache` in vx (D-98).
128
+ throw new UserError(
129
+ `${where} must be an object like { dir, policy, scope }; a task caches through its own \`cache\` in its project's vx.config`,
130
+ )
131
+ throw new UserError(`${where} must be an object (fields: ${[...fields].join(', ')})`)
132
+ }
133
+ assertKnownFields(value, fields, where)
134
+ for (const [k, v] of Object.entries(value)) {
135
+ if (v !== undefined) validateSettingsAt(v, `${at}.${k}`, configPath)
82
136
  }
137
+ return
83
138
  }
139
+ if (at === 'run.rules.dependencies') validateDependencyRules(value, where)
140
+ else validateSettingValue(value, at, where)
84
141
  }
85
142
 
86
- const RETENTION_FIELDS = new Set(['olderThan', 'maxSize'])
143
+ const RULE_FIELDS = new Set(['from', 'allow', 'deny'])
144
+
145
+ function validateDependencyRules(value: unknown, where: string): void {
146
+ const shape = `an array of { from, allow?, deny? }, each a selector like 'tag:app' or '@acme/*'`
147
+ if (!Array.isArray(value)) throw new UserError(`${where} must be ${shape}`)
148
+ const selector = (v: unknown) => typeof v === 'string' && v.trim() !== '' && v !== 'tag:'
149
+ for (const [i, rule] of (value as unknown[]).entries()) {
150
+ const at = `${where}[${i}]`
151
+ if (typeof rule !== 'object' || rule === null || Array.isArray(rule))
152
+ throw new UserError(`${at} must be { from, allow?, deny? }`)
153
+ assertKnownFields(rule, RULE_FIELDS, at)
154
+ const { from, allow, deny } = rule as Record<string, unknown>
155
+ if (!selector(from)) throw new UserError(`${at}.from must be a selector like 'tag:app'`)
156
+ for (const [k, list] of [
157
+ ['allow', allow],
158
+ ['deny', deny],
159
+ ] as const) {
160
+ if (list !== undefined && (!Array.isArray(list) || !list.every(selector)))
161
+ throw new UserError(`${at}.${k} must be an array of selectors like 'tag:lib'`)
162
+ }
163
+ // A rule that says nothing reads as a constraint and checks nothing.
164
+ if (allow === undefined && deny === undefined)
165
+ throw new UserError(`${at} names neither \`allow\` nor \`deny\``)
166
+ }
167
+ }
168
+
169
+ /** `value` as the setting at `path` takes it, refused in `where`'s words. */
170
+ function validateSettingValue(value: unknown, path: string, where: string): void {
171
+ const { kind } = setting(path)
172
+ // The bound `vx cache clean` refuses too: each run would empty the cache,
173
+ // the entries it just saved included, so nothing ever hits (item 969).
174
+ if (
175
+ path === 'cache.retention.olderThan' &&
176
+ typeof value === 'string' &&
177
+ parseDuration(value) === 0
178
+ ) {
179
+ throw new UserError(`${where} of 0 evicts every entry after every run`)
180
+ }
181
+ // A file's values are typed: a count or a switch is no string (only a
182
+ // share of the cores is), as a variable's or a flag's must be.
183
+ const untyped =
184
+ (kind.type === 'concurrency' && typeof value === 'string' && !value.endsWith('%')) ||
185
+ (kind.type === 'int' && typeof value !== 'number') ||
186
+ (kind.type === 'bool' && typeof value !== 'boolean')
187
+ if (untyped || parseSettingValue(kind, value) === null) {
188
+ throw new UserError(settingRefusal(path, where, value, shown(value)))
189
+ }
190
+ }
191
+
192
+ /** The rules a setting's kind cannot say. */
193
+ function validateSettingsExtra(config: WorkspaceConfig, configPath: string): void {
194
+ const dir = config.cache?.dir
195
+ if (dir !== undefined && dir.includes('\0')) {
196
+ // No path can carry one: the mkdir's argument error was reported as an
197
+ // unwritable workspace.
198
+ throw new UserError(`${configPath}: \`cache.dir\` holds a NUL, which no path can carry`)
199
+ }
200
+ const base = config.run?.affectedBase
201
+ // A leading '-' would reach git as an option, not a ref; a NUL fails the
202
+ // spawn with an argument error naming nothing in this file.
203
+ if (base !== undefined && !/^[^-\s\0][^\s\0]*$/.test(base)) {
204
+ throw new UserError(`${configPath}: \`run.affectedBase\` must be a git ref like 'origin/main'`)
205
+ }
206
+ // `policy`'s grammar is the cache module's (`parseCachePolicy`), which
207
+ // this module may not import: the run parses it before any task (prepare.ts).
208
+ const scope = config.cache?.scope
209
+ if (scope !== undefined && !isCacheScope(scope)) {
210
+ throw new UserError(`${configPath}: \`cache.scope\` ${CACHE_SCOPE_RULE}`)
211
+ }
212
+ if (config.cache?.retention !== undefined) validateRetention(config.cache.retention, configPath)
213
+ }
87
214
 
88
215
  /**
89
216
  * A refused value as a message quotes it. A workspace file is evaluated
@@ -95,43 +222,26 @@ function shown(v: unknown): string {
95
222
  return typeof v === 'bigint' ? `${v}n` : (JSON.stringify(v) ?? String(v))
96
223
  }
97
224
 
98
- function validateRetention(retention: unknown, configPath: string): void {
99
- const where = `${configPath}: \`cacheRetention\``
100
- const field = (key: string) => `${configPath}: \`cacheRetention.${key}\``
101
- if (retention === null || typeof retention !== 'object' || Array.isArray(retention)) {
102
- throw new UserError(`${where} must be { olderThan?: '30d', maxSize?: '10G' }`)
103
- }
104
- assertKnownFields(retention as Record<string, unknown>, RETENTION_FIELDS, where)
105
- const { olderThan, maxSize } = retention as { olderThan?: unknown; maxSize?: unknown }
225
+ function validateRetention(
226
+ retention: NonNullable<NonNullable<WorkspaceConfig['cache']>['retention']>,
227
+ configPath: string,
228
+ ): void {
229
+ const { olderThan, maxSize } = retention
106
230
  // An empty object would load and evict nothing, which reads as a policy.
107
231
  if (olderThan === undefined && maxSize === undefined) {
108
- throw new UserError(`${where} names neither \`olderThan\` nor \`maxSize\``)
109
- }
110
- if (
111
- olderThan !== undefined &&
112
- (typeof olderThan !== 'string' || parseDuration(olderThan) === null)
113
- ) {
114
232
  throw new UserError(
115
- `${field('olderThan')} must be a duration like '30d', '12h', '90m' or '45s' (got ${shown(olderThan)})`,
233
+ `${configPath}: \`cache.retention\` names neither \`olderThan\` nor \`maxSize\``,
116
234
  )
117
235
  }
118
- if (maxSize !== undefined && (typeof maxSize !== 'string' || parseSize(maxSize) === null)) {
236
+ // As for olderThan (item 969).
237
+ if (maxSize !== undefined && parseSize(maxSize) === 0) {
119
238
  throw new UserError(
120
- `${field('maxSize')} must be a size like '10G', '500MB' or '64KB' (got ${shown(maxSize)})`,
239
+ `${configPath}: \`cache.retention.maxSize\` of 0 evicts every entry after every run`,
121
240
  )
122
241
  }
123
- // The bounds `vx cache prune` refuses, for the same reason: each evicts
124
- // every entry, and here it would do so at the end of every run, the
125
- // entries that run just saved included, so nothing ever hits (item 969).
126
- if (olderThan !== undefined && parseDuration(olderThan) === 0) {
127
- throw new UserError(`${field('olderThan')} of 0 evicts every entry after every run`)
128
- }
129
- if (maxSize !== undefined && parseSize(maxSize) === 0) {
130
- throw new UserError(`${field('maxSize')} of 0 evicts every entry after every run`)
131
- }
132
242
  if (maxSize !== undefined && /^\d+$/.test(maxSize)) {
133
243
  throw new UserError(
134
- `${field('maxSize')} '${maxSize}' reads as ${maxSize} bytes — give a unit (e.g. '${maxSize}M', '${maxSize}G')`,
244
+ `${configPath}: \`cache.retention.maxSize\` '${maxSize}' reads as ${maxSize} bytes — give a unit (e.g. '${maxSize}MB', '${maxSize}GB')`,
135
245
  )
136
246
  }
137
247
  }
@@ -146,65 +256,10 @@ export function validateWorkspace(config: WorkspaceConfig, configPath: string):
146
256
  // objects of functions by design, the file is evaluated in-process on
147
257
  // every load (never through the config worker), and no key folds its JSON.
148
258
  assertKnownFields(config, WORKSPACE_FIELDS, configPath)
149
- if (config.concurrency !== undefined) {
150
- if (
151
- typeof config.concurrency !== 'number' ||
152
- !Number.isFinite(config.concurrency) ||
153
- config.concurrency < 1 ||
154
- !Number.isInteger(config.concurrency)
155
- ) {
156
- throw new UserError(`${configPath}: \`concurrency\` must be a positive integer`)
157
- }
158
- }
159
- if (config.cacheDir !== undefined && typeof config.cacheDir !== 'string') {
160
- throw new UserError(`${configPath}: \`cacheDir\` must be a string`)
161
- }
162
- // `' '` made a directory named three spaces at the root, hidden by the
163
- // cache's own `.gitignore` (X-22).
164
- if (
165
- typeof config.cacheDir === 'string' &&
166
- config.cacheDir !== '' &&
167
- config.cacheDir.trim() === ''
168
- ) {
169
- throw new UserError(`${configPath}: \`cacheDir\` is only whitespace — name a directory`)
170
- }
171
- // No path can carry one: the mkdir's argument error was reported as an
172
- // unwritable workspace.
173
- if (typeof config.cacheDir === 'string' && config.cacheDir.includes('\0')) {
174
- throw new UserError(`${configPath}: \`cacheDir\` holds a NUL, which no path can carry`)
175
- }
176
- if (config.timeout !== undefined) {
177
- if (
178
- typeof config.timeout !== 'number' ||
179
- !Number.isInteger(config.timeout) ||
180
- config.timeout <= 0
181
- ) {
182
- throw new UserError(`${configPath}: \`timeout\` must be a positive integer (milliseconds)`)
183
- }
184
- assertTimeoutInRange(config.timeout, `${configPath}: \`timeout\``)
185
- }
186
- if (config.cacheRetention !== undefined) validateRetention(config.cacheRetention, configPath)
187
- if (config.affectedBase !== undefined) {
188
- // A leading '-' would reach git as an option, not a ref; a NUL fails the
189
- // spawn with an argument error naming nothing in this file.
190
- if (
191
- typeof config.affectedBase !== 'string' ||
192
- !/^[^-\s\0][^\s\0]*$/.test(config.affectedBase)
193
- ) {
194
- throw new UserError(`${configPath}: \`affectedBase\` must be a git ref like 'origin/main'`)
195
- }
196
- }
197
- if (config.cacheScope !== undefined && !isCacheScope(config.cacheScope)) {
198
- throw new UserError(`${configPath}: \`cacheScope\` ${CACHE_SCOPE_RULE}`)
199
- }
200
- if (config.rules !== undefined) validateRules(config.rules, configPath)
201
- if (config.schedule !== undefined && !Object.hasOwn(SCHEDULES, config.schedule)) {
202
- throw new UserError(
203
- `${configPath}: \`schedule\` must be one of ${Object.keys(SCHEDULES)
204
- .map((k) => `'${k}'`)
205
- .join(', ')}`,
206
- )
259
+ for (const group of SETTING_GROUPS) {
260
+ if (config[group] !== undefined) validateSettingsAt(config[group], group, configPath)
207
261
  }
262
+ validateSettingsExtra(config, configPath)
208
263
  if (config.plugins !== undefined) {
209
264
  if (!Array.isArray(config.plugins)) {
210
265
  throw new UserError(`${configPath}: \`plugins\` must be an array of plugin objects`)
@@ -227,7 +282,7 @@ export function validateWorkspace(config: WorkspaceConfig, configPath: string):
227
282
  }
228
283
  // The loose schema's shape (unknown-typed hooks) plus the one retired
229
284
  // key this loader still refuses by name.
230
- const plug = p as Partial<Plugin> & { name?: unknown; backend?: unknown }
285
+ const plug = p as Partial<Plugin> & { name?: unknown }
231
286
  // A plugin's name is its package name and nothing else. `definePlugin`
232
287
  // reads it and stamps it under a registry symbol; a plain object, or
233
288
  // one whose `name` was overwritten after the stamp, is refused here —
@@ -250,21 +305,6 @@ export function validateWorkspace(config: WorkspaceConfig, configPath: string):
250
305
  `${configPath}: \`plugins[${i}].name\` overrides the package name ('${String(plug.name)}' over '${pkg}') — a plugin's name is its package name; drop the field`,
251
306
  )
252
307
  }
253
- // The whole-run `backend` seam was REMOVED on 2026-08-23 with vx
254
- // cloud: a run always executes in the `vx run` process. Nothing
255
- // consults `.backend` any more, so leaving it in `caps` below meant a
256
- // plugin declaring ONLY `backend` — a third-party one written against
257
- // the old API, say — validated as "contributes a capability" and was
258
- // then silently ignored. Refuse it by name and point at the seam that
259
- // replaced it; a silent no-op is the failure this validation exists
260
- // to prevent.
261
- if (plug.backend !== undefined) {
262
- throw new UserError(
263
- `${configPath}: \`plugins[${i}].backend\` is no longer a capability — the whole-run ` +
264
- `backend seam was removed in 2026-08. Use \`executor\` to change where a single ` +
265
- `task's command runs (see docs/architecture.md § plugin capabilities).`,
266
- )
267
- }
268
308
  // A misspelled hook (`excutor`, `setUp`) was never called and never
269
309
  // said: tasks ran here with the plugin's executor unheard (X-19).
270
310
  for (const key of Object.keys(plug)) {
@@ -351,11 +391,10 @@ export function validateWorkspace(config: WorkspaceConfig, configPath: string):
351
391
  )
352
392
  }
353
393
  for (const file of claim.files as unknown[]) {
354
- // A bare name at the workspace root: a lockfile core folds (taken
355
- // out of the fingerprint), or a root file the plugin's stages read
356
- // (`turbo.json`: nothing to take out; `--affected` and `vx watch`
357
- // ask the claimant about it, item 961). A path is refused: the
358
- // root arm `vx watch` keeps is not recursive.
394
+ // A bare name at the workspace root the plugin reads: a lockfile it
395
+ // folds, or a file its stages read (`turbo.json`; `--affected` and
396
+ // `vx watch` ask the claimant about it, item 961). A path is
397
+ // refused: the root arm `vx watch` keeps is not recursive.
359
398
  if (
360
399
  typeof file !== 'string' ||
361
400
  file === '' ||
@@ -400,9 +439,15 @@ export function validateWorkspace(config: WorkspaceConfig, configPath: string):
400
439
  * typechecked (plain .js, or TS with errors ignored). Catch the worst
401
440
  * shape problems early with a clear message rather than letting them
402
441
  * crash deeper in the orchestrator. Also applied to configs loaded
403
- * back from `vx-lock.json` (a hand-editable file — same boundary).
442
+ * back from `.vx/projects.lock.json` (a hand-editable file — same boundary).
443
+ * `ownConfig` is the config file outputs may not cover (null: none), apart
444
+ * from `configPath` when that is a label (`<path> (after plugin 'x')`).
404
445
  */
405
- export function validateProjectConfig(config: ProjectConfig, configPath: string): void {
446
+ export function validateProjectConfig(
447
+ config: ProjectConfig,
448
+ configPath: string,
449
+ ownConfig: string | null = configPath,
450
+ ): void {
406
451
  // Before the schema, on the object as it stands: the key folds its JSON,
407
452
  // so a value JSON cannot carry is refused here and in the config worker
408
453
  // alike (json-data.ts). A plugin's `project` edit and a lock entry cross
@@ -412,6 +457,15 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
412
457
  // The top level too: `task:` (singular) loaded as a project with no
413
458
  // tasks and every request against it said "no projects declare".
414
459
  assertKnownFields(config, PROJECT_FIELDS, configPath)
460
+ const name: unknown = config.name
461
+ if (name !== undefined) {
462
+ // A task is `<project>#<task>`, split at the first `#`.
463
+ if (typeof name !== 'string' || name === '' || name.trim() !== name || name.includes('#')) {
464
+ throw new UserError(
465
+ `${configPath}: \`name\` must be a non-empty string with no surrounding whitespace and no "#" (a task is <name>#<task>)`,
466
+ )
467
+ }
468
+ }
415
469
  const tags: unknown = config.tags
416
470
  if (
417
471
  tags !== undefined &&
@@ -473,10 +527,7 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
473
527
  }
474
528
  const timeout = (exec as { timeout?: unknown }).timeout
475
529
  if (timeout !== undefined) {
476
- if (typeof timeout !== 'number' || !Number.isInteger(timeout) || timeout <= 0) {
477
- throw new UserError(`${where}.exec.timeout must be a positive integer (milliseconds)`)
478
- }
479
- assertTimeoutInRange(timeout, `${where}.exec.timeout`)
530
+ validateSettingValue(timeout, 'run.timeout', `${where}.exec.timeout`)
480
531
  }
481
532
  const retries = (exec as { retries?: unknown }).retries
482
533
  if (retries !== undefined) {
@@ -668,10 +719,9 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
668
719
  )
669
720
  }
670
721
  assertKnownFields(cache, CACHE_FIELDS, `${where}.cache`)
671
- const rewritten = (cache as { rewrittenInputs?: unknown }).rewrittenInputs
672
- if (rewritten !== undefined && rewritten !== 'withhold' && rewritten !== 'compare') {
673
- throw new UserError(`${where}.cache.rewrittenInputs must be 'withhold' or 'compare'`)
674
- }
722
+ const stability = (cache as { inputStability?: unknown }).inputStability
723
+ if (stability !== undefined)
724
+ validateInputStability(stability, `${where}.cache.inputStability`)
675
725
  const inputs = (cache as { inputs?: unknown }).inputs
676
726
  const outputs = (cache as { outputs?: unknown }).outputs
677
727
  // A present field of the wrong shape (`outputs: 'dist'`) was told it
@@ -726,6 +776,10 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
726
776
  )
727
777
  }
728
778
  assertKnownFields(outputs, CACHE_OUTPUT_FIELDS, `${where}.cache.outputs`)
779
+ const clean = (outputs as { clean?: unknown }).clean
780
+ if (clean !== undefined && typeof clean !== 'boolean') {
781
+ throw new UserError(`${where}.cache.outputs.clean must be true or false`)
782
+ }
729
783
  const outFiles = (outputs as { files?: unknown }).files
730
784
  if (!Array.isArray(outFiles)) {
731
785
  throw new UserError(`${where}.cache.outputs.files must be an array of glob strings`)
@@ -774,12 +828,12 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
774
828
  // Taken back by a `!`, the file is never cleaned or restored.
775
829
  const own = g.startsWith('!')
776
830
  ? undefined
777
- : ownFilesCovered(g, configPath).find((f) => !takenBack(outFiles as unknown[], f))
831
+ : ownFilesCovered(g, ownConfig).find((f) => !takenBack(outFiles as unknown[], f))
778
832
  if (own !== undefined) {
779
833
  throw new UserError(
780
834
  `${where}.cache.outputs.files: "${g}" covers the project's own ${own} — vx deletes a ` +
781
835
  `task's outputs before it runs and restores them on a hit, so the project would lose ` +
782
- `its manifest and config. Name the directory the task writes, such as "dist/**", ` +
836
+ `its config. Name the directory the task writes, such as "dist/**", ` +
783
837
  `or take the file back with "!${own}".`,
784
838
  )
785
839
  }
@@ -894,7 +948,7 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
894
948
  // encodes. A new field must be added here deliberately.
895
949
  // `tasks` first: `nearest` breaks a tie by order, and `taks` is one edit
896
950
  // from both.
897
- const PROJECT_FIELDS = new Set(['tasks', 'tags'])
951
+ const PROJECT_FIELDS = new Set(['name', 'tasks', 'tags'])
898
952
  const TASK_FIELDS = new Set(['description', 'exec', 'dependsOn', 'cache'])
899
953
  const EXEC_FIELDS = new Set([
900
954
  'command',
@@ -908,7 +962,7 @@ const EXEC_FIELDS = new Set([
908
962
  ])
909
963
  const PERSISTENT_FIELDS = new Set(['readyWhen'])
910
964
  const ENV_FIELDS = new Set(['passThrough', 'define', 'secret'])
911
- const CACHE_FIELDS = new Set(['inputs', 'outputs', 'rewrittenInputs'])
965
+ const CACHE_FIELDS = new Set(['inputs', 'outputs', 'inputStability'])
912
966
  const CACHE_INPUT_FIELDS = new Set([
913
967
  'files',
914
968
  'workspaceFiles',
@@ -917,41 +971,7 @@ const CACHE_INPUT_FIELDS = new Set([
917
971
  'runtime',
918
972
  'workspaceRuntime',
919
973
  ])
920
- const CACHE_OUTPUT_FIELDS = new Set(['files', 'workspaceFiles'])
921
-
922
- /** A field a release took out of the schema, and what took its place. */
923
- interface Removal {
924
- /** The vx version whose schema first refused it. */
925
- removedIn: string
926
- /** What to write instead, as a sentence fragment after "use". */
927
- use: string
928
- }
929
-
930
- /**
931
- * Fields the schema once accepted, by the level that held them (keyed by
932
- * that level's field set). A removed field is refused naming its
933
- * replacement and the version that removed it, never as an unknown field a
934
- * reader must research (docs/design/versioning-1.0.md § Deprecation). An
935
- * entry stays for good: a config written against an old release meets it
936
- * whenever it upgrades.
937
- */
938
- export const REMOVED_FIELDS: ReadonlyMap<
939
- ReadonlySet<string>,
940
- Readonly<Record<string, Removal>>
941
- > = new Map([
942
- [
943
- EXEC_FIELDS,
944
- {
945
- // Reservations left core with the `admit` seam (item 157).
946
- resources: {
947
- removedIn: '0.0.19',
948
- use:
949
- "`@vzn/vx-schedule-history`, which learns each task's reservation from its run " +
950
- "history (declare one by hand with its `reservations: { 'pkg#task': { cpus, memory } }`)",
951
- },
952
- },
953
- ],
954
- ])
974
+ const CACHE_OUTPUT_FIELDS = new Set(['files', 'workspaceFiles', 'clean'])
955
975
 
956
976
  /**
957
977
  * Another runner's spelling of a field vx has under another name, by the
@@ -983,7 +1003,7 @@ const FOREIGN_FIELDS: ReadonlyMap<ReadonlySet<string>, Readonly<Record<string, s
983
1003
  with: '`dependsOn` on each task it runs beside: a persistent one stays up, and this task starts once it is ready',
984
1004
  cwd: '`cd <dir> && …` in `exec.command`: a task runs in its project directory',
985
1005
  parallelism:
986
- '`concurrency: 1` in vx.workspace or `--concurrency 1` on the run: no task runs alone beside others',
1006
+ '`run.concurrency: 1` in .vx/workspace or `--concurrency 1` on the run: no task runs alone beside others',
987
1007
  configurations:
988
1008
  'one task per configuration (`build:production`, with its own `exec.command`)',
989
1009
  },
@@ -1040,14 +1060,13 @@ const FOREIGN_FIELDS: ReadonlyMap<ReadonlySet<string>, Readonly<Record<string, s
1040
1060
  targets: '`tasks` (a target is a task: `exec.command`, `dependsOn`, `cache`)',
1041
1061
  extends: 'an imported module spread into `tasks` (a vx.config is code; nothing is inherited)',
1042
1062
  implicitDependencies: 'a `dependsOn` entry `pkg#task`, or a package.json dependency',
1043
- name: 'the package.json `name` (a project is named by its package)',
1044
1063
  // A package.json's, turbo.json's and project.json's keys, and a
1045
1064
  // task's fields one level too high (D-99).
1046
1065
  scripts:
1047
1066
  "`tasks` (a script is a task's `exec.command`; `vx init` writes them from package.json)",
1048
1067
  pipeline: '`tasks` (`bunx @vzn/vx-migrate` writes them from turbo.json)',
1049
1068
  namedInputs: 'a module the vx.config imports, spread into `cache.inputs.files`',
1050
- root: "no field: a project's directory is where its package.json sits",
1069
+ root: "no field: a project's directory is where its vx.config sits",
1051
1070
  sourceRoot: "a task's `cache.inputs.files`",
1052
1071
  projectType: 'no field: vx runs every project alike',
1053
1072
  ...Object.fromEntries(
@@ -1061,7 +1080,7 @@ const FOREIGN_FIELDS: ReadonlyMap<ReadonlySet<string>, Readonly<Record<string, s
1061
1080
  env: '`tasks.<name>.cache.inputs.env` (to key a task on it) or `tasks.<name>.exec.env`',
1062
1081
  },
1063
1082
  ],
1064
- // Turbo's turbo.json and Nx's nx.json keys, written into vx.workspace
1083
+ // Turbo's turbo.json and Nx's nx.json keys, written into .vx/workspace
1065
1084
  // (D-38).
1066
1085
  [
1067
1086
  WORKSPACE_FIELDS,
@@ -1077,33 +1096,47 @@ const FOREIGN_FIELDS: ReadonlyMap<ReadonlySet<string>, Readonly<Record<string, s
1077
1096
  globalDependencies: '`cache.inputs.workspaceFiles` on the tasks it keys',
1078
1097
  remoteCache:
1079
1098
  'a cache plugin in `plugins` (`turboCache()` or `nxCache()` from `@vzn/vx-migrate`)',
1080
- parallel: '`concurrency`',
1081
- cacheDirectory: '`cacheDir`',
1082
- defaultBase: '`affectedBase`',
1099
+ parallel: '`run.concurrency`',
1100
+ cacheDirectory: '`cache.dir`',
1101
+ defaultBase: '`run.affectedBase`',
1083
1102
  tasksRunnerOptions: 'a cache plugin in `plugins` (`nxCache()` from `@vzn/vx-migrate`)',
1084
1103
  globalPassThroughEnv: '`exec.env.passThrough` on the tasks it passes to',
1085
1104
  // pnpm-workspace.yaml's, Lerna's and the package.json's own keys
1086
- // pasted into vx.workspace: refused with no word on where vx reads
1105
+ // pasted into .vx/workspace: refused with no word on where vx reads
1087
1106
  // them (D-98).
1088
- packages:
1089
- "`workspaces` in package.json or pnpm-workspace.yaml's `packages`, where vx reads the members",
1090
- workspaces:
1091
- "`workspaces` in package.json or pnpm-workspace.yaml's `packages`, where vx reads the members",
1092
- projects:
1093
- "`workspaces` in package.json or pnpm-workspace.yaml's `packages`, where vx reads the members",
1107
+ packages: 'a `vx.config` in each project directory: a project is a directory holding one',
1108
+ workspaces: 'a `vx.config` in each project directory: a project is a directory holding one',
1109
+ projects: 'a `vx.config` in each project directory: a project is a directory holding one',
1094
1110
  ignore:
1095
- 'a `!` pattern in the workspace globs (package.json `workspaces` or pnpm-workspace.yaml)',
1111
+ "no field: a directory with no `vx.config` is no project, and git's ignore rules apply",
1096
1112
  catalog:
1097
1113
  "pnpm-workspace.yaml's or package.json's `catalog`, where the package manager reads it",
1098
1114
  npmClient: "no field: a task's command runs as written, with no package manager in between",
1099
- cache:
1100
- "each task's `cache` in its project's vx.config (a task caches only when it declares one)",
1101
1115
  remote: 'a cache plugin in `plugins`',
1102
1116
  env: '`cache.inputs.env` on the tasks it keys, or `exec.env` on the tasks it passes to',
1103
1117
  },
1104
1118
  ],
1105
1119
  ])
1106
1120
 
1121
+ /**
1122
+ * A top-level typo of a grouped setting, as its path: by its last name
1123
+ * (`concurency`) or its path run together (`cachedir`).
1124
+ */
1125
+ function nearestSetting(key: string): string | undefined {
1126
+ const byName = new Map<string, string>()
1127
+ for (const s of SETTINGS) {
1128
+ if (s.config === false) continue
1129
+ const parts = s.path.split('.')
1130
+ byName.set(parts.at(-1)!, s.path)
1131
+ byName.set(
1132
+ parts.map((p, i) => (i === 0 ? p : p[0]!.toUpperCase() + p.slice(1))).join(''),
1133
+ s.path,
1134
+ )
1135
+ }
1136
+ const name = nearest(key, byName.keys())
1137
+ return name === undefined ? undefined : `\`${byName.get(name)}\``
1138
+ }
1139
+
1107
1140
  function assertKnownFields(value: object, allowed: ReadonlySet<string>, where: string): void {
1108
1141
  // `typeof [] === 'object'`, so an array reaches here and its indices read
1109
1142
  // as fields: `outputs: ['dist/**']` (Turbo's spelling) was refused as
@@ -1116,16 +1149,11 @@ function assertKnownFields(value: object, allowed: ReadonlySet<string>, where: s
1116
1149
  }
1117
1150
  for (const key of Object.keys(value)) {
1118
1151
  if (!allowed.has(key)) {
1119
- const removal = REMOVED_FIELDS.get(allowed)?.[key]
1120
- if (removal !== undefined) {
1121
- throw new UserError(
1122
- `${where} has field "${key}", which vx ${removal.removedIn} removed — use ${removal.use}`,
1123
- )
1124
- }
1125
1152
  // The nearest accepted spelling first: the list says what the level
1126
1153
  // takes, the hint says which one was meant.
1127
1154
  const foreign = FOREIGN_FIELDS.get(allowed)?.[key]
1128
- const near = nearest(key, allowed)
1155
+ const near =
1156
+ nearest(key, allowed) ?? (allowed === WORKSPACE_FIELDS ? nearestSetting(key) : undefined)
1129
1157
  throw new UserError(
1130
1158
  `${where} has unknown field "${key}" (allowed: ${[...allowed].sort().join(', ')})` +
1131
1159
  (foreign !== undefined
@@ -1139,35 +1167,17 @@ function assertKnownFields(value: object, allowed: ReadonlySet<string>, where: s
1139
1167
  }
1140
1168
 
1141
1169
  /**
1142
- * A millisecond delay past `MAX_TIMEOUT_MS` does not mean "effectively never" —
1143
- * `setTimeout` silently reduces it to 1 ms, so the task is killed the instant it
1144
- * spawns and reported `failed`. That is the exact inverse of the declaration,
1145
- * and the only clue is a `TimeoutOverflowWarning` on stderr.
1146
- *
1147
- * Refused rather than clamped, because this is a value the user WROTE and reads
1148
- * back: silently substituting ~24.8 days for the 317 years they asked for would
1149
- * trade one surprise for a quieter one. Costs nothing — such a config never
1150
- * worked, it killed the task in milliseconds, so refusing reports a state that
1151
- * already existed.
1152
- */
1153
- function assertTimeoutInRange(ms: number, where: string): void {
1154
- if (ms <= MAX_TIMEOUT_MS) return
1155
- throw new UserError(
1156
- `${where}: ${ms} ms exceeds the maximum timer delay (${MAX_TIMEOUT_MS} ms, ~24.8 days). ` +
1157
- `Timers larger than this do NOT mean "no limit" — the platform reduces them to 1 ms, so ` +
1158
- `the task would be killed the moment it starts. Omit \`timeout\` for no limit.`,
1159
- )
1160
- }
1161
-
1162
- /**
1163
- * The project file an output glob would take, or null. `outputs: ['**']`
1164
- * loaded, and the clean before the run deleted the project's source,
1165
- * `package.json` and `vx.config` while the run reported success (item
1166
- * 1002); the message for `'.'` had even suggested `**`.
1170
+ * The project's config file if an output glob would take it. `outputs:
1171
+ * ['**']` loaded, and the clean before the run deleted the project's
1172
+ * source and `vx.config` while the run reported success (item 1002); the
1173
+ * message for `'.'` had even suggested `**`. Core names no other project
1174
+ * file: a file a plugin keys on is an input, and an output takes it out of
1175
+ * the input set like any other.
1167
1176
  */
1168
- function ownFilesCovered(glob: string, configPath: string): string[] {
1169
- const g = new Bun.Glob(normalizeGlob(glob))
1170
- return ['package.json', path.basename(configPath)].filter((f) => g.match(f))
1177
+ function ownFilesCovered(glob: string, ownConfig: string | null): string[] {
1178
+ if (ownConfig === null) return []
1179
+ const own = path.basename(ownConfig)
1180
+ return new Bun.Glob(normalizeGlob(glob)).match(own) ? [own] : []
1171
1181
  }
1172
1182
 
1173
1183
  function takenBack(globs: readonly unknown[], file: string): boolean {