@vzn/vx 0.0.645 → 0.0.648

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 (186) hide show
  1. package/README.md +39 -50
  2. package/launcher.cjs +9 -2
  3. package/launcher.sh +2 -2
  4. package/package.json +5 -5
  5. package/schemas/cache.json +5 -5
  6. package/schemas/error.json +1 -1
  7. package/schemas/info.json +16 -2
  8. package/schemas/last.json +127 -5
  9. package/schemas/plan.json +28 -2
  10. package/schemas/show.json +10 -1
  11. package/skills/vx/SKILL.md +19 -3
  12. package/src/bin.ts +54 -13
  13. package/src/cache/archive.ts +324 -23
  14. package/src/cache/cache.ts +330 -65
  15. package/src/cache/git-inputs.ts +274 -97
  16. package/src/cache/index.ts +7 -2
  17. package/src/cache/inputs.ts +515 -83
  18. package/src/cache/key-fold.ts +15 -6
  19. package/src/cache/layer.ts +74 -11
  20. package/src/cache/layered-cache.ts +154 -59
  21. package/src/cache/linked-inputs.ts +1 -1
  22. package/src/cache/output-index.ts +44 -14
  23. package/src/cache/plugin-cache.ts +49 -23
  24. package/src/cache/remote-hits.ts +72 -0
  25. package/src/cache/remote-lane.ts +100 -15
  26. package/src/cache/run-history.ts +77 -23
  27. package/src/cache/run-store.ts +11 -10
  28. package/src/cache/schema.ts +42 -6
  29. package/src/cache/tar-stream.ts +5 -0
  30. package/src/cache/umask.ts +1 -2
  31. package/src/cli/cache.ts +58 -27
  32. package/src/cli/completions.ts +12 -5
  33. package/src/cli/core-alias.ts +5 -2
  34. package/src/cli/docs.ts +42 -16
  35. package/src/cli/help.ts +62 -25
  36. package/src/cli/index.ts +79 -45
  37. package/src/cli/info.ts +12 -6
  38. package/src/cli/init.ts +222 -34
  39. package/src/cli/last.ts +443 -47
  40. package/src/cli/lock.ts +197 -52
  41. package/src/cli/plan-format.ts +91 -49
  42. package/src/cli/plugin-commands.ts +160 -11
  43. package/src/cli/plugin-templates.ts +13 -13
  44. package/src/cli/registry.ts +209 -0
  45. package/src/cli/run.ts +111 -41
  46. package/src/cli/select.ts +148 -49
  47. package/src/cli/show.ts +59 -16
  48. package/src/cli/task-verb.ts +16 -6
  49. package/src/cli/upgrade.ts +155 -54
  50. package/src/cli/watch-cycle.ts +10 -5
  51. package/src/cli/watch-filter.ts +71 -3
  52. package/src/cli/watch-fs.ts +22 -6
  53. package/src/cli/watch-judge.ts +48 -6
  54. package/src/cli/watch.ts +163 -42
  55. package/src/cli/why.ts +211 -73
  56. package/src/cli/workspace-config.ts +54 -15
  57. package/src/config.ts +13 -1
  58. package/src/exec/env.ts +7 -2
  59. package/src/exec/executor.ts +262 -15
  60. package/src/exec/index.ts +8 -2
  61. package/src/exec/kill-tree.ts +15 -0
  62. package/src/exec/local-executor.ts +1 -1
  63. package/src/exec/proc-sample.ts +70 -6
  64. package/src/exec/runner.ts +256 -53
  65. package/src/exec/sandbox-binds.ts +7 -3
  66. package/src/exec/sandbox-paths.ts +1 -1
  67. package/src/exec/sandbox-runtime.ts +222 -28
  68. package/src/exec/sandbox-violations.ts +52 -20
  69. package/src/graph/critical-path.ts +79 -0
  70. package/src/graph/default-build.ts +19 -0
  71. package/src/graph/definition.ts +38 -0
  72. package/src/graph/folds.ts +38 -5
  73. package/src/graph/index.ts +4 -1
  74. package/src/graph/priorities.ts +74 -8
  75. package/src/graph/scheduler.ts +326 -65
  76. package/src/graph/task-graph.ts +228 -35
  77. package/src/handoff.ts +97 -9
  78. package/src/history/failure-mode.ts +6 -3
  79. package/src/history/history.ts +268 -50
  80. package/src/history/index.ts +2 -0
  81. package/src/history/metrics.ts +142 -45
  82. package/src/index.ts +42 -10
  83. package/src/orchestrator/admission.ts +10 -6
  84. package/src/orchestrator/affected-tasks.ts +11 -6
  85. package/src/orchestrator/deferred-outputs.ts +2 -0
  86. package/src/orchestrator/doctor.ts +134 -42
  87. package/src/orchestrator/execute-args.ts +6 -0
  88. package/src/orchestrator/execute-task.ts +155 -42
  89. package/src/orchestrator/fingerprint-watch.ts +1 -1
  90. package/src/orchestrator/graph-memo.ts +140 -0
  91. package/src/orchestrator/graph-stage.ts +18 -1
  92. package/src/orchestrator/held-servers.ts +86 -0
  93. package/src/orchestrator/hit-restore.ts +227 -122
  94. package/src/orchestrator/index.ts +20 -3
  95. package/src/orchestrator/local-floor.ts +198 -0
  96. package/src/orchestrator/local-shortcircuit.ts +51 -8
  97. package/src/orchestrator/lockfile-claim.ts +96 -16
  98. package/src/orchestrator/miss-save.ts +52 -12
  99. package/src/orchestrator/options.ts +8 -6
  100. package/src/orchestrator/output-host.ts +43 -14
  101. package/src/orchestrator/output-log.ts +90 -10
  102. package/src/orchestrator/persistent-task.ts +97 -28
  103. package/src/orchestrator/persistent.ts +16 -4
  104. package/src/orchestrator/placement.ts +257 -14
  105. package/src/orchestrator/plan.ts +64 -7
  106. package/src/orchestrator/plugin-host.ts +230 -40
  107. package/src/orchestrator/plugin.ts +150 -37
  108. package/src/orchestrator/prepare.ts +178 -32
  109. package/src/orchestrator/projects.ts +65 -40
  110. package/src/orchestrator/remote-prefetch.ts +119 -25
  111. package/src/orchestrator/run-artifacts.ts +95 -32
  112. package/src/orchestrator/run-context.ts +39 -41
  113. package/src/orchestrator/run-failures.ts +65 -23
  114. package/src/orchestrator/run-id.ts +6 -3
  115. package/src/orchestrator/run-keys.ts +300 -0
  116. package/src/orchestrator/run-live.ts +294 -0
  117. package/src/orchestrator/run-lock.ts +13 -7
  118. package/src/orchestrator/run-records.ts +35 -23
  119. package/src/orchestrator/run-report.ts +3 -3
  120. package/src/orchestrator/run-steps.ts +58 -22
  121. package/src/orchestrator/run-stop.ts +22 -5
  122. package/src/orchestrator/run.ts +409 -79
  123. package/src/orchestrator/sandbox-fix.ts +7 -5
  124. package/src/orchestrator/sandbox-request.ts +78 -22
  125. package/src/orchestrator/shell-verdict.ts +5 -5
  126. package/src/orchestrator/signals.ts +26 -1
  127. package/src/orchestrator/stable-keys.ts +92 -8
  128. package/src/orchestrator/steps.ts +2 -2
  129. package/src/orchestrator/task-artifacts.ts +206 -0
  130. package/src/orchestrator/task-hash.ts +89 -14
  131. package/src/orchestrator/task-log-buffer.ts +72 -36
  132. package/src/orchestrator/task-process.ts +22 -0
  133. package/src/orchestrator/telemetry-host.ts +24 -3
  134. package/src/orchestrator/telemetry.ts +113 -38
  135. package/src/orchestrator/upstream.ts +15 -0
  136. package/src/report/events.ts +32 -13
  137. package/src/report/forecast.ts +36 -35
  138. package/src/report/framed-output.ts +173 -46
  139. package/src/report/index.ts +1 -0
  140. package/src/report/logger.ts +217 -186
  141. package/src/report/path-links.ts +12 -4
  142. package/src/report/plain-output.ts +13 -5
  143. package/src/report/status-line.ts +94 -46
  144. package/src/report/summary.ts +174 -38
  145. package/src/report/terminal-signals.ts +13 -2
  146. package/src/settings.ts +14 -6
  147. package/src/util/bun-version.ts +4 -1
  148. package/src/util/colors.ts +20 -3
  149. package/src/util/config-frame.ts +41 -4
  150. package/src/{graph → util}/dependency-spec.ts +22 -13
  151. package/src/util/edit-distance.ts +13 -1
  152. package/src/util/env-flag.ts +9 -0
  153. package/src/util/errors.ts +59 -3
  154. package/src/util/essential-env.ts +57 -0
  155. package/src/util/git-ignored.ts +1 -1
  156. package/src/util/hash.ts +35 -0
  157. package/src/util/hide-env.ts +45 -5
  158. package/src/util/index.ts +22 -5
  159. package/src/util/num.ts +8 -0
  160. package/src/util/paths.ts +121 -21
  161. package/src/util/report.ts +6 -1
  162. package/src/util/secret-mask.ts +62 -1
  163. package/src/util/secrets.ts +7 -2
  164. package/src/util/size.ts +11 -3
  165. package/src/util/tail.ts +6 -1
  166. package/src/util/timing.ts +13 -1
  167. package/src/util/ui.ts +65 -4
  168. package/src/util/which.ts +1 -1
  169. package/src/util/write-file.ts +67 -0
  170. package/src/workspace/affected.ts +710 -107
  171. package/src/workspace/bracket-literal.ts +84 -0
  172. package/src/workspace/canonical-dir.ts +37 -0
  173. package/src/workspace/config-bytecode.ts +282 -0
  174. package/src/workspace/config-eval.ts +29 -8
  175. package/src/workspace/config-imports.ts +199 -58
  176. package/src/workspace/config-schema.ts +242 -39
  177. package/src/workspace/filter.ts +37 -8
  178. package/src/workspace/index.ts +11 -2
  179. package/src/workspace/json-data.ts +3 -4
  180. package/src/workspace/lockfile.ts +57 -8
  181. package/src/workspace/migration.ts +72 -49
  182. package/src/workspace/package-graph.ts +5 -4
  183. package/src/workspace/project-loader.ts +314 -47
  184. package/src/workspace/repo-id.ts +1 -1
  185. package/src/workspace/workspace.ts +176 -33
  186. package/src/cli/foreign-flags.ts +0 -727
@@ -7,6 +7,7 @@
7
7
 
8
8
  import path from 'node:path'
9
9
  import {
10
+ PLATFORMS,
10
11
  PLUGIN_FUNCTION_HOOKS,
11
12
  PLUGIN_HOOKS,
12
13
  PLUGIN_PACKAGE,
@@ -22,9 +23,14 @@ import {
22
23
  UserError,
23
24
  BUN_GLOB_WILDCARDS,
24
25
  EXTGLOB,
26
+ braceRange,
27
+ BRACE_RANGE_LIMIT,
25
28
  anyTaskGlob,
26
29
  asTrees,
27
30
  isLiteralPattern,
31
+ hasControl,
32
+ parseDependencySpec,
33
+ DependencySpecError,
28
34
  } from '../util/index.js'
29
35
  import {
30
36
  parseSettingValue,
@@ -36,6 +42,7 @@ import {
36
42
  } from '../settings.js'
37
43
  import { compileNameGlob, parseFilter } from './filter.js'
38
44
  import { nonJsonMessage, nonJsonPaths } from './json-data.js'
45
+ import { projectNameProblem, ROOT_NAME } from './workspace.js'
39
46
 
40
47
  // Mirrors `WorkspaceConfig` in src/config.ts. Unknown keys are REJECTED for
41
48
  // the same reason the task levels reject them: `plugin: [...]` (singular)
@@ -49,27 +56,31 @@ export function isCacheScope(v: unknown): v is string {
49
56
  return typeof v === 'string' && /^[\w.@/:-]{1,128}$/.test(v)
50
57
  }
51
58
 
52
- /**
53
- * A branch's cache scope, `<owner>/<repo>:<branch>`: what CI writes for a
54
- * branch and a laptop on it reads (owner, 2026-10-17). The repository's
55
- * identity, not a remote's local name, so a clone whose `origin` is a fork
56
- * names the fork. A name may hold characters a scope may not; each
57
- * becomes `_`.
58
- */
59
59
  const BASE_BRANCH_RULE = "must be a branch name like 'main'"
60
60
 
61
61
  /**
62
62
  * A branch name git takes after `origin/`: a leading '-' would reach git
63
- * as an option, and whitespace, `..`, `~^:?*[\\` are no ref's.
63
+ * as an option, and whitespace, `..`, `~^:?*[\\` are no ref's. A ref
64
+ * spelling (`origin/main`, `refs/heads/main`) is no branch either: vx
65
+ * looked for `origin/origin/main`, found no base, and a run on main took
66
+ * a branch's cache scope.
64
67
  */
65
68
  function isBaseBranch(value: unknown): value is string {
66
69
  return (
67
70
  typeof value === 'string' &&
68
71
  /^[^-\s\0~^:?*[\\][^\s\0~^:?*[\\]*$/.test(value) &&
69
- !value.includes('..')
72
+ !value.includes('..') &&
73
+ !/^(?:origin|refs)\//.test(value)
70
74
  )
71
75
  }
72
76
 
77
+ /**
78
+ * A branch's cache scope, `<owner>/<repo>:<branch>`: what CI writes for a
79
+ * branch and a laptop on it reads (2026-10-17). The repository's
80
+ * identity, not a remote's local name, so a clone whose `origin` is a fork
81
+ * names the fork. A name may hold characters a scope may not; each
82
+ * becomes `_`.
83
+ */
73
84
  export function branchCacheScope(repo: string, branch: string): string {
74
85
  return `${repo.toLowerCase()}:${branch}`.replace(/[^\w.@/:-]/g, '_').slice(0, 128)
75
86
  }
@@ -79,16 +90,16 @@ export function cacheScopeError(source = 'VX_CACHE_SCOPE'): string {
79
90
  return `${source} ${CACHE_SCOPE_RULE}`
80
91
  }
81
92
 
82
- /** Why `VX_CACHE_SCOPE` is refused, as the run's error. */
83
- export function cacheScopeEnvError(): UserError {
84
- return new UserError(cacheScopeError())
93
+ /** Why `VX_CACHE_SCOPE` (`got`) is refused, as the run's error: usage, as every `VX_*` value is. */
94
+ export function cacheScopeEnvError(got: string): UserError {
95
+ return new UserError(`${cacheScopeError()} (got ${got})`, 'VX_E_USAGE')
85
96
  }
86
97
 
87
98
  /** `baseBranch`: `VX_BASE_BRANCH`, else the workspace's; undefined lets git name it. */
88
99
  export function configuredBaseBranch(workspace: WorkspaceConfig | null): string | undefined {
89
100
  const env = settingEnv('baseBranch')
90
101
  if (env !== undefined && !isBaseBranch(env)) {
91
- throw new UserError(`VX_BASE_BRANCH ${BASE_BRANCH_RULE}`, 'VX_E_USAGE')
102
+ throw new UserError(`VX_BASE_BRANCH ${BASE_BRANCH_RULE} (got ${env as string})`, 'VX_E_USAGE')
92
103
  }
93
104
  return (env as string | undefined) ?? workspace?.baseBranch
94
105
  }
@@ -178,6 +189,14 @@ function validateDependencyRules(value: unknown, where: string): void {
178
189
  const shape = `an array of { from, allow?, deny? }, each a selector like 'tag:app' or '@acme/*'`
179
190
  if (!Array.isArray(value)) throw new UserError(`${where} must be ${shape}`)
180
191
  const selector = (v: unknown) => typeof v === 'string' && v.trim() !== '' && v !== 'tag:'
192
+ const matchable = (v: string, field: string) => {
193
+ const why = ruleSelectorProblem(v)
194
+ if (why !== null)
195
+ throw new UserError(
196
+ `${field} ${JSON.stringify(v)} ${why}; a selector is 'tag:<tag>' or a project name, ` +
197
+ `either may hold '*' (like 'tag:app' or '@acme/*')`,
198
+ )
199
+ }
181
200
  for (const [i, rule] of (value as unknown[]).entries()) {
182
201
  const at = `${where}[${i}]`
183
202
  if (typeof rule !== 'object' || rule === null || Array.isArray(rule))
@@ -185,12 +204,14 @@ function validateDependencyRules(value: unknown, where: string): void {
185
204
  assertKnownFields(rule, RULE_FIELDS, at)
186
205
  const { from, allow, deny } = rule as Record<string, unknown>
187
206
  if (!selector(from)) throw new UserError(`${at}.from must be a selector like 'tag:app'`)
207
+ matchable(from as string, `${at}.from`)
188
208
  for (const [k, list] of [
189
209
  ['allow', allow],
190
210
  ['deny', deny],
191
211
  ] as const) {
192
212
  if (list !== undefined && (!Array.isArray(list) || !list.every(selector)))
193
213
  throw new UserError(`${at}.${k} must be an array of selectors like 'tag:lib'`)
214
+ for (const [j, v] of ((list ?? []) as string[]).entries()) matchable(v, `${at}.${k}[${j}]`)
194
215
  }
195
216
  // A rule that says nothing reads as a constraint and checks nothing.
196
217
  if (allow === undefined && deny === undefined)
@@ -198,6 +219,33 @@ function validateDependencyRules(value: unknown, where: string): void {
198
219
  }
199
220
  }
200
221
 
222
+ /**
223
+ * Why a rule selector can match no project, or null. The checker reads a
224
+ * selector as a tag or name glob only, so a `--filter` form loaded and
225
+ * matched nothing, and a `deny` of it passed every edge. Asked of the
226
+ * filter parser itself, as `tagProblem`.
227
+ */
228
+ function ruleSelectorProblem(s: string): string | null {
229
+ const glob = s.startsWith('tag:') ? s.slice(4) : s
230
+ if (s.trim() !== s || glob.trim() !== glob) return 'has surrounding whitespace'
231
+ // The root's own name, which the filter reads as the root's directory.
232
+ if (s === ROOT_NAME) return null
233
+ if (s.startsWith('!')) return "starts with '!', which a filter reads as an exclusion"
234
+ const walk = "starts or ends with '...', which a filter reads as a dependency walk"
235
+ let f: ReturnType<typeof parseFilter>
236
+ try {
237
+ f = parseFilter(s, '/')
238
+ } catch {
239
+ // `...` alone: the walk with no project between.
240
+ return walk
241
+ }
242
+ if (f.withDeps || f.withDependents || f.onlyDeps || f.onlyDependents) return walk
243
+ if (f.gitSince !== undefined)
244
+ return `ends in "[${f.gitSince}]", which a filter reads as a git range`
245
+ if (f.isPath) return 'reads as a directory to a filter'
246
+ return null
247
+ }
248
+
201
249
  /** `value` as the setting at `path` takes it, refused in `where`'s words. */
202
250
  function validateSettingValue(value: unknown, path: string, where: string): void {
203
251
  const { kind } = setting(path)
@@ -206,7 +254,8 @@ function validateSettingValue(value: unknown, path: string, where: string): void
206
254
  const untyped =
207
255
  (kind.type === 'concurrency' && typeof value === 'string' && !value.endsWith('%')) ||
208
256
  (kind.type === 'int' && typeof value !== 'number') ||
209
- (kind.type === 'bool' && typeof value !== 'boolean')
257
+ (kind.type === 'bool' && typeof value !== 'boolean') ||
258
+ (kind.type === 'size' && value === 'false')
210
259
  if (untyped || parseSettingValue(kind, value) === null) {
211
260
  throw new UserError(settingRefusal(path, where, value, shown(value)))
212
261
  }
@@ -231,11 +280,13 @@ function validateSettingsExtra(config: WorkspaceConfig, configPath: string): voi
231
280
  /**
232
281
  * A refused value as a message quotes it. A workspace file is evaluated
233
282
  * in-process and no JSON rule precedes its schema, so a value here may be
234
- * one `JSON.stringify` throws on (`30n`) or drops (a symbol): the refusal
235
- * became a TypeError with a stack.
283
+ * one `JSON.stringify` throws on (`30n`), drops (a symbol) or misnames
284
+ * (`Infinity` as null): the refusal became a TypeError with a stack.
236
285
  */
237
286
  function shown(v: unknown): string {
238
- return typeof v === 'bigint' ? `${v}n` : (JSON.stringify(v) ?? String(v))
287
+ if (typeof v === 'bigint') return `${v}n`
288
+ if (typeof v === 'number' && !Number.isFinite(v)) return String(v)
289
+ return JSON.stringify(v) ?? String(v)
239
290
  }
240
291
 
241
292
  /** A `plugins` entry that stands for no plugin: `undefined`, `null` or `false`. */
@@ -258,18 +309,24 @@ export function validateWorkspace(config: WorkspaceConfig, configPath: string):
258
309
  }
259
310
  const verbOwners = new Map<string, string>()
260
311
  const fileClaimants = new Map<string, { name: string; at: number }>()
312
+ const seenAt = new Map<object, number>()
261
313
  for (const [i, p] of (config.plugins as readonly unknown[]).entries()) {
262
314
  // A factory that declines returns nothing, and `cond && plugin()` is
263
315
  // false: both are skipped, as Vite skips them.
264
316
  if (isSkippedPlugin(p)) continue
265
317
  if (p === null || typeof p !== 'object') {
266
318
  // Nx's nx.json lists plugins by module name (`'@nx/vite/plugin'`);
267
- // a vx plugin is the value its package's function returns.
319
+ // a vx plugin is the value its package's function returns, and
320
+ // `plugins: [otel]` is that function, not called.
268
321
  throw new UserError(
269
322
  `${configPath}: \`plugins[${i}]\` must be an object` +
270
323
  (typeof p === 'string'
271
324
  ? ` — a plugin is what its package's function returns (\`nx()\` from \`@vzn/vx-migrate\`), not a module name`
272
- : ''),
325
+ : typeof p === 'function'
326
+ ? p.name === ''
327
+ ? ' — it is a function; call the factory that returns the plugin'
328
+ : ` — it is the function '${p.name}'; call it: ${p.name}()`
329
+ : ''),
273
330
  )
274
331
  }
275
332
  // The loose schema's shape (unknown-typed hooks) plus the one retired
@@ -297,6 +354,15 @@ export function validateWorkspace(config: WorkspaceConfig, configPath: string):
297
354
  `${configPath}: \`plugins[${i}].name\` overrides the package name ('${String(plug.name)}' over '${pkg}') — a plugin's name is its package name; drop the field`,
298
355
  )
299
356
  }
357
+ // One instance twice (two presets sharing it) ran its setup, its
358
+ // handlers and its telemetry sinks twice. Two instances stay two.
359
+ const first = seenAt.get(p)
360
+ if (first !== undefined) {
361
+ throw new UserError(
362
+ `${configPath}: \`plugins[${i}]\` is \`plugins[${first}]\` again (plugin '${pkg}') — declare a plugin once`,
363
+ )
364
+ }
365
+ seenAt.set(p, i)
300
366
  // A misspelled hook (`excutor`, `setUp`) was never called and never
301
367
  // said: tasks ran here with the plugin's executor unheard (X-19).
302
368
  for (const key of Object.keys(plug)) {
@@ -462,6 +528,18 @@ export function validateProjectConfig(
462
528
  `${configPath}: \`name\` must be a non-empty string with no surrounding whitespace and no "#" (a task is <name>#<task>)`,
463
529
  )
464
530
  }
531
+ // Printed raw by the status line and summary: `ESC ]0;` retitled the terminal.
532
+ if (hasControl(name)) {
533
+ throw new UserError(
534
+ `${configPath}: \`name\` ${JSON.stringify(name)} holds a control character — rename the project`,
535
+ )
536
+ }
537
+ const why = projectNameProblem(name)
538
+ if (why !== null) {
539
+ throw new UserError(
540
+ `${configPath}: \`name\` ${JSON.stringify(name)} ${why} — dependsOn, --filter and the CLI could not name the project. Rename it.`,
541
+ )
542
+ }
465
543
  }
466
544
  const tags: unknown = config.tags
467
545
  if (
@@ -478,6 +556,12 @@ export function validateProjectConfig(
478
556
  `name it. Rename the tag.`,
479
557
  )
480
558
  }
559
+ // Printed raw by `vx show`, as a name is.
560
+ if (hasControl(tag)) {
561
+ throw new UserError(
562
+ `${configPath}: tag ${JSON.stringify(tag)} holds a control character — rename the tag`,
563
+ )
564
+ }
481
565
  }
482
566
  const tasks = config.tasks
483
567
  if (tasks === undefined) return
@@ -528,9 +612,7 @@ export function validateProjectConfig(
528
612
  }
529
613
  const retries = (exec as { retries?: unknown }).retries
530
614
  if (retries !== undefined) {
531
- if (typeof retries !== 'number' || !Number.isInteger(retries) || retries < 0) {
532
- throw new UserError(`${where}.exec.retries must be a non-negative integer`)
533
- }
615
+ validateSettingValue(retries, 'run.retries', `${where}.exec.retries`)
534
616
  }
535
617
  const remote = (exec as { remote?: unknown }).remote
536
618
  if (remote !== undefined && typeof remote !== 'boolean' && remote !== 'only') {
@@ -545,6 +627,20 @@ export function validateProjectConfig(
545
627
  `and its key is the address of its remote record`,
546
628
  )
547
629
  }
630
+ const platforms = (exec as { platforms?: unknown }).platforms
631
+ if (platforms !== undefined) {
632
+ const known = PLATFORMS as readonly unknown[]
633
+ if (
634
+ !Array.isArray(platforms) ||
635
+ platforms.length === 0 ||
636
+ !platforms.every((p) => known.includes(p)) ||
637
+ new Set(platforms).size !== platforms.length
638
+ ) {
639
+ throw new UserError(
640
+ `${where}.exec.platforms must be a non-empty array of distinct platforms, each one of ${PLATFORMS.join(', ')}`,
641
+ )
642
+ }
643
+ }
548
644
  const env = (exec as { env?: unknown }).env
549
645
  if (env !== undefined) {
550
646
  if (typeof env !== 'object' || env === null) {
@@ -799,9 +895,9 @@ export function validateProjectConfig(
799
895
  // The only InputsConfig field that reached the run unvalidated: a
800
896
  // non-string entry crashes deep in `filterUpstreamHashes` /
801
897
  // `parseDependencySpec` with a raw TypeError naming neither the task
802
- // nor the config. Entry SYNTAX is already checked downstream (a
803
- // `DependencySpecError` becomes a UserError naming the task) — this
804
- // only pins the shape.
898
+ // nor the config. Each entry's syntax too: a malformed one (`^`,
899
+ // `a#`) passed `vx lock` and failed the task at hash time, after its
900
+ // upstream ran.
805
901
  const taskFilters = (inputs as { tasks?: unknown }).tasks
806
902
  if (taskFilters !== undefined) {
807
903
  if (
@@ -813,6 +909,14 @@ export function validateProjectConfig(
813
909
  `(Turbo/Nx micro-syntax: 'name', '^name', 'pkg#name', '*', '^*', '!name')`,
814
910
  )
815
911
  }
912
+ for (const raw of taskFilters as string[]) {
913
+ try {
914
+ parseDependencySpec(raw, 'inputs.tasks')
915
+ } catch (err) {
916
+ if (err instanceof DependencySpecError) throw new UserError(`${where}: ${err.message}`)
917
+ throw err
918
+ }
919
+ }
816
920
  assertFilterNamesDeclaredDeps(taskFilters, task.dependsOn, where)
817
921
  }
818
922
  for (const field of ['runtime', 'workspaceRuntime'] as const) {
@@ -893,8 +997,8 @@ export function validateProjectConfig(
893
997
  if (hasParentSegment(g)) {
894
998
  throw new UserError(
895
999
  `${where}.cache.outputs.files: '..' path segments are not allowed (got "${g}") — ` +
896
- `outputs must stay within the project (a glob that escapes the project dir would ` +
897
- `let cleanOutputs delete files outside it)`,
1000
+ `outputs must stay within the project (vx clears a task's outputs before it runs, ` +
1001
+ `so a glob that escapes the project would delete files outside it)`,
898
1002
  )
899
1003
  }
900
1004
  assertPosixGlob(g, `${where}.cache.outputs.files`, 'project')
@@ -931,6 +1035,7 @@ export function validateProjectConfig(
931
1035
  }
932
1036
  }
933
1037
  assertOutputsNotNegationOnly(outFiles as string[], `${where}.cache.outputs.files`)
1038
+ assertNoLiteralTakenBack(outFiles as string[], `${where}.cache.outputs.files`, OUTPUT_LOST)
934
1039
  const wsOutputs = (outputs as { workspaceFiles?: unknown }).workspaceFiles
935
1040
  if (wsOutputs !== undefined) {
936
1041
  validateWorkspaceGlobs(wsOutputs, `${where}.cache.outputs.workspaceFiles`, false)
@@ -958,6 +1063,7 @@ const EXEC_FIELDS = new Set([
958
1063
  'interactive',
959
1064
  'remote',
960
1065
  'sandbox',
1066
+ 'platforms',
961
1067
  ])
962
1068
  const PERSISTENT_FIELDS = new Set(['readyWhen'])
963
1069
  const ENV_FIELDS = new Set(['passThrough', 'define', 'secret'])
@@ -1028,7 +1134,7 @@ const FOREIGN_FIELDS: ReadonlyMap<ReadonlySet<string>, Readonly<Record<string, s
1028
1134
  files: "the task's `inputs.files`",
1029
1135
  output: '`outputs.files`',
1030
1136
  env: "the task's `inputs.env`",
1031
- dependencies: "the task's `dependsOn` (an upstream's key folds into this one)",
1137
+ dependencies: "the task's `dependsOn` (an upstream's key goes into this one)",
1032
1138
  enabled: 'no field: a task with a `cache` block caches, one without runs every time',
1033
1139
  },
1034
1140
  ],
@@ -1134,6 +1240,23 @@ function nearestSetting(key: string): string | undefined {
1134
1240
  return name === undefined ? undefined : `\`${byName.get(name)}\``
1135
1241
  }
1136
1242
 
1243
+ /**
1244
+ * Where a field one level down lives: `retries`, `timeout` or `sandbox` on
1245
+ * a task, beside `exec` rather than in it, was refused with the task's
1246
+ * fields alone, none of which is it.
1247
+ */
1248
+ function nestedHome(allowed: ReadonlySet<string>, key: string): string | undefined {
1249
+ if (allowed !== TASK_FIELDS) return undefined
1250
+ for (const [field, own] of [
1251
+ ['exec', EXEC_FIELDS],
1252
+ ['inputs', INPUT_FIELDS],
1253
+ ['cache', CACHE_FIELDS],
1254
+ ] as const) {
1255
+ if (own.has(key)) return `\`${field}.${key}\``
1256
+ }
1257
+ return undefined
1258
+ }
1259
+
1137
1260
  function assertKnownFields(value: object, allowed: ReadonlySet<string>, where: string): void {
1138
1261
  // `typeof [] === 'object'`, so an array reaches here and its indices read
1139
1262
  // as fields: `outputs: ['dist/**']` (Turbo's spelling) was refused as
@@ -1148,7 +1271,7 @@ function assertKnownFields(value: object, allowed: ReadonlySet<string>, where: s
1148
1271
  if (!allowed.has(key)) {
1149
1272
  // The nearest accepted spelling first: the list says what the level
1150
1273
  // takes, the hint says which one was meant.
1151
- const foreign = FOREIGN_FIELDS.get(allowed)?.[key]
1274
+ const foreign = FOREIGN_FIELDS.get(allowed)?.[key] ?? nestedHome(allowed, key)
1152
1275
  const near =
1153
1276
  nearest(key, allowed) ?? (allowed === WORKSPACE_FIELDS ? nearestSetting(key) : undefined)
1154
1277
  throw new UserError(
@@ -1246,6 +1369,63 @@ function assertNoForeignToken(glob: string, where: string): void {
1246
1369
  `files it names. Write ${rewrite} instead.`,
1247
1370
  )
1248
1371
  }
1372
+ assertNoBareGroup(glob, body, where)
1373
+ assertNoBraceRange(glob, where)
1374
+ assertNoGluedGlobstar(glob, where)
1375
+ }
1376
+
1377
+ /**
1378
+ * `Bun.Glob` reads `**` glued to other text (`src/**.ts`, `a**`) as `*`, one
1379
+ * level: deeper inputs left the key and deeper outputs were never saved.
1380
+ * Turbo rewrites it to the deep reading (vercel/turborepo#5431); vx refuses
1381
+ * and names both.
1382
+ */
1383
+ function assertNoGluedGlobstar(glob: string, where: string): void {
1384
+ const body = glob.startsWith('!') ? glob.slice(1) : glob
1385
+ if (!body.split(/[/{},]/).some((seg) => seg.includes('**') && /[^*]/.test(seg))) return
1386
+ const deep = glob.replace(/\*\*([^*/{},]+)/g, '**/*$1').replace(/([^*/{},]+)\*\*/g, '$1*/**')
1387
+ const one = glob.replace(/\*{2,}/g, (run, at: number) =>
1388
+ /[^*/{},]/.test(glob[at - 1] ?? '') || /[^*/{},]/.test(glob[at + run.length] ?? '') ? '*' : run,
1389
+ )
1390
+ throw new UserError(
1391
+ `${where}: "${glob}" glues ** to other text in one path segment, which vx's glob engine ` +
1392
+ `reads as one *, a single directory level. Write "${deep}" for every depth, or "${one}" ` +
1393
+ `for one level.`,
1394
+ )
1395
+ }
1396
+
1397
+ /** `(a|b)` with no extglob sigil: `Bun.Glob` reads parens and `|` as literal text. */
1398
+ const BARE_GROUP = /(?<!\\)\(([^()|]*(?:\|[^()|]*)+)\)/
1399
+
1400
+ function assertNoBareGroup(glob: string, body: string, where: string): void {
1401
+ const group = BARE_GROUP.exec(body)
1402
+ if (group === null) return
1403
+ const names = group[1]!.split('|')
1404
+ const rewrite = BUN_GLOB_WILDCARDS.test(group[1]!)
1405
+ ? 'braces ("{a,b}") or one entry per alternative'
1406
+ : `"${glob.replace(group[0], `{${names.join(',')}}`)}"`
1407
+ throw new UserError(
1408
+ `${where}: "${glob}" holds a bare (…|…) group, which vx's glob engine reads as literal ` +
1409
+ `text, never as the alternatives it lists, so the cache key would miss the files it ` +
1410
+ `names. Write ${rewrite} instead.`,
1411
+ )
1412
+ }
1413
+
1414
+ const BRACE_RANGE = /(?<!\\)\{(-?\d+|[a-zA-Z])\.\.(-?\d+|[a-zA-Z])(\.\.-?\d+)?\}/g
1415
+
1416
+ /**
1417
+ * A range vx expands is any task glob's (`braceRange`). One it does not —
1418
+ * mixed case, or past the limit — `Bun.Glob` would still read, so the
1419
+ * readings would differ by engine: refused.
1420
+ */
1421
+ function assertNoBraceRange(glob: string, where: string): void {
1422
+ // Every range, not the first: `a{1..2}{A..c}` held one vx reads past.
1423
+ const m = [...glob.matchAll(BRACE_RANGE)].find((r) => braceRange(r[0].slice(1, -1)) === null)
1424
+ if (m === undefined) return
1425
+ throw new UserError(
1426
+ `${where}: "${glob}" holds the brace range ${m[0]}, which vx expands only within one case ` +
1427
+ `and up to ${BRACE_RANGE_LIMIT} names. Write a smaller range or a comma brace.`,
1428
+ )
1249
1429
  }
1250
1430
 
1251
1431
  /**
@@ -1331,7 +1511,7 @@ function assertPosixGlob(glob: string, where: string, base: string): void {
1331
1511
  )
1332
1512
  }
1333
1513
 
1334
- const ONE_ALTERNATIVE_BRACE = /(?<!\\)\{[^{},]*\}/
1514
+ const ONE_ALTERNATIVE_BRACE = /(?<!\\)\{[^{},]*\}/g
1335
1515
 
1336
1516
  /**
1337
1517
  * What every run would refuse, or silently key nothing for, refused at
@@ -1341,8 +1521,11 @@ const ONE_ALTERNATIVE_BRACE = /(?<!\\)\{[^{},]*\}/
1341
1521
  * named. A path under `.git` is never listed by git, so it keyed nothing.
1342
1522
  */
1343
1523
  function assertInputGlob(glob: string, where: string): void {
1344
- const m = ONE_ALTERNATIVE_BRACE.exec(glob)
1345
- if (m !== null) {
1524
+ // A range is a brace vx expands (`braceRange`), not one alternative.
1525
+ const m = [...glob.matchAll(ONE_ALTERNATIVE_BRACE)].find(
1526
+ (b) => braceRange(b[0].slice(1, -1)) === null,
1527
+ )
1528
+ if (m !== undefined) {
1346
1529
  const inner = m[0].slice(1, -1)
1347
1530
  throw new UserError(
1348
1531
  `${where}: "${glob}" holds a brace with one alternative, which matches ` +
@@ -1427,7 +1610,7 @@ function assertFilterNamesDeclaredDeps(
1427
1610
  // the wildcard skip below takes (item 678).
1428
1611
  if (raw.startsWith('!')) continue
1429
1612
  const f = specForm(raw)
1430
- if (f.task.length === 0 || f.task.includes('*') || named(f)) continue
1613
+ if (f.task.includes('*') || named(f)) continue
1431
1614
  // `where` opens with the config's path; the message names it once.
1432
1615
  const task = where.slice(where.lastIndexOf(': tasks.') + 2)
1433
1616
  throw new UserError(
@@ -1501,7 +1684,9 @@ export function taskNameProblem(name: string): string | null {
1501
1684
  ? "holds '*', which makes it a pattern"
1502
1685
  : name.startsWith('^') || name.startsWith('!')
1503
1686
  ? `starts with '${name[0]}', which names dependencies' tasks or negates`
1504
- : null
1687
+ : hasControl(name)
1688
+ ? 'holds a control character'
1689
+ : null
1505
1690
  }
1506
1691
 
1507
1692
  /**
@@ -1512,11 +1697,19 @@ export function taskNameProblem(name: string): string | null {
1512
1697
  function tagProblem(tag: string): string | null {
1513
1698
  if (tag.trim() !== tag) return 'has surrounding whitespace'
1514
1699
  if (tag.includes('*')) return "holds '*', which makes it a pattern"
1515
- const f = parseFilter(`tag:${tag}`, '/')
1700
+ const walk = "ends in '...', which a filter reads as a dependency walk"
1701
+ let f: ReturnType<typeof parseFilter>
1702
+ try {
1703
+ f = parseFilter(`tag:${tag}`, '/')
1704
+ } catch {
1705
+ // `...` alone: the walk leaves the filter no tag, and its refusal
1706
+ // named neither the config nor the tag.
1707
+ return walk
1708
+ }
1516
1709
  if (f.gitSince !== undefined) {
1517
1710
  return `ends in "[${f.gitSince}]", which a filter reads as a git range`
1518
1711
  }
1519
- if (f.withDeps || f.onlyDeps) return "ends in '...', which a filter reads as a dependency walk"
1712
+ if (f.withDeps || f.onlyDeps) return walk
1520
1713
  return null
1521
1714
  }
1522
1715
 
@@ -1548,7 +1741,12 @@ function assertNotNegationOnly(globs: readonly string[], where: string): void {
1548
1741
  * entered the key, and an edit to it replayed the old output. Refusing is
1549
1742
  * free: the literal already selected nothing.
1550
1743
  */
1551
- function assertNoLiteralTakenBack(globs: readonly string[], where: string): void {
1744
+ function assertNoLiteralTakenBack(
1745
+ globs: readonly string[],
1746
+ where: string,
1747
+ // An output's literal is never saved: a hit left it missing and said up-to-date.
1748
+ lost = 'the file never enters the key',
1749
+ ): void {
1552
1750
  const negative = globs.filter((g) => g.startsWith('!')).map((g) => g.slice(1))
1553
1751
  if (negative.length === 0) return
1554
1752
  const taken = anyTaskGlob(asTrees(negative))
@@ -1559,7 +1757,7 @@ function assertNoLiteralTakenBack(globs: readonly string[], where: string): void
1559
1757
  const by = negative.find((n) => anyTaskGlob(asTrees([n]))(lit))
1560
1758
  throw new UserError(
1561
1759
  `${where}: "${g}" is taken back by "!${by}" — a \`!\` entry subtracts wherever it sits in ` +
1562
- `the list, so the file never enters the key. Narrow the negation so it leaves "${g}".`,
1760
+ `the list, so ${lost}. Narrow the negation so it leaves "${g}".`,
1563
1761
  )
1564
1762
  }
1565
1763
  }
@@ -1601,13 +1799,18 @@ function validateWorkspaceGlobs(v: unknown, where: string, negation: boolean): v
1601
1799
  if (negation) {
1602
1800
  assertNotNegationOnly(v as string[], where)
1603
1801
  assertNoLiteralTakenBack(v as string[], where)
1604
- } else assertOutputsNotNegationOnly(v as string[], where)
1802
+ } else {
1803
+ assertOutputsNotNegationOnly(v as string[], where)
1804
+ assertNoLiteralTakenBack(v as string[], where, OUTPUT_LOST)
1805
+ }
1605
1806
  }
1606
1807
 
1607
1808
  /**
1608
1809
  * An output list of only `!` entries selects nothing: the task saves and
1609
1810
  * restores nothing while it reads as if it declared outputs (A-44).
1610
1811
  */
1812
+ const OUTPUT_LOST = 'the file is never saved or restored'
1813
+
1611
1814
  function assertOutputsNotNegationOnly(globs: readonly string[], where: string): void {
1612
1815
  if (globs.length === 0 || globs.some((g) => !g.startsWith('!'))) return
1613
1816
  throw new UserError(
@@ -24,6 +24,7 @@
24
24
  import path from 'node:path'
25
25
  import { BUN_GLOB_WILDCARDS, relPosix, UserError } from '../util/index.js'
26
26
  import type { PackageGraph } from './package-graph.js'
27
+ import { canonicalDir } from './canonical-dir.js'
27
28
  import type { ProjectMeta } from './workspace.js'
28
29
 
29
30
  export interface ParsedFilter {
@@ -54,7 +55,7 @@ export interface ParsedFilter {
54
55
  pathGlob?: Bun.Glob
55
56
  /** `./a/**`'s `a`: a trailing `**` matches zero dirs, and `Bun.Glob` does not (D-84). */
56
57
  pathGlobBase?: Bun.Glob
57
- /** The workspace root `pathGlob` is relative to. */
58
+ /** The workspace root a path form is relative to. */
58
59
  pathRoot?: string
59
60
  /** `//`: the project at `matcher` itself, never the ones under it. */
60
61
  exactDir?: true
@@ -67,7 +68,12 @@ export interface ParsedFilter {
67
68
  dirFallback?: ParsedFilter
68
69
  }
69
70
 
70
- export function parseFilter(raw: string, workspaceRoot: string): ParsedFilter {
71
+ export function parseFilter(
72
+ raw: string,
73
+ workspaceRoot: string,
74
+ /** The filter as typed, when `raw` is the `./` reading of a name (`dirFallback`). */
75
+ typed = raw,
76
+ ): ParsedFilter {
71
77
  let s = raw
72
78
  const negate = s.startsWith('!')
73
79
  if (negate) s = s.slice(1)
@@ -180,8 +186,18 @@ export function parseFilter(raw: string, workspaceRoot: string): ParsedFilter {
180
186
  ? s.slice(1, -1)
181
187
  : undefined
182
188
  if (pathForm !== undefined) {
189
+ // `{}` resolved to the root, every project under it: an unset
190
+ // variable in `--filter "{$PKG}"` ran the whole workspace.
191
+ if (pathForm.trim() === '') throw new UserError(`filter "${raw}" names no project`)
183
192
  isPath = true
184
193
  matcher = path.resolve(workspaceRoot, pathForm)
194
+ // `../` (pnpm's cwd-relative sibling) climbed out of the root, and
195
+ // every project sat under the directory it named: all of them ran.
196
+ const up = path.relative(workspaceRoot, matcher)
197
+ if (up === '..' || up.startsWith(`..${path.sep}`))
198
+ throw new UserError(
199
+ `filter "${typed}" names a directory outside the workspace root: a path filter is relative to the root`,
200
+ )
185
201
  // `./packages/*` — pnpm's and Turbo's spelling for "every package under
186
202
  // packages": a glob over the root-relative project dir. Resolved
187
203
  // against the workspace root like the literal form.
@@ -200,7 +216,7 @@ export function parseFilter(raw: string, workspaceRoot: string): ParsedFilter {
200
216
  // 23.2.1), and `-p 'apps/*'` matched nothing here.
201
217
  const dirFallback =
202
218
  !isPath && s.includes('/') && !s.startsWith('@')
203
- ? parseFilter(`./${s}`, workspaceRoot)
219
+ ? parseFilter(`./${s}`, workspaceRoot, raw)
204
220
  : undefined
205
221
 
206
222
  return {
@@ -213,7 +229,8 @@ export function parseFilter(raw: string, workspaceRoot: string): ParsedFilter {
213
229
  isPath,
214
230
  matcher,
215
231
  ...(dirFallback !== undefined ? { dirFallback } : {}),
216
- ...(pathGlob !== undefined ? { pathGlob, pathRoot: workspaceRoot } : {}),
232
+ ...(isPath ? { pathRoot: workspaceRoot } : {}),
233
+ ...(pathGlob !== undefined ? { pathGlob } : {}),
217
234
  ...(pathGlobBase !== undefined ? { pathGlobBase } : {}),
218
235
  ...(gitSince !== undefined ? { gitSince } : {}),
219
236
  ...(sinceViaDeps ? { sinceViaDeps: true as const } : {}),
@@ -261,7 +278,13 @@ function matchSelector(
261
278
  // pnpm read it: `.` is the root project, never every project under the
262
279
  // root, and `./packages/app` leaves the examples nested in it (D-43).
263
280
  // A directory that is no project keeps the "at or under" reading.
264
- const exact = projects.find((p) => p.dir === filter.matcher)
281
+ // A link to a project's directory (`./widgets/a -> ../submodules/a`)
282
+ // names that project, which discovery holds at its real directory.
283
+ let exact = projects.find((p) => p.dir === filter.matcher)
284
+ if (exact === undefined && filter.pathRoot !== undefined) {
285
+ const real = canonicalDir(filter.pathRoot, filter.matcher)
286
+ if (real !== null && real !== filter.matcher) exact = projects.find((p) => p.dir === real)
287
+ }
265
288
  if (exact !== undefined) return [exact.name]
266
289
  if (filter.exactDir === true) return out
267
290
  const prefix = filter.matcher + path.sep
@@ -339,9 +362,10 @@ export interface ApplyFiltersOptions {
339
362
  /**
340
363
  * Called once per filter that matched zero projects, before expansion.
341
364
  * Only the TOTAL empty selection is an error, so without this a typo among
342
- * several filters silently under-selects.
365
+ * several filters silently under-selects. `emptyDiff`: a `[<since>]`
366
+ * filter whose name or dir part names projects, none of them changed.
343
367
  */
344
- onNoMatch?: (filter: ParsedFilter) => void
368
+ onNoMatch?: (filter: ParsedFilter, emptyDiff: boolean) => void
345
369
  /**
346
370
  * Called once per filter whose pattern matched projects but whose walk
347
371
  * selected none (`...^c` for a `c` nothing depends on), with what it
@@ -362,7 +386,12 @@ export function applyFilters(opts: ApplyFiltersOptions): Set<string> {
362
386
  const excludes: Set<string>[] = []
363
387
  for (const f of opts.filters) {
364
388
  const matched = matchProjects(f, opts.projects, opts.affectedByFilter, opts.graph, opts.tags)
365
- if (matched.length === 0) opts.onNoMatch?.(f)
389
+ if (matched.length === 0)
390
+ opts.onNoMatch?.(
391
+ f,
392
+ f.gitSince !== undefined &&
393
+ (f.matcher === '' || matchSelector(f, opts.projects, opts.tags).length > 0),
394
+ )
366
395
  const expanded = new Set<string>()
367
396
  for (const name of matched) {
368
397
  if (!f.onlyDeps && !f.onlyDependents) expanded.add(name)