@vzn/vx 0.0.636 → 0.0.638

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 (151) hide show
  1. package/README.md +39 -11
  2. package/docs/caching.md +246 -213
  3. package/docs/cli.md +791 -705
  4. package/docs/execution.md +68 -76
  5. package/docs/features.md +47 -42
  6. package/docs/patterns.md +26 -25
  7. package/docs/schema.md +392 -205
  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 +4 -4
  16. package/schemas/show.json +5 -3
  17. package/schemas/why.json +15 -1
  18. package/skills/vx/SKILL.md +28 -13
  19. package/src/bin.ts +30 -9
  20. package/src/cache/archive.ts +11 -0
  21. package/src/cache/cache.ts +124 -53
  22. package/src/cache/chained-cache.ts +4 -0
  23. package/src/cache/git-inputs.ts +277 -63
  24. package/src/cache/index.ts +15 -1
  25. package/src/cache/inputs.ts +102 -36
  26. package/src/cache/key-fold.ts +8 -7
  27. package/src/cache/layer.ts +15 -18
  28. package/src/cache/layered-cache.ts +20 -102
  29. package/src/cache/plugin-cache.ts +248 -0
  30. package/src/cache/policy.ts +32 -27
  31. package/src/cache/run-history.ts +104 -14
  32. package/src/cache/run-store.ts +77 -0
  33. package/src/cache/schema.ts +58 -31
  34. package/src/cache/scoped-cache.ts +237 -0
  35. package/src/cli/cache.ts +173 -34
  36. package/src/cli/completions.ts +11 -7
  37. package/src/cli/core-alias.ts +1 -1
  38. package/src/cli/foreign-flags.ts +13 -12
  39. package/src/cli/help.ts +110 -70
  40. package/src/cli/index.ts +67 -24
  41. package/src/cli/info.ts +43 -27
  42. package/src/cli/init.ts +115 -407
  43. package/src/cli/last.ts +157 -9
  44. package/src/cli/lock.ts +356 -122
  45. package/src/cli/plan-format.ts +7 -5
  46. package/src/cli/plugin-commands.ts +81 -14
  47. package/src/cli/plugin-templates.ts +11 -11
  48. package/src/cli/run.ts +116 -160
  49. package/src/cli/select.ts +50 -47
  50. package/src/cli/show.ts +83 -59
  51. package/src/cli/task-verb.ts +6 -13
  52. package/src/cli/upgrade.ts +415 -149
  53. package/src/cli/watch-cycle.ts +9 -2
  54. package/src/cli/watch-filter.ts +39 -61
  55. package/src/cli/watch-fs.ts +29 -16
  56. package/src/cli/watch-judge.ts +18 -4
  57. package/src/cli/watch-set.ts +16 -42
  58. package/src/cli/watch.ts +227 -230
  59. package/src/cli/why.ts +38 -18
  60. package/src/cli/workspace-config.ts +33 -70
  61. package/src/config.ts +169 -70
  62. package/src/exec/env.ts +1 -31
  63. package/src/exec/executor.ts +1 -5
  64. package/src/exec/kill-tree.ts +2 -1
  65. package/src/exec/sandbox-runtime.ts +12 -1
  66. package/src/exec/sandbox-violations.ts +9 -1
  67. package/src/graph/dependency-spec.ts +1 -1
  68. package/src/graph/index.ts +1 -0
  69. package/src/graph/priorities.ts +1 -1
  70. package/src/graph/scheduler.ts +27 -7
  71. package/src/graph/task-graph.ts +76 -24
  72. package/src/handoff.ts +169 -0
  73. package/src/index.ts +35 -58
  74. package/src/orchestrator/affected-tasks.ts +15 -8
  75. package/src/orchestrator/command-windows.ts +44 -0
  76. package/src/orchestrator/deferred-outputs.ts +12 -10
  77. package/src/orchestrator/doctor.ts +98 -110
  78. package/src/orchestrator/download-policy.ts +3 -3
  79. package/src/orchestrator/events.ts +19 -3
  80. package/src/orchestrator/excluded-keys.ts +0 -2
  81. package/src/orchestrator/execute-task.ts +147 -69
  82. package/src/orchestrator/failure-mode.ts +19 -10
  83. package/src/orchestrator/fingerprint-watch.ts +18 -15
  84. package/src/orchestrator/forecast.ts +10 -2
  85. package/src/orchestrator/framed-output.ts +9 -5
  86. package/src/orchestrator/graph-stage.ts +5 -5
  87. package/src/orchestrator/history.ts +126 -30
  88. package/src/orchestrator/hit-restore.ts +15 -3
  89. package/src/orchestrator/index.ts +20 -5
  90. package/src/orchestrator/local-shortcircuit.ts +2 -1
  91. package/src/orchestrator/lockfile-claim.ts +79 -20
  92. package/src/orchestrator/logger.ts +80 -41
  93. package/src/orchestrator/metrics.ts +78 -9
  94. package/src/orchestrator/miss-save.ts +3 -0
  95. package/src/orchestrator/options.ts +42 -29
  96. package/src/orchestrator/output-host.ts +72 -0
  97. package/src/orchestrator/plan.ts +6 -8
  98. package/src/orchestrator/plugin-host.ts +100 -66
  99. package/src/orchestrator/plugin.ts +173 -67
  100. package/src/orchestrator/prepare.ts +163 -78
  101. package/src/orchestrator/projects.ts +105 -99
  102. package/src/orchestrator/remote-prefetch.ts +11 -6
  103. package/src/orchestrator/run-context.ts +25 -6
  104. package/src/orchestrator/run-failures.ts +17 -8
  105. package/src/orchestrator/run-lock.ts +1 -1
  106. package/src/orchestrator/run-records.ts +1 -1
  107. package/src/orchestrator/run-report.ts +9 -9
  108. package/src/orchestrator/run-settings.ts +48 -0
  109. package/src/orchestrator/run.ts +244 -119
  110. package/src/orchestrator/sandbox-fix.ts +39 -20
  111. package/src/orchestrator/sandbox-request.ts +84 -13
  112. package/src/orchestrator/signals.ts +1 -1
  113. package/src/orchestrator/stable-keys.ts +13 -11
  114. package/src/orchestrator/status-line.ts +49 -7
  115. package/src/orchestrator/summary.ts +115 -106
  116. package/src/orchestrator/task-hash.ts +12 -83
  117. package/src/orchestrator/telemetry.ts +4 -4
  118. package/src/settings.ts +428 -0
  119. package/src/{orchestrator → util}/colors.ts +5 -0
  120. package/src/util/config-frame.ts +31 -2
  121. package/src/util/errors.ts +33 -1
  122. package/src/util/essential-env.ts +31 -0
  123. package/src/util/hide-env.ts +107 -0
  124. package/src/util/index.ts +16 -3
  125. package/src/util/paths.ts +10 -0
  126. package/src/util/refusal-notice.ts +111 -0
  127. package/src/util/report.ts +31 -0
  128. package/src/util/settle.ts +49 -30
  129. package/src/util/size.ts +23 -4
  130. package/src/util/timing.ts +3 -2
  131. package/src/util/ui.ts +186 -0
  132. package/src/util/ulid.ts +1 -1
  133. package/src/util/verbs.ts +0 -15
  134. package/src/workspace/affected.ts +84 -232
  135. package/src/workspace/config-eval.ts +15 -20
  136. package/src/workspace/config-imports.ts +78 -3
  137. package/src/workspace/config-schema.ts +209 -268
  138. package/src/workspace/dependency-rules.ts +65 -0
  139. package/src/workspace/filter.ts +2 -2
  140. package/src/workspace/fingerprint.ts +11 -121
  141. package/src/workspace/index.ts +20 -27
  142. package/src/workspace/load-reads.ts +4 -4
  143. package/src/workspace/lockfile.ts +96 -140
  144. package/src/workspace/migration.ts +307 -215
  145. package/src/workspace/package-graph.ts +30 -216
  146. package/src/workspace/project-loader.ts +80 -285
  147. package/src/workspace/repo-id.ts +2 -1
  148. package/src/workspace/workspace.ts +363 -831
  149. package/src/cache/config-evals.ts +0 -130
  150. package/src/workspace/config-cache.ts +0 -661
  151. package/src/workspace/migrate-scripts.ts +0 -1121
package/src/exec/env.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { existsSync, readFileSync } from 'node:fs'
2
2
  import path from 'node:path'
3
+ import { ESSENTIAL_ENV } from '../util/index.js'
3
4
 
4
5
  // Build the environment exposed to a task.
5
6
  //
@@ -9,37 +10,6 @@ import path from 'node:path'
9
10
  // 3. define: explicit name=value pairs from the task config.
10
11
  // 4. binPaths prepended to PATH (after all the above resolve PATH).
11
12
 
12
- /**
13
- * Exported so the schema doc's enumeration of it can be PINNED against the
14
- * real list: a hand-copied allowlist is how the two drift, and a reader
15
- * asking "what does my build script actually see?" is asking a
16
- * security-shaped question that a stale list answers wrongly.
17
- */
18
- export const ESSENTIAL_ENV: readonly string[] = [
19
- 'PATH',
20
- 'HOME',
21
- 'SHELL',
22
- 'USER',
23
- 'LOGNAME',
24
- 'TMPDIR',
25
- 'TEMP',
26
- 'TMP',
27
- 'LANG',
28
- 'LC_ALL',
29
- 'LC_CTYPE',
30
- 'TERM',
31
- 'COLORTERM',
32
- 'FORCE_COLOR',
33
- 'NO_COLOR',
34
- 'CI',
35
- 'NODE_OPTIONS',
36
- // Where corepack and pnpm keep their own binaries, as HOME is: a machine
37
- // path, never task output. Without them a corepack shim re-downloads its
38
- // manager (offline or sandboxed: fails) and pnpm loses its global dir.
39
- 'COREPACK_HOME',
40
- 'PNPM_HOME',
41
- ]
42
-
43
13
  /**
44
14
  * The two variables vx sets on every task it spawns, over the layers
45
15
  * above: the workspace root the run is in and the `project#task` running.
@@ -73,12 +73,8 @@ export interface TaskInputs {
73
73
  readonly hash: string
74
74
  readonly outputs: readonly string[]
75
75
  }>
76
- /** Digest of the project's own `package.json` (folded even when no glob lists it). */
77
- readonly packageJsonDigest: string
78
76
  /** Digest of the resolved task config. */
79
77
  readonly configDigest: string
80
- /** The workspace fingerprint (root manifests + lockfile). */
81
- readonly workspaceFingerprint: string
82
78
  }
83
79
 
84
80
  export interface ExecuteRequest {
@@ -146,7 +142,7 @@ export interface ExecuteRequest {
146
142
  * work and returns: a child an executor spawned is not one core can
147
143
  * reach (H-10, H-12). One already aborted is work not to start (B-55).
148
144
  * Any exit after the timeout's abort, a 0 included, is a timeout. Core
149
- * waits the kill grace (`VX_KILL_GRACE_MS`, 2 s) after the abort, then
145
+ * waits the kill grace (`VX_RUN_KILL_GRACE`, 2 s) after the abort, then
150
146
  * settles the attempt without `execute` and abandons it (H-14).
151
147
  */
152
148
  readonly signal?: AbortSignal
@@ -11,7 +11,7 @@
11
11
  // takes even that.
12
12
 
13
13
  import { closeSync, readdirSync, readFileSync, writeSync } from 'node:fs'
14
- import { procfsIsOwn, taskShell } from '../util/index.js'
14
+ import { helperEnv, procfsIsOwn, taskShell } from '../util/index.js'
15
15
 
16
16
  export type Child = ReturnType<typeof Bun.spawn>
17
17
 
@@ -104,6 +104,7 @@ function startGuard(): void {
104
104
  try {
105
105
  const guard = Bun.spawn([taskShell(), '-c', GUARD_SCRIPT], {
106
106
  argv0: 'vx-group-guard',
107
+ env: helperEnv(),
107
108
  stdio: ['ignore', 'ignore', 'ignore', 'pipe'],
108
109
  // Out of vx's group, so a Ctrl-C at the terminal leaves it to the
109
110
  // pipe; vx's own teardown owns that path.
@@ -62,6 +62,7 @@ import {
62
62
  import {
63
63
  BUN_GLOB_WILDCARDS,
64
64
  executablePath,
65
+ helperEnv,
65
66
  taskShell,
66
67
  grantPrefix,
67
68
  isTmpdirRefusal,
@@ -1806,10 +1807,11 @@ function spawnHostBridges(ports: readonly number[], tag: string): void {
1806
1807
  ? Bun.spawn(argv, {
1807
1808
  stdio: ['ignore', 'ignore', 'ignore'],
1808
1809
  detached: true,
1810
+ env: helperEnv(),
1809
1811
  })
1810
1812
  : Bun.spawn(
1811
1813
  [taskShell(), '-c', `${guardLine(3)}exec ${argv.map(shellQuote).join(' ')}`],
1812
- { stdio: ['ignore', 'ignore', 'ignore', guard], detached: true },
1814
+ { stdio: ['ignore', 'ignore', 'ignore', guard], detached: true, env: helperEnv() },
1813
1815
  ),
1814
1816
  ),
1815
1817
  )
@@ -2691,6 +2693,15 @@ function expandGrants(
2691
2693
  // names a wall literally never reaches this loop and stays, and one
2692
2694
  // spelled with escaped brackets names it as surely.
2693
2695
  if (namedPath(p) === undefined && walls.some((w) => atOrUnder(real, w))) continue
2696
+ // A write hit that HOLDS a wall would bind it writable: `.*` in a root
2697
+ // project matched `.vx`, which holds the cache. A read hit is punched
2698
+ // around it instead (`punchWalls`).
2699
+ if (
2700
+ unmatched !== undefined &&
2701
+ namedPath(p) === undefined &&
2702
+ walls.some((w) => atOrUnder(w, real))
2703
+ )
2704
+ continue
2694
2705
  out.push(abs)
2695
2706
  hits++
2696
2707
  }
@@ -744,6 +744,9 @@ export function refusedConnections(records: readonly string[]): SandboxViolation
744
744
  }))
745
745
  }
746
746
 
747
+ /** Write syscalls that act on a path's last component, not what a link there names. */
748
+ const NAME_SYSCALL = /^(?:symlink|unlink|rename|rmdir|mknod|link)/
749
+
747
750
  /**
748
751
  * The writes SRT's Linux observer saw that this task's binds do not cover.
749
752
  * The observer reports every write-intent syscall as `deny <syscall>
@@ -774,7 +777,12 @@ export function refusedWrites(
774
777
  // `5>out.log` in the project reported a write to `out.log` and failed
775
778
  // a clean task (2026-10-02).
776
779
  if (/^\/(?:dev|proc)\//.test(raw)) continue
777
- const abs = toRealPath(raw)
780
+ // These act on the name itself, never through it: resolving the link
781
+ // `ln -s ../src/a.ts dist/a` just made judged the write by `src/a.ts`,
782
+ // and a clean task failed with a fix that granted `write: ['src/']`.
783
+ const abs = NAME_SYSCALL.test(syscall)
784
+ ? path.join(toRealPath(path.dirname(raw)), path.basename(raw))
785
+ : toRealPath(raw)
778
786
  if (isUnderAny(abs, binds) || underGlob(abs, globs)) continue
779
787
  // A directory a write bind lies in exists, since bwrap made it to mount
780
788
  // the bind: `mkdir -p node_modules/.cache/tool` under a grant of
@@ -11,7 +11,7 @@ export type DependencySpec =
11
11
 
12
12
  export class DependencySpecError extends Error {
13
13
  constructor(raw: string, message: string) {
14
- super(`Invalid dependency spec "${raw}": ${message}`)
14
+ super(`invalid dependsOn "${raw}": ${message}`)
15
15
  }
16
16
  }
17
17
 
@@ -24,6 +24,7 @@ export {
24
24
  declaredTask,
25
25
  excludeDependencies,
26
26
  expandRequested,
27
+ type GraphRules,
27
28
  isGroupTask,
28
29
  markSurfacedDeps,
29
30
  outputsOverlap,
@@ -161,7 +161,7 @@ export function computeReverseDepCount(nodes: Map<string, TaskNode>): Map<string
161
161
 
162
162
  /**
163
163
  * The ready-queue baseline a workspace may pick in place of the default
164
- * (`schedule` in vx.workspace.ts). Each ranks with no timings; a `schedule`
164
+ * (`schedule` in .vx/workspace.ts). Each ranks with no timings; a `schedule`
165
165
  * plugin's weights still sort above it, and it breaks their ties.
166
166
  * - `most-work`: transitive dependents (`computeReverseDepCount`), the default.
167
167
  * - `critical-path`: steps on the longest chain of dependents below a task.
@@ -121,14 +121,14 @@ export interface TaskOutcome {
121
121
  groupUpstream?: readonly TaskOutcome[]
122
122
  /**
123
123
  * This task RAN over inputs its key no longer describes: one moved while
124
- * it ran, the workspace fingerprint moved, or an upstream carried this
124
+ * it ran, a claimed root file moved, or an upstream carried this
125
125
  * mark. Its save was withheld, and its outputs are not what `hash` names,
126
126
  * so a dependant that folds `hash` saves nothing either (A-12).
127
127
  */
128
128
  unkeyed?: true
129
129
  /**
130
130
  * The run's cache policy read and wrote nothing (`--no-cache`,
131
- * `--cache=local:`): no cache answered for this task, so a run of it is
131
+ * `--cache-policy=local:`): no cache answered for this task, so a run of it is
132
132
  * `no-cache`, not a miss. Set by `run()`; `ranNoCache` reads it.
133
133
  */
134
134
  cacheOff?: true
@@ -152,19 +152,33 @@ export interface TaskOutcome {
152
152
  * bounded to its first 8 KiB and last 56 KiB. The run's history keeps it.
153
153
  */
154
154
  failedOutput?: string
155
+ /**
156
+ * When the run keeps its bytes in a remote, a task that ran and saved no
157
+ * entry (failed, or uncached): its whole output as `encodeOutputLog`
158
+ * stores it, secrets masked, at the entry's bounds.
159
+ */
160
+ runLog?: string
155
161
  /**
156
162
  * On a failed persistent task: why it never became ready — the readiness
157
163
  * deadline fired, the child exited first (`exitCode` is then its own), or
158
164
  * the spawn itself failed. Every label reads this instead of the exit.
159
165
  */
160
166
  notReady?: 'timeout' | 'exited' | 'spawn'
167
+ /**
168
+ * The input that changed while the command ran, failing the task under
169
+ * `inputStability.severity: 'error'` (workspace-relative; '' when vx
170
+ * could not name it). The exit is vx's 1; the label reads this instead.
171
+ */
172
+ inputMoved?: string
161
173
  /** Executor-reported placement label (`ExecuteResult.where`) — set only
162
174
  * when the task ran somewhere other than this host. Telemetry-only. */
163
175
  where?: string
164
176
  /** `'deferred'` when the task's outputs were left in the remote store
165
- * (`--download=none`) instead of landing on this machine. Absent for
177
+ * (`--cache-download=none`) instead of landing on this machine. Absent for
166
178
  * every ordinary outcome. Telemetry-only. */
167
179
  outputs?: 'deferred'
180
+ /** The task ran here and its result went to the cache. The footer's `↑`. */
181
+ saved?: true
168
182
  /**
169
183
  * v11 analytics: hrtime span relative to the parent run's t=0.
170
184
  * Lets downstream analytics reconstruct the actual parallel timeline
@@ -183,7 +197,7 @@ export interface TaskOutcome {
183
197
  restored?: boolean
184
198
  /**
185
199
  * How many times the task executed this run, when `exec.retries` /
186
- * `--retry` re-ran failed attempts. Set only when > 1 — a plain
200
+ * `--retries` re-ran failed attempts. Set only when > 1 — a plain
187
201
  * single-attempt run carries nothing. Not persisted; telemetry-side
188
202
  * flaky detection reads it off the outcome stream.
189
203
  */
@@ -216,6 +230,12 @@ export interface TaskOutcome {
216
230
  systemInfo?: string[]
217
231
  /** Credential paths denied and left out of the grants: an agent applies the fix as given. */
218
232
  withheld?: string[]
233
+ /**
234
+ * Reads of the project the task's `cache.inputs.files` does not match:
235
+ * add them there too when the output depends on them, or a hit replays
236
+ * output built from an older copy. Cached tasks only.
237
+ */
238
+ inputs?: string[]
219
239
  }
220
240
  }
221
241
 
@@ -267,7 +287,7 @@ export interface ScheduleOptions {
267
287
  * outcome lands. Absent = stderr. The run gives it the task's own
268
288
  * stderr, so its frame carries the reason.
269
289
  */
270
- onError?: (node: TaskNode, line: string) => void
290
+ onError?: (node: TaskNode, line: string, err: unknown) => void
271
291
  /**
272
292
  * What an outcome still owes before its dependents may start — a cache
273
293
  * save that runs off the execution slot (the orchestrator's save lane):
@@ -294,7 +314,7 @@ export interface ScheduleOptions {
294
314
  */
295
315
  priorities?: ReadonlyMap<string, number>
296
316
  /**
297
- * The baseline ranking under `priorities` (`schedule` in vx.workspace.ts).
317
+ * The baseline ranking under `priorities` (`schedule` in .vx/workspace.ts).
298
318
  * Undefined or `most-work` is the reverse-deps count above.
299
319
  */
300
320
  strategy?: ScheduleStrategy
@@ -1027,7 +1047,7 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
1027
1047
  }
1028
1048
  try {
1029
1049
  if (options.onError === undefined) process.stderr.write(line)
1030
- else options.onError(node, line)
1050
+ else options.onError(node, line, err)
1031
1051
  } catch {
1032
1052
  process.stderr.write(line)
1033
1053
  }
@@ -7,6 +7,7 @@ import {
7
7
  splitNegations,
8
8
  staticPrefix,
9
9
  taskGlob,
10
+ refusal,
10
11
  UserError,
11
12
  wholeSubtreePrefixes,
12
13
  } from '../util/index.js'
@@ -288,10 +289,16 @@ export interface BuildGraphOptions {
288
289
  * with other root-anchored ones.
289
290
  */
290
291
  workspaceRoot?: string
291
- /** The workspace's `rules`; each is on unless set to `false`. */
292
- rules?: WorkspaceRules | undefined
292
+ /** The workspace's `run.rules`; each is on unless set to `false`. */
293
+ rules?: GraphRules | undefined
293
294
  }
294
295
 
296
+ /**
297
+ * `run.rules` as the graph applies them. `locked`: a run, which never
298
+ * checks them; `vx lock` and `vx lock --check` do (owner, 2026-10-14).
299
+ */
300
+ export type GraphRules = WorkspaceRules & { readonly locked?: boolean }
301
+
295
302
  /**
296
303
  * The refusal of a literal `^name` that no project in the workspace
297
304
  * declares: it has no holder anywhere, so it can only be a typo (nx#32779
@@ -301,7 +308,7 @@ export interface BuildGraphOptions {
301
308
  */
302
309
  export function undeclaredDepsError(taskId: string, name: string): UserError {
303
310
  return new UserError(
304
- `Task ${taskId} depends on ^${name} but no project in the workspace declares ${name}`,
311
+ `${taskId}: dependsOn ^${name} but no project in the workspace declares ${name}`,
305
312
  'VX_E_DEPENDS_ON',
306
313
  )
307
314
  }
@@ -493,7 +500,7 @@ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode
493
500
  spec = parseDependencySpec(raw)
494
501
  } catch (err) {
495
502
  if (err instanceof DependencySpecError) {
496
- throw new UserError(`Task ${id}: ${err.message}`, 'VX_E_DEPENDS_ON')
503
+ throw new UserError(`${id}: ${err.message}`, 'VX_E_DEPENDS_ON')
497
504
  }
498
505
  throw err
499
506
  }
@@ -505,19 +512,19 @@ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode
505
512
  // they name a namespace of tasks to add (Nx 19.5 parity).
506
513
  if (spec.kind === 'wildcardSelf' || spec.kind === 'wildcardDeps') {
507
514
  throw new UserError(
508
- `Task ${id}: dependsOn does not accept bare wildcards (got "${raw}")`,
515
+ `${id}: dependsOn does not accept bare wildcards (got "${raw}")`,
509
516
  'VX_E_DEPENDS_ON',
510
517
  )
511
518
  }
512
519
  if (spec.negated) {
513
520
  throw new UserError(
514
- `Task ${id}: dependsOn does not accept negation (got "${raw}")`,
521
+ `${id}: dependsOn does not accept negation (got "${raw}")`,
515
522
  'VX_E_DEPENDS_ON',
516
523
  )
517
524
  }
518
525
  if (spec.kind === 'cross' && (isTaskPattern(spec.task) || isTaskPattern(spec.project))) {
519
526
  throw new UserError(
520
- `Task ${id}: dependsOn patterns are not supported in the "pkg#task" form (got "${raw}")`,
527
+ `${id}: dependsOn patterns are not supported in the "pkg#task" form (got "${raw}")`,
521
528
  'VX_E_DEPENDS_ON',
522
529
  )
523
530
  }
@@ -537,7 +544,7 @@ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode
537
544
  // doesn't resolve in this project.
538
545
  if (!visit(frame, projectName, spec.task, false)) {
539
546
  throw new UserError(
540
- `Task ${id} depends on ${taskId(projectName, spec.task)} but no such task is declared`,
547
+ `${id}: dependsOn ${taskId(projectName, spec.task)} but no such task is declared`,
541
548
  'VX_E_DEPENDS_ON',
542
549
  )
543
550
  }
@@ -570,6 +577,17 @@ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode
570
577
  // last (`resolveDeferred`), and a walk that meets it on a cycle takes
571
578
  // the edge and goes on past it, so the builds it could not depend on
572
579
  // still come first.
580
+ //
581
+ // With no edge source every `^` resolves to nothing and its upstream
582
+ // folds into no key: a stale hit, green. With one, a project with no
583
+ // deps is a leaf. The default `build`'s `^build` is no exception: a
584
+ // task behind it would fold no upstream either.
585
+ if (!packageGraph.edgeSource) {
586
+ throw new UserError(
587
+ `${id}: ^${spec.task} needs project edges; add pnpm()/bun()/npm()/yarn() or a plugin that returns \`deps\``,
588
+ 'VX_E_DEPENDS_ON',
589
+ )
590
+ }
573
591
  const visited = new Set<string>([projectName])
574
592
  if (!isTaskPattern(spec.task)) {
575
593
  const held = walkHolders(frame, spec.task, visited, projectName)
@@ -602,11 +620,11 @@ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode
602
620
  // The loader spells `//` as the root project's name when there is one.
603
621
  if (spec.project === '//')
604
622
  throw new UserError(
605
- `Task ${id} depends on //#${spec.task}, Turbo's root package, but the workspace root is no project here: give it a package.json name and a vx.config with that task`,
623
+ `${id}: dependsOn //#${spec.task}, the workspace root's task, but the root declares no such task: add a vx.config at the root declaring it and run \`vx lock\``,
606
624
  'VX_E_DEPENDS_ON',
607
625
  )
608
626
  throw new UserError(
609
- `Task ${id} depends on ${taskId(spec.project, spec.task)} but no such project or task is declared`,
627
+ `${id}: dependsOn ${taskId(spec.project, spec.task)} but no such project or task is declared`,
610
628
  'VX_E_DEPENDS_ON',
611
629
  )
612
630
  }
@@ -746,7 +764,7 @@ function reachingTo(nodes: Map<string, TaskNode>, target: string): Set<string> {
746
764
  export function checkGraph(
747
765
  nodes: Map<string, TaskNode>,
748
766
  workspaceRoot?: string,
749
- rules?: WorkspaceRules,
767
+ rules?: GraphRules,
750
768
  ): void {
751
769
  for (const [key, node] of nodes) {
752
770
  if (node.id !== key) {
@@ -762,8 +780,12 @@ export function checkGraph(
762
780
  }
763
781
  for (const node of nodes.values()) refuseSelfClean(node)
764
782
  detectCycle(nodes)
765
- detectOutputCollisions(nodes, workspaceRoot, rules?.exclusiveOutputs !== false)
766
- if (rules?.upfrontKeys !== false) detectInputOverlaps(nodes, workspaceRoot)
783
+ const exclusive = rules?.exclusiveOutputs !== false
784
+ const locked = rules?.locked === true
785
+ // Under a lock an exclusive graph has no overlap to mark; a non-exclusive
786
+ // one still needs the addition marks the walk derives.
787
+ if (!locked || !exclusive) detectOutputCollisions(nodes, workspaceRoot, exclusive)
788
+ if (rules?.upfrontKeys !== false && !locked) detectInputOverlaps(nodes, workspaceRoot)
767
789
  }
768
790
 
769
791
  /**
@@ -948,6 +970,19 @@ function witnessed(a: string, b: string): boolean {
948
970
  if (ga.match(candidate) && gb.match(candidate)) return true
949
971
  }
950
972
  }
973
+ // Each glob filled in whole, every wildcard a name and `**` one level or
974
+ // none: `**/dist/**` beside `**/*` meets at `x/dist/x`, a path neither
975
+ // last segment builds (solid, M 2026-10-15).
976
+ for (const glob of [a, b]) {
977
+ const segs = glob.split('/')
978
+ if (segs.some((s) => /[[\]{}()!+@]/.test(s))) continue
979
+ for (const drop of [false, true]) {
980
+ const candidate = segs
981
+ .flatMap((s) => (s === '**' ? (drop ? [] : ['x']) : [s.replace(/\*+|\?/g, 'x')]))
982
+ .join('/')
983
+ if (candidate !== '' && ga.match(candidate) && gb.match(candidate)) return true
984
+ }
985
+ }
951
986
  return false
952
987
  }
953
988
 
@@ -1352,13 +1387,13 @@ function neverWritesLocally(n: TaskNode): boolean {
1352
1387
  }
1353
1388
 
1354
1389
  /**
1355
- * The way out of an edge-ordered overlap under `rules.exclusiveOutputs`: an
1390
+ * The way out of an edge-ordered overlap under `run.rules.exclusiveOutputs`: an
1356
1391
  * ordered pair is correct (the addition shape, item 588), only slower to
1357
1392
  * clean and restore, so the rule may be turned off.
1358
1393
  */
1359
1394
  const EXCLUSIVE_OUTPUTS_FIX =
1360
- 'Give each task its own output path, or set rules: { exclusiveOutputs: false } in ' +
1361
- "vx.workspace.ts to let a dependant add to its upstream's outputs."
1395
+ 'Give each task its own output path, or set run.rules.exclusiveOutputs: false in ' +
1396
+ ".vx/workspace.ts to let a dependant add to its upstream's outputs."
1362
1397
 
1363
1398
  function collide(
1364
1399
  a: TaskNode,
@@ -1383,11 +1418,22 @@ function collide(
1383
1418
  // dependant runs after its upstream and adds to that tree, so the
1384
1419
  // order is fixed and the dependant's own set can be told apart from
1385
1420
  // what it found. Marked on both, and the pair is allowed when
1386
- // `rules.exclusiveOutputs` is off (X-53). Without an edge the two run
1421
+ // `run.rules.exclusiveOutputs` is off (X-53). Without an edge the two run
1387
1422
  // in either order, and the refusal below stands.
1388
1423
  const [up, down] = reaches(b.id, a.id) ? [a, b] : reaches(a.id, b.id) ? [b, a] : []
1424
+ const parts = {
1425
+ headline: `${a.id} and ${b.id} both write ${ga === gb ? ga : `${ga} / ${gb}`}`,
1426
+ ...(field === 'files' ? {} : { what: ['in cache.outputs.workspaceFiles'] }),
1427
+ why: "vx clears a task's outputs before it runs, so one deletes the other's",
1428
+ }
1389
1429
  if (up !== undefined && exclusive)
1390
- throw new UserError(`${head}. ${EXCLUSIVE_OUTPUTS_FIX}`, 'VX_E_OUTPUT_OVERLAP')
1430
+ throw refusal(
1431
+ {
1432
+ ...parts,
1433
+ fix: 'give each task its own output path, or set run.rules.exclusiveOutputs: false in .vx/workspace.ts',
1434
+ },
1435
+ { code: 'VX_E_OUTPUT_OVERLAP', message: `${head}. ${EXCLUSIVE_OUTPUTS_FIX}` },
1436
+ )
1391
1437
  if (up !== undefined && down !== undefined) {
1392
1438
  const downGlobs = splitNegations(
1393
1439
  (field === 'files'
@@ -1398,9 +1444,12 @@ function collide(
1398
1444
  ;(up.outputsAddedToBy ??= []).push(...(downGlobs ?? []))
1399
1445
  return
1400
1446
  }
1401
- throw new UserError(
1402
- `${head} and the run still reports success. Give each task its own output path.`,
1403
- 'VX_E_OUTPUT_OVERLAP',
1447
+ throw refusal(
1448
+ { ...parts, fix: 'give each task its own output path' },
1449
+ {
1450
+ code: 'VX_E_OUTPUT_OVERLAP',
1451
+ message: `${head} and the run still reports success. Give each task its own output path.`,
1452
+ },
1404
1453
  )
1405
1454
  }
1406
1455
  }
@@ -1504,7 +1553,7 @@ interface Side {
1504
1553
  }
1505
1554
 
1506
1555
  /**
1507
- * `rules.upfrontKeys` (X-54): refuse a task whose input globs can match
1556
+ * `run.rules.upfrontKeys` (X-54): refuse a task whose input globs can match
1508
1557
  * another task's declared outputs. Such a key reads bytes a producer writes
1509
1558
  * this run, so it is not known until the producer ran (`stable-keys.ts`
1510
1559
  * keeps it waiting); refused, every key reads only what no task writes and
@@ -1677,7 +1726,7 @@ function readsOutputs(
1677
1726
  `${writer.node.id}'s output ${JSON.stringify(shown)} — a task's key must not read ` +
1678
1727
  `another task's outputs (the dependency's key already cascades through dependsOn). ` +
1679
1728
  `Exclude it: add ${JSON.stringify(`!${out}`)} to ${reader.node.id}'s ` +
1680
- `cache.inputs.${field}, or set rules: { upfrontKeys: false } in vx.workspace.ts to ` +
1729
+ `cache.inputs.${field}, or set run.rules.upfrontKeys: false in .vx/workspace.ts to ` +
1681
1730
  `let it wait for its producer.`,
1682
1731
  'VX_E_INPUT_IS_OUTPUT',
1683
1732
  )
@@ -1725,7 +1774,10 @@ function detectCycle(nodes: Map<string, TaskNode>): void {
1725
1774
  for (let i = 0; i < stack.length; i += 2) path.push(idsArr[stack[i]!]!)
1726
1775
  const startInPath = path.indexOf(idsArr[depIdx]!)
1727
1776
  const cycle = [...path.slice(startInPath), idsArr[depIdx]!].join(' -> ')
1728
- throw new UserError(`Cycle detected in task graph: ${cycle}`, 'VX_E_CYCLE')
1777
+ throw new UserError(
1778
+ `dependsOn cycle: ${cycle} — remove one edge of the loop`,
1779
+ 'VX_E_CYCLE',
1780
+ )
1729
1781
  }
1730
1782
  color[depIdx] = GRAY
1731
1783
  stack.push(depIdx, 0)