@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
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
@@ -158,11 +158,17 @@ export interface TaskOutcome {
158
158
  * the spawn itself failed. Every label reads this instead of the exit.
159
159
  */
160
160
  notReady?: 'timeout' | 'exited' | 'spawn'
161
+ /**
162
+ * The input that changed while the command ran, failing the task under
163
+ * `inputStability.severity: 'error'` (workspace-relative; '' when vx
164
+ * could not name it). The exit is vx's 1; the label reads this instead.
165
+ */
166
+ inputMoved?: string
161
167
  /** Executor-reported placement label (`ExecuteResult.where`) — set only
162
168
  * when the task ran somewhere other than this host. Telemetry-only. */
163
169
  where?: string
164
170
  /** `'deferred'` when the task's outputs were left in the remote store
165
- * (`--download=none`) instead of landing on this machine. Absent for
171
+ * (`--cache-download=none`) instead of landing on this machine. Absent for
166
172
  * every ordinary outcome. Telemetry-only. */
167
173
  outputs?: 'deferred'
168
174
  /**
@@ -183,7 +189,7 @@ export interface TaskOutcome {
183
189
  restored?: boolean
184
190
  /**
185
191
  * How many times the task executed this run, when `exec.retries` /
186
- * `--retry` re-ran failed attempts. Set only when > 1 — a plain
192
+ * `--retries` re-ran failed attempts. Set only when > 1 — a plain
187
193
  * single-attempt run carries nothing. Not persisted; telemetry-side
188
194
  * flaky detection reads it off the outcome stream.
189
195
  */
@@ -216,6 +222,12 @@ export interface TaskOutcome {
216
222
  systemInfo?: string[]
217
223
  /** Credential paths denied and left out of the grants: an agent applies the fix as given. */
218
224
  withheld?: string[]
225
+ /**
226
+ * Reads of the project the task's `cache.inputs.files` does not match:
227
+ * add them there too when the output depends on them, or a hit replays
228
+ * output built from an older copy. Cached tasks only.
229
+ */
230
+ inputs?: string[]
219
231
  }
220
232
  }
221
233
 
@@ -267,7 +279,7 @@ export interface ScheduleOptions {
267
279
  * outcome lands. Absent = stderr. The run gives it the task's own
268
280
  * stderr, so its frame carries the reason.
269
281
  */
270
- onError?: (node: TaskNode, line: string) => void
282
+ onError?: (node: TaskNode, line: string, err: unknown) => void
271
283
  /**
272
284
  * What an outcome still owes before its dependents may start — a cache
273
285
  * save that runs off the execution slot (the orchestrator's save lane):
@@ -294,7 +306,7 @@ export interface ScheduleOptions {
294
306
  */
295
307
  priorities?: ReadonlyMap<string, number>
296
308
  /**
297
- * The baseline ranking under `priorities` (`schedule` in vx.workspace.ts).
309
+ * The baseline ranking under `priorities` (`schedule` in .vx/workspace.ts).
298
310
  * Undefined or `most-work` is the reverse-deps count above.
299
311
  */
300
312
  strategy?: ScheduleStrategy
@@ -1027,7 +1039,7 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
1027
1039
  }
1028
1040
  try {
1029
1041
  if (options.onError === undefined) process.stderr.write(line)
1030
- else options.onError(node, line)
1042
+ else options.onError(node, line, err)
1031
1043
  } catch {
1032
1044
  process.stderr.write(line)
1033
1045
  }
@@ -288,10 +288,16 @@ export interface BuildGraphOptions {
288
288
  * with other root-anchored ones.
289
289
  */
290
290
  workspaceRoot?: string
291
- /** The workspace's `rules`; each is on unless set to `false`. */
292
- rules?: WorkspaceRules | undefined
291
+ /** The workspace's `run.rules`; each is on unless set to `false`. */
292
+ rules?: GraphRules | undefined
293
293
  }
294
294
 
295
+ /**
296
+ * `run.rules` as the graph applies them. `locked`: a run, which never
297
+ * checks them; `vx lock` and `vx lock --check` do (owner, 2026-10-14).
298
+ */
299
+ export type GraphRules = WorkspaceRules & { readonly locked?: boolean }
300
+
295
301
  /**
296
302
  * The refusal of a literal `^name` that no project in the workspace
297
303
  * declares: it has no holder anywhere, so it can only be a typo (nx#32779
@@ -301,7 +307,7 @@ export interface BuildGraphOptions {
301
307
  */
302
308
  export function undeclaredDepsError(taskId: string, name: string): UserError {
303
309
  return new UserError(
304
- `Task ${taskId} depends on ^${name} but no project in the workspace declares ${name}`,
310
+ `${taskId}: dependsOn ^${name} but no project in the workspace declares ${name}`,
305
311
  'VX_E_DEPENDS_ON',
306
312
  )
307
313
  }
@@ -493,7 +499,7 @@ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode
493
499
  spec = parseDependencySpec(raw)
494
500
  } catch (err) {
495
501
  if (err instanceof DependencySpecError) {
496
- throw new UserError(`Task ${id}: ${err.message}`, 'VX_E_DEPENDS_ON')
502
+ throw new UserError(`${id}: ${err.message}`, 'VX_E_DEPENDS_ON')
497
503
  }
498
504
  throw err
499
505
  }
@@ -505,19 +511,19 @@ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode
505
511
  // they name a namespace of tasks to add (Nx 19.5 parity).
506
512
  if (spec.kind === 'wildcardSelf' || spec.kind === 'wildcardDeps') {
507
513
  throw new UserError(
508
- `Task ${id}: dependsOn does not accept bare wildcards (got "${raw}")`,
514
+ `${id}: dependsOn does not accept bare wildcards (got "${raw}")`,
509
515
  'VX_E_DEPENDS_ON',
510
516
  )
511
517
  }
512
518
  if (spec.negated) {
513
519
  throw new UserError(
514
- `Task ${id}: dependsOn does not accept negation (got "${raw}")`,
520
+ `${id}: dependsOn does not accept negation (got "${raw}")`,
515
521
  'VX_E_DEPENDS_ON',
516
522
  )
517
523
  }
518
524
  if (spec.kind === 'cross' && (isTaskPattern(spec.task) || isTaskPattern(spec.project))) {
519
525
  throw new UserError(
520
- `Task ${id}: dependsOn patterns are not supported in the "pkg#task" form (got "${raw}")`,
526
+ `${id}: dependsOn patterns are not supported in the "pkg#task" form (got "${raw}")`,
521
527
  'VX_E_DEPENDS_ON',
522
528
  )
523
529
  }
@@ -537,7 +543,7 @@ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode
537
543
  // doesn't resolve in this project.
538
544
  if (!visit(frame, projectName, spec.task, false)) {
539
545
  throw new UserError(
540
- `Task ${id} depends on ${taskId(projectName, spec.task)} but no such task is declared`,
546
+ `${id}: dependsOn ${taskId(projectName, spec.task)} but no such task is declared`,
541
547
  'VX_E_DEPENDS_ON',
542
548
  )
543
549
  }
@@ -570,6 +576,17 @@ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode
570
576
  // last (`resolveDeferred`), and a walk that meets it on a cycle takes
571
577
  // the edge and goes on past it, so the builds it could not depend on
572
578
  // still come first.
579
+ //
580
+ // With no edge source every `^` resolves to nothing and its upstream
581
+ // folds into no key: a stale hit, green. With one, a project with no
582
+ // deps is a leaf. The default `build`'s `^build` is no exception: a
583
+ // task behind it would fold no upstream either.
584
+ if (!packageGraph.edgeSource) {
585
+ throw new UserError(
586
+ `${id}: ^${spec.task} needs project edges; add pnpm()/bun()/npm()/yarn() or a plugin that returns \`deps\``,
587
+ 'VX_E_DEPENDS_ON',
588
+ )
589
+ }
573
590
  const visited = new Set<string>([projectName])
574
591
  if (!isTaskPattern(spec.task)) {
575
592
  const held = walkHolders(frame, spec.task, visited, projectName)
@@ -602,11 +619,11 @@ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode
602
619
  // The loader spells `//` as the root project's name when there is one.
603
620
  if (spec.project === '//')
604
621
  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`,
622
+ `${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
623
  'VX_E_DEPENDS_ON',
607
624
  )
608
625
  throw new UserError(
609
- `Task ${id} depends on ${taskId(spec.project, spec.task)} but no such project or task is declared`,
626
+ `${id}: dependsOn ${taskId(spec.project, spec.task)} but no such project or task is declared`,
610
627
  'VX_E_DEPENDS_ON',
611
628
  )
612
629
  }
@@ -746,7 +763,7 @@ function reachingTo(nodes: Map<string, TaskNode>, target: string): Set<string> {
746
763
  export function checkGraph(
747
764
  nodes: Map<string, TaskNode>,
748
765
  workspaceRoot?: string,
749
- rules?: WorkspaceRules,
766
+ rules?: GraphRules,
750
767
  ): void {
751
768
  for (const [key, node] of nodes) {
752
769
  if (node.id !== key) {
@@ -762,8 +779,12 @@ export function checkGraph(
762
779
  }
763
780
  for (const node of nodes.values()) refuseSelfClean(node)
764
781
  detectCycle(nodes)
765
- detectOutputCollisions(nodes, workspaceRoot, rules?.exclusiveOutputs !== false)
766
- if (rules?.upfrontKeys !== false) detectInputOverlaps(nodes, workspaceRoot)
782
+ const exclusive = rules?.exclusiveOutputs !== false
783
+ const locked = rules?.locked === true
784
+ // Under a lock an exclusive graph has no overlap to mark; a non-exclusive
785
+ // one still needs the addition marks the walk derives.
786
+ if (!locked || !exclusive) detectOutputCollisions(nodes, workspaceRoot, exclusive)
787
+ if (rules?.upfrontKeys !== false && !locked) detectInputOverlaps(nodes, workspaceRoot)
767
788
  }
768
789
 
769
790
  /**
@@ -1352,13 +1373,13 @@ function neverWritesLocally(n: TaskNode): boolean {
1352
1373
  }
1353
1374
 
1354
1375
  /**
1355
- * The way out of an edge-ordered overlap under `rules.exclusiveOutputs`: an
1376
+ * The way out of an edge-ordered overlap under `run.rules.exclusiveOutputs`: an
1356
1377
  * ordered pair is correct (the addition shape, item 588), only slower to
1357
1378
  * clean and restore, so the rule may be turned off.
1358
1379
  */
1359
1380
  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."
1381
+ 'Give each task its own output path, or set run.rules.exclusiveOutputs: false in ' +
1382
+ ".vx/workspace.ts to let a dependant add to its upstream's outputs."
1362
1383
 
1363
1384
  function collide(
1364
1385
  a: TaskNode,
@@ -1383,7 +1404,7 @@ function collide(
1383
1404
  // dependant runs after its upstream and adds to that tree, so the
1384
1405
  // order is fixed and the dependant's own set can be told apart from
1385
1406
  // 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
1407
+ // `run.rules.exclusiveOutputs` is off (X-53). Without an edge the two run
1387
1408
  // in either order, and the refusal below stands.
1388
1409
  const [up, down] = reaches(b.id, a.id) ? [a, b] : reaches(a.id, b.id) ? [b, a] : []
1389
1410
  if (up !== undefined && exclusive)
@@ -1504,7 +1525,7 @@ interface Side {
1504
1525
  }
1505
1526
 
1506
1527
  /**
1507
- * `rules.upfrontKeys` (X-54): refuse a task whose input globs can match
1528
+ * `run.rules.upfrontKeys` (X-54): refuse a task whose input globs can match
1508
1529
  * another task's declared outputs. Such a key reads bytes a producer writes
1509
1530
  * this run, so it is not known until the producer ran (`stable-keys.ts`
1510
1531
  * keeps it waiting); refused, every key reads only what no task writes and
@@ -1677,7 +1698,7 @@ function readsOutputs(
1677
1698
  `${writer.node.id}'s output ${JSON.stringify(shown)} — a task's key must not read ` +
1678
1699
  `another task's outputs (the dependency's key already cascades through dependsOn). ` +
1679
1700
  `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 ` +
1701
+ `cache.inputs.${field}, or set run.rules.upfrontKeys: false in .vx/workspace.ts to ` +
1681
1702
  `let it wait for its producer.`,
1682
1703
  'VX_E_INPUT_IS_OUTPUT',
1683
1704
  )
@@ -1725,7 +1746,10 @@ function detectCycle(nodes: Map<string, TaskNode>): void {
1725
1746
  for (let i = 0; i < stack.length; i += 2) path.push(idsArr[stack[i]!]!)
1726
1747
  const startInPath = path.indexOf(idsArr[depIdx]!)
1727
1748
  const cycle = [...path.slice(startInPath), idsArr[depIdx]!].join(' -> ')
1728
- throw new UserError(`Cycle detected in task graph: ${cycle}`, 'VX_E_CYCLE')
1749
+ throw new UserError(
1750
+ `dependsOn cycle: ${cycle} — remove one edge of the loop`,
1751
+ 'VX_E_CYCLE',
1752
+ )
1729
1753
  }
1730
1754
  color[depIdx] = GRAY
1731
1755
  stack.push(depIdx, 0)
package/src/handoff.ts ADDED
@@ -0,0 +1,169 @@
1
+ // A global vx hands off to the project's own (owner, 2026-10-13), found
2
+ // from the workspace root, the one lookup every verb shares. Here for
3
+ // the compiled binary installed by hand; an npm install's launcher
4
+ // (npm-launcher.sh) walks the same way and `exec`s, which Bun cannot. The
5
+ // walk runs before anything else loads, through `Bun.file(…).exists()`:
6
+ // importing `node:fs` alone was ~1.1 ms of a 4 ms start (2026-10-13).
7
+
8
+ /**
9
+ * The project-local vx to run instead of this one, or null: the workspace
10
+ * root's ({@link workspaceFile}) `node_modules/@vzn/vx`, run through its
11
+ * `.bin/vx`. Null once handed
12
+ * off (one hop), for a vx that is not a standalone compiled binary (source;
13
+ * an npm install, whose launcher already walked), outside a workspace,
14
+ * when the local one is this binary, and when another user owns it.
15
+ */
16
+ export async function localVx(
17
+ cwd: string,
18
+ execPath: string,
19
+ compiled: boolean,
20
+ uid = process.getuid?.(),
21
+ ): Promise<string | null> {
22
+ if (process.env['VX_HANDED_OFF'] !== undefined || !compiled) return null
23
+ if (execPath.includes('/node_modules/')) return null
24
+ const file = await workspaceFile(cwd)
25
+ if (file === null) return null
26
+ const root = workspaceRootOf(file)
27
+ if (!(await Bun.file(`${root}/node_modules/@vzn/vx/package.json`).exists())) return null
28
+ const bin = `${root}/node_modules/.bin/vx`
29
+ const { realpathSync, statSync } = await import('node:fs')
30
+ try {
31
+ const real = realpathSync(bin)
32
+ // One another user planted would run as you.
33
+ if (statSync(real).uid !== uid) return null
34
+ return real === realpathSync(execPath) ? null : bin
35
+ } catch {
36
+ return null
37
+ }
38
+ }
39
+
40
+ /**
41
+ * The workspace config's paths under the root, in lookup order: one marks
42
+ * the workspace root, the directory holding its `.vx` (owner, 2026-10-14).
43
+ */
44
+ export const WORKSPACE_CONFIG_FILENAMES = [
45
+ '.vx/workspace.ts',
46
+ '.vx/workspace.mts',
47
+ '.vx/workspace.js',
48
+ '.vx/workspace.mjs',
49
+ // A CommonJS config (`module.exports = …`), as Vite and TS name theirs: it
50
+ // was ignored without a word (D-86).
51
+ '.vx/workspace.cts',
52
+ '.vx/workspace.cjs',
53
+ ]
54
+
55
+ /** The workspace root a workspace config marks: the directory above its `.vx`. */
56
+ export function workspaceRootOf(file: string): string {
57
+ return file.slice(0, file.lastIndexOf('/.vx/')) || '/'
58
+ }
59
+
60
+ const files = new Map<string, Promise<string | null>>()
61
+
62
+ /**
63
+ * The workspace config that marks the root for `dir`: the first
64
+ * `.vx/workspace.*` from `dir` up to the git top-level ({@link gitTop}),
65
+ * inclusive, or to `/` outside git (owner, 2026-10-13: "Workspace file is
66
+ * the ultimate" and "never go out of git root"). Null when there is none.
67
+ * Learned once per directory per run: the hand-off and the workspace root
68
+ * share it (principle 9).
69
+ */
70
+ export function workspaceFile(dir: string): Promise<string | null> {
71
+ let file = files.get(dir)
72
+ if (file === undefined) {
73
+ file = findWorkspaceFile(dir)
74
+ files.set(dir, file)
75
+ }
76
+ return file
77
+ }
78
+
79
+ /** vx wrote a workspace config (`vx init`): what was learned before it is stale. */
80
+ export function workspaceFileWritten(): void {
81
+ files.clear()
82
+ }
83
+
84
+ async function findWorkspaceFile(start: string): Promise<string | null> {
85
+ const top = await gitTop(start)
86
+ const passed: string[] = []
87
+ let found: string | null = null
88
+ walk: for (let d = start; ; d = d.slice(0, d.lastIndexOf('/'))) {
89
+ passed.push(d || '/')
90
+ for (const name of WORKSPACE_CONFIG_FILENAMES) {
91
+ const file = `${d}/${name}`
92
+ if (await Bun.file(file).exists()) {
93
+ found = file
94
+ break walk
95
+ }
96
+ }
97
+ if (d === '' || d === top) break
98
+ }
99
+ // Every directory walked has the same answer: the loader asks for the root's.
100
+ for (const d of passed) if (!files.has(d)) files.set(d, Promise.resolve(found))
101
+ return found
102
+ }
103
+
104
+ const tops = new Map<string, Promise<string | null>>()
105
+
106
+ /**
107
+ * The git top-level holding `dir`, or null outside git: the nearest `.git`
108
+ * up from it, a directory in a clone, a file in a worktree. A submodule's
109
+ * `.git` file points into its superproject's `.git/modules`, so the walk
110
+ * goes on to the superproject's. Learned once per directory per run: the
111
+ * hand-off and the workspace root walk share it (principle 9).
112
+ */
113
+ export function gitTop(dir: string): Promise<string | null> {
114
+ let top = tops.get(dir)
115
+ if (top === undefined) {
116
+ top = findGitTop(dir)
117
+ tops.set(dir, top)
118
+ }
119
+ return top
120
+ }
121
+
122
+ async function findGitTop(start: string): Promise<string | null> {
123
+ for (let d = start; ; d = d.slice(0, d.lastIndexOf('/'))) {
124
+ // A file each: `exists()` is false for a directory.
125
+ if (await Bun.file(`${d}/.git/HEAD`).exists()) return d || '/'
126
+ const file = Bun.file(`${d}/.git`)
127
+ if (await file.exists()) {
128
+ const gitdir = await file.text().catch(() => '')
129
+ if (!/(^|[\s/])\.git\/modules\//.test(gitdir)) return d || '/'
130
+ }
131
+ if (d === '') return null
132
+ }
133
+ }
134
+
135
+ /** This vx's own file: the compiled binary, else the source entry Bun runs. */
136
+ export function vxPath(): string {
137
+ return Bun.main.startsWith('/$bunfs') ? process.execPath : Bun.main
138
+ }
139
+
140
+ /**
141
+ * Run `bin` with this process's arguments and stdio, and exit as it does.
142
+ * A signal sent to this vx alone (`kill`, a process manager) is passed on;
143
+ * one the terminal sent reached the whole foreground group already, and a
144
+ * second would read as "stop now" (npm-launcher.cjs, the same rule).
145
+ */
146
+ export async function handOff(bin: string, args: readonly string[]): Promise<never> {
147
+ const child = Bun.spawn([bin, ...args], {
148
+ stdio: ['inherit', 'inherit', 'inherit'],
149
+ env: { ...process.env, VX_HANDED_OFF: process.execPath },
150
+ })
151
+ for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP'] as const) {
152
+ process.on(sig, () => {
153
+ if (!inForeground()) child.kill(sig)
154
+ })
155
+ }
156
+ const code = await child.exited
157
+ const { constants } = await import('node:os')
158
+ process.exit(child.signalCode === null ? code : 128 + (constants.signals[child.signalCode] ?? 1))
159
+ }
160
+
161
+ function inForeground(): boolean {
162
+ try {
163
+ const stat = Bun.spawnSync(['ps', '-o', 'pgid=,tpgid=', '-p', String(process.pid)])
164
+ const [pgid, tpgid] = stat.stdout.toString().trim().split(/\s+/)
165
+ return pgid !== undefined && pgid === tpgid
166
+ } catch {
167
+ return false
168
+ }
169
+ }