@vzn/vx 0.0.638 → 0.0.640

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 (134) hide show
  1. package/README.md +1 -1
  2. package/docs/caching.md +152 -95
  3. package/docs/cli.md +225 -372
  4. package/docs/execution.md +22 -47
  5. package/docs/features.md +14 -16
  6. package/docs/patterns.md +6 -8
  7. package/docs/schema.md +268 -215
  8. package/docs/security.md +1 -1
  9. package/package.json +5 -5
  10. package/schemas/last.json +7 -4
  11. package/schemas/show.json +14 -14
  12. package/skills/vx/SKILL.md +10 -7
  13. package/src/cache/cache.ts +44 -11
  14. package/src/cache/file-hashes.ts +7 -12
  15. package/src/cache/fs-clock.ts +62 -0
  16. package/src/cache/git-inputs.ts +8 -2
  17. package/src/cache/index.ts +5 -2
  18. package/src/cache/inputs.ts +154 -83
  19. package/src/cache/key-fold.ts +23 -3
  20. package/src/cache/layer.ts +33 -18
  21. package/src/cache/layered-cache.ts +108 -315
  22. package/src/cache/linked-inputs.ts +87 -0
  23. package/src/cache/plugin-cache.ts +2 -10
  24. package/src/cache/remote-lane.ts +383 -0
  25. package/src/cache/run-history.ts +24 -8
  26. package/src/cache/run-store.ts +4 -4
  27. package/src/cache/schema.ts +6 -4
  28. package/src/cache/scoped-cache.ts +7 -0
  29. package/src/cli/completions.ts +2 -2
  30. package/src/cli/foreign-flags.ts +11 -23
  31. package/src/cli/help.ts +6 -31
  32. package/src/cli/info.ts +2 -1
  33. package/src/cli/init.ts +23 -11
  34. package/src/cli/last.ts +30 -4
  35. package/src/cli/plan-format.ts +5 -58
  36. package/src/cli/plugin-commands.ts +2 -2
  37. package/src/cli/plugin-templates.ts +2 -2
  38. package/src/cli/run.ts +23 -185
  39. package/src/cli/select.ts +16 -6
  40. package/src/cli/show.ts +15 -6
  41. package/src/cli/upgrade.ts +215 -55
  42. package/src/cli/watch-cycle.ts +2 -1
  43. package/src/cli/watch-filter.ts +22 -6
  44. package/src/cli/watch-judge.ts +49 -5
  45. package/src/cli/watch-set.ts +18 -8
  46. package/src/cli/watch.ts +103 -16
  47. package/src/cli/why.ts +9 -7
  48. package/src/config.ts +19 -21
  49. package/src/exec/executor.ts +5 -5
  50. package/src/exec/sandbox-runtime.ts +1 -1
  51. package/src/exec/sandbox-violations.ts +3 -1
  52. package/src/graph/dependency-spec.ts +2 -2
  53. package/src/graph/folds.ts +236 -0
  54. package/src/graph/index.ts +17 -2
  55. package/src/graph/priorities.ts +1 -1
  56. package/src/graph/scheduler.ts +45 -31
  57. package/src/graph/task-graph.ts +190 -58
  58. package/src/graph/task-status.ts +95 -0
  59. package/src/{orchestrator → history}/failure-mode.ts +1 -1
  60. package/src/{orchestrator → history}/history.ts +1 -1
  61. package/src/history/index.ts +48 -0
  62. package/src/{orchestrator → history}/metrics.ts +7 -20
  63. package/src/{orchestrator → history}/miss-reason.ts +1 -1
  64. package/src/index.ts +11 -29
  65. package/src/orchestrator/affected-tasks.ts +20 -19
  66. package/src/orchestrator/doctor.ts +1 -1
  67. package/src/orchestrator/download-policy.ts +16 -22
  68. package/src/orchestrator/excluded-keys.ts +4 -5
  69. package/src/orchestrator/execute-args.ts +80 -0
  70. package/src/orchestrator/execute-task.ts +50 -411
  71. package/src/orchestrator/fingerprint-watch.ts +2 -2
  72. package/src/orchestrator/index.ts +2 -73
  73. package/src/orchestrator/keyed-projects.ts +12 -51
  74. package/src/orchestrator/local-shortcircuit.ts +1 -1
  75. package/src/orchestrator/miss-save.ts +33 -3
  76. package/src/orchestrator/options.ts +1 -36
  77. package/src/orchestrator/output-host.ts +4 -3
  78. package/src/orchestrator/persistent-task.ts +321 -0
  79. package/src/orchestrator/persistent.ts +130 -3
  80. package/src/orchestrator/placement.ts +5 -5
  81. package/src/orchestrator/plan.ts +19 -6
  82. package/src/orchestrator/plugin-host.ts +129 -69
  83. package/src/orchestrator/plugin.ts +18 -8
  84. package/src/orchestrator/prepare.ts +40 -34
  85. package/src/orchestrator/projects.ts +7 -10
  86. package/src/orchestrator/remote-prefetch.ts +14 -1
  87. package/src/orchestrator/run-artifacts.ts +5 -177
  88. package/src/orchestrator/run-context.ts +55 -0
  89. package/src/orchestrator/run-failures.ts +15 -3
  90. package/src/orchestrator/run-records.ts +35 -3
  91. package/src/orchestrator/run-report.ts +151 -171
  92. package/src/orchestrator/run-settings.ts +1 -16
  93. package/src/orchestrator/run-steps.ts +57 -0
  94. package/src/orchestrator/run-stop.ts +153 -0
  95. package/src/orchestrator/run.ts +293 -604
  96. package/src/orchestrator/sandbox-fix.ts +1 -17
  97. package/src/orchestrator/sandbox-request.ts +2 -2
  98. package/src/orchestrator/signals.ts +14 -6
  99. package/src/orchestrator/stable-keys.ts +164 -27
  100. package/src/orchestrator/steps.ts +70 -0
  101. package/src/orchestrator/task-hash.ts +34 -25
  102. package/src/orchestrator/task-log-buffer.ts +1 -1
  103. package/src/orchestrator/task-process.ts +94 -0
  104. package/src/orchestrator/telemetry-host.ts +4 -4
  105. package/src/orchestrator/telemetry.ts +14 -86
  106. package/src/orchestrator/upstream.ts +4 -181
  107. package/src/{orchestrator → report}/events.ts +41 -8
  108. package/src/{orchestrator → report}/forecast.ts +35 -2
  109. package/src/{orchestrator → report}/framed-output.ts +40 -34
  110. package/src/report/index.ts +36 -0
  111. package/src/{orchestrator → report}/logger.ts +158 -20
  112. package/src/{orchestrator → report}/status-line.ts +88 -7
  113. package/src/{orchestrator → report}/summary.ts +126 -35
  114. package/src/report/tally.ts +83 -0
  115. package/src/settings.ts +12 -39
  116. package/src/util/colors.ts +44 -4
  117. package/src/util/git-ignored.ts +143 -0
  118. package/src/util/index.ts +19 -4
  119. package/src/util/paths.ts +1 -1
  120. package/src/util/report.ts +13 -5
  121. package/src/util/settle.ts +14 -39
  122. package/src/util/tail.ts +1 -1
  123. package/src/util/ui.ts +17 -6
  124. package/src/workspace/affected.ts +81 -2
  125. package/src/workspace/config-imports.ts +1 -1
  126. package/src/workspace/config-schema.ts +142 -143
  127. package/src/workspace/lockfile.ts +1 -1
  128. package/src/workspace/migration.ts +19 -3
  129. package/schemas/summary.json +0 -133
  130. package/src/cache/chained-cache.ts +0 -280
  131. package/src/orchestrator/tally.ts +0 -112
  132. /package/src/{orchestrator → report}/path-links.ts +0 -0
  133. /package/src/{orchestrator → report}/plain-output.ts +0 -0
  134. /package/src/{orchestrator → report}/terminal-signals.ts +0 -0
@@ -8,6 +8,15 @@ export {
8
8
  parseDependencySpec,
9
9
  type DependencySpec,
10
10
  } from './dependency-spec.js'
11
+ export {
12
+ type FoldCandidate,
13
+ foldedDeps,
14
+ isGroupTask,
15
+ keyedDeps,
16
+ refuseFiltersThroughGroups,
17
+ selectFoldedDeps,
18
+ } from './folds.js'
19
+ export { computeReverseDepCount } from './priorities.js'
11
20
  export {
12
21
  type ContinueMode,
13
22
  deadServerBehind,
@@ -16,8 +25,15 @@ export {
16
25
  RestoreDemoted,
17
26
  runGraph,
18
27
  type TaskOutcome,
19
- type TaskStatus,
20
28
  } from './scheduler.js'
29
+ export {
30
+ type CacheSource,
31
+ deriveCacheSource,
32
+ isCacheHit,
33
+ isPassStatus,
34
+ TASK_STATUSES,
35
+ type TaskStatus,
36
+ } from './task-status.js'
21
37
  export {
22
38
  buildTaskGraph,
23
39
  checkGraph,
@@ -25,7 +41,6 @@ export {
25
41
  excludeDependencies,
26
42
  expandRequested,
27
43
  type GraphRules,
28
- isGroupTask,
29
44
  markSurfacedDeps,
30
45
  outputsOverlap,
31
46
  outputTakenBack,
@@ -85,7 +85,7 @@ export function tieredReverseDepCount(
85
85
  * dependents only (`calculateReverseDeps` in
86
86
  * `packages/nx/src/tasks-runner/utils.ts`, read 2026-09-24).
87
87
  */
88
- export function computeReverseDepCount(nodes: Map<string, TaskNode>): Map<string, number> {
88
+ export function computeReverseDepCount(nodes: ReadonlyMap<string, TaskNode>): Map<string, number> {
89
89
  // Exact transitive-dependent COUNTS need closure SETS (diamonds
90
90
  // double-count under naive summing). Set-of-strings closures are
91
91
  // O(N²) entries and took 8.5s on a 1090-package, 100-layer repo;
@@ -12,7 +12,9 @@ import {
12
12
  tieredReverseDepCount,
13
13
  type ScheduleStrategy,
14
14
  } from './priorities.js'
15
- import { isGroupTask, type TaskNode } from './task-graph.js'
15
+ import { isGroupTask } from './folds.js'
16
+ import type { TaskNode } from './task-graph.js'
17
+ import type { TaskStatus } from './task-status.js'
16
18
 
17
19
  /**
18
20
  * Thrown by `execute` for a restore-tier task that turned out to have
@@ -29,19 +31,6 @@ export class RestoreDemoted extends Error {
29
31
  }
30
32
  }
31
33
 
32
- export type TaskStatus =
33
- | 'success'
34
- | 'cache-hit'
35
- | 'cache-hit-remote'
36
- | 'failed'
37
- | 'skipped'
38
- // A child killed by a shutdown signal (SIGINT/SIGTERM — e.g. Ctrl-C):
39
- // the task never finished on its own terms, so it reverts to aborted —
40
- // excluded from the tally and the run history. It still PROPAGATES like
41
- // a failure (see `willSkip`): the child died mid-write, so its outputs
42
- // are partial.
43
- | 'aborted'
44
-
45
34
  /** What moved in a missed task's key since the last entry saved for it. */
46
35
  export interface InputChanges {
47
36
  /** How many components changed, appeared or went. 0: the same key, its entry gone. */
@@ -79,8 +68,8 @@ export interface TaskOutcome {
79
68
  * How long an `admit` policy held this task after it was ready with a
80
69
  * free worker — the wait the plugin, not the count gate, imposed. Set
81
70
  * only when a policy refused it at least once (so a run with no policy
82
- * carries the field on no outcome); `--summarize` rows, the event
83
- * stream and the summary footer show it.
71
+ * carries the field on no outcome); the event stream and the summary
72
+ * footer show it.
84
73
  */
85
74
  admissionHeldMs?: number
86
75
  /**
@@ -126,6 +115,12 @@ export interface TaskOutcome {
126
115
  * so a dependant that folds `hash` saves nothing either (A-12).
127
116
  */
128
117
  unkeyed?: true
118
+ /**
119
+ * This task's key folds a task with no `inputs` (or it is one): no run
120
+ * can derive that key again, so it neither read nor saved an entry, and a
121
+ * dependant that folds `hash` does neither.
122
+ */
123
+ keyless?: true
129
124
  /**
130
125
  * The run's cache policy read and wrote nothing (`--no-cache`,
131
126
  * `--cache-policy=local:`): no cache answered for this task, so a run of it is
@@ -136,7 +131,7 @@ export interface TaskOutcome {
136
131
  * On a `skipped` outcome: the task at the ROOT of what blocked it — the
137
132
  * failed (or aborted) upstream, followed through any chain of skips
138
133
  * between. Absent when the skip was fail-fast's (no upstream failed).
139
- * The footer, `--summarize`, the telemetry record and the run row all
134
+ * The footer, the telemetry record and the run row all
140
135
  * read this one field (item 267).
141
136
  */
142
137
  blockedBy?: string
@@ -231,7 +226,7 @@ export interface TaskOutcome {
231
226
  /** Credential paths denied and left out of the grants: an agent applies the fix as given. */
232
227
  withheld?: string[]
233
228
  /**
234
- * Reads of the project the task's `cache.inputs.files` does not match:
229
+ * Reads of the project the task's `inputs.files` does not match:
235
230
  * add them there too when the output depends on them, or a hit replays
236
231
  * output built from an older copy. Cached tasks only.
237
232
  */
@@ -313,6 +308,12 @@ export interface ScheduleOptions {
313
308
  * heuristic (the default and historically the only behavior).
314
309
  */
315
310
  priorities?: ReadonlyMap<string, number>
311
+ /**
312
+ * Called once as the run starts with a function that replaces
313
+ * `priorities`: the ready queues re-rank at once, over the same baseline.
314
+ * A `schedule` plugin's later, better weights land through it.
315
+ */
316
+ reprioritize?: (apply: (priorities: ReadonlyMap<string, number>) => void) => void
316
317
  /**
317
318
  * The baseline ranking under `priorities` (`schedule` in .vx/workspace.ts).
318
319
  * Undefined or `most-work` is the reverse-deps count above.
@@ -383,10 +384,28 @@ class ReadyHeap {
383
384
  // was two per heap level on every push and pop.
384
385
  private readonly prio: number[] = []
385
386
  private next = 0
386
- constructor(private readonly priority: ReadonlyMap<string, number>) {}
387
+ constructor(private priority: ReadonlyMap<string, number>) {}
387
388
  get size(): number {
388
389
  return this.ids.length
389
390
  }
391
+ /** Re-rank every queued id under `priority`, and every later push. */
392
+ reprioritize(priority: ReadonlyMap<string, number>): void {
393
+ this.priority = priority
394
+ for (let i = 0; i < this.ids.length; i++) this.prio[i] = priority.get(this.ids[i]!) ?? 0
395
+ for (let i = (this.ids.length >> 1) - 1; i >= 0; i--) this.down(i)
396
+ }
397
+ private down(i: number): void {
398
+ for (;;) {
399
+ const l = 2 * i + 1
400
+ const r = 2 * i + 2
401
+ let best = i
402
+ if (l < this.ids.length && this.higher(l, best)) best = l
403
+ if (r < this.ids.length && this.higher(r, best)) best = r
404
+ if (best === i) return
405
+ this.swap(i, best)
406
+ i = best
407
+ }
408
+ }
390
409
  /** True if slot i outranks slot j (higher priority, or equal priority + earlier seq). */
391
410
  private higher(i: number, j: number): boolean {
392
411
  const pi = this.prio[i]!
@@ -446,17 +465,7 @@ class ReadyHeap {
446
465
  this.ids[0] = lastId
447
466
  this.seq[0] = lastSeq
448
467
  this.prio[0] = lastPrio
449
- let i = 0
450
- for (;;) {
451
- const l = 2 * i + 1
452
- const r = 2 * i + 2
453
- let best = i
454
- if (l < this.ids.length && this.higher(l, best)) best = l
455
- if (r < this.ids.length && this.higher(r, best)) best = r
456
- if (best === i) break
457
- this.swap(i, best)
458
- i = best
459
- }
468
+ this.down(0)
460
469
  }
461
470
  return top
462
471
  }
@@ -565,6 +574,11 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
565
574
  let settledIdle = 0
566
575
  const execReady = new ReadyHeap(priority)
567
576
  const restoreReady = new ReadyHeap(priority)
577
+ options.reprioritize?.((weights) => {
578
+ const merged = mergePriorities(baseline, weights)
579
+ execReady.reprioritize(merged)
580
+ restoreReady.reprioritize(merged)
581
+ })
568
582
  // A restore-tier task is dep-independent (a stable hit's restore needs
569
583
  // none of its deps' output), so it's ready immediately; its `pending`
570
584
  // decrements still happen (in finishOne) but never re-enqueue it.
@@ -1020,7 +1034,7 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
1020
1034
  return
1021
1035
  }
1022
1036
  // A UserError is a config/input failure (e.g. a failed
1023
- // `cache.inputs.runtime` command), not a vx bug — report it
1037
+ // `inputs.runtime` command), not a vx bug — report it
1024
1038
  // plainly, never as an "internal error".
1025
1039
  let line: string
1026
1040
  // Out of descriptors is the environment's limit too: a stack
@@ -1,7 +1,10 @@
1
1
  import type { ProjectConfig, TaskConfig, WorkspaceRules } from '../config.js'
2
2
  import {
3
3
  asTrees,
4
+ checkIgnoreSync,
5
+ ignoreProbes,
4
6
  isLiteralPattern,
7
+ listedSync,
5
8
  normalizeGlob,
6
9
  relPosix,
7
10
  splitNegations,
@@ -9,6 +12,7 @@ import {
9
12
  taskGlob,
10
13
  refusal,
11
14
  UserError,
15
+ wholeIgnored,
12
16
  wholeSubtreePrefixes,
13
17
  } from '../util/index.js'
14
18
  import type { PackageGraph, ProjectEntry } from '../workspace/index.js'
@@ -19,6 +23,7 @@ import {
19
23
  parseDependencySpec,
20
24
  type DependencySpec,
21
25
  } from './dependency-spec.js'
26
+ import { foldedDeps, isGroupTask } from './folds.js'
22
27
  import type { TaskOutcome } from './scheduler.js'
23
28
 
24
29
  // Re-exported so existing importers keep working while the type's home
@@ -96,23 +101,9 @@ export function taskId(project: string, task: string): string {
96
101
  // graph is where every reader of a task id looks for it.
97
102
  export { splitTaskId } from '../util/index.js'
98
103
 
99
- /**
100
- * A task is a "group" if it has no `exec` — it exists only to chain
101
- * `dependsOn` (an umbrella for `vx run ci`). Group tasks:
102
- * - never spawn a process,
103
- * - never read/write the cache (it's a config error to declare one),
104
- * - never appear in the run summary or `runs` analytics table,
105
- * - render no framed block in the live output.
106
- * Six call sites used to repeat `node.config.exec === undefined`;
107
- * centralising the predicate prevents that check from drifting.
108
- */
109
- export function isGroupTask(node: TaskNode): boolean {
110
- return node.config.exec === undefined
111
- }
112
-
113
- /** A keyed group: the default `build` (orchestrator/projects.ts), or a config's group with `cache`. */
104
+ /** A keyed group: the default `build` (orchestrator/projects.ts), or a config's group with `inputs`. */
114
105
  function isKeyedGroup(task: TaskConfig): boolean {
115
- return task.exec === undefined && task.cache !== undefined
106
+ return task.exec === undefined && task.inputs !== undefined
116
107
  }
117
108
 
118
109
  /**
@@ -507,7 +498,7 @@ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode
507
498
 
508
499
  // dependsOn is about which tasks to ADD to the graph, not which
509
500
  // to filter. BARE wildcards ("*"/"^*" = "everything upstream") and
510
- // negation aren't meaningful here — they're cache.inputs.tasks
501
+ // negation aren't meaningful here — they're inputs.tasks
511
502
  // operations. PARTIAL patterns (`build.*`, `^build.*`) are legal:
512
503
  // they name a namespace of tasks to add (Nx 19.5 parity).
513
504
  if (spec.kind === 'wildcardSelf' || spec.kind === 'wildcardDeps') {
@@ -584,7 +575,7 @@ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode
584
575
  // task behind it would fold no upstream either.
585
576
  if (!packageGraph.edgeSource) {
586
577
  throw new UserError(
587
- `${id}: ^${spec.task} needs project edges; add pnpm()/bun()/npm()/yarn() or a plugin that returns \`deps\``,
578
+ `${id}: ^${spec.task} needs project edges; add pnpm()/bun()/npm()/yarn() and install so its lockfile exists, or a plugin that returns \`deps\``,
588
579
  'VX_E_DEPENDS_ON',
589
580
  )
590
581
  }
@@ -785,7 +776,7 @@ export function checkGraph(
785
776
  // Under a lock an exclusive graph has no overlap to mark; a non-exclusive
786
777
  // one still needs the addition marks the walk derives.
787
778
  if (!locked || !exclusive) detectOutputCollisions(nodes, workspaceRoot, exclusive)
788
- if (rules?.upfrontKeys !== false && !locked) detectInputOverlaps(nodes, workspaceRoot)
779
+ if (!locked) detectInputOverlaps(nodes, workspaceRoot)
789
780
  }
790
781
 
791
782
  /**
@@ -1128,6 +1119,15 @@ function detectOutputCollisions(
1128
1119
  }
1129
1120
  }
1130
1121
 
1122
+ /**
1123
+ * What a task's `!` output entries take back (A-44). A glob of the other
1124
+ * task's that one takes back whole is a path this task never cleans or
1125
+ * captures, so the pair cannot collide there.
1126
+ */
1127
+ function takenBackOf(n: TaskNode, field: 'files' | 'workspaceFiles'): string[] {
1128
+ return splitNegations(n.config.cache?.outputs[field] ?? []).negative
1129
+ }
1130
+
1131
1131
  /**
1132
1132
  * `collide` for a root-anchored output against another project's `files`
1133
1133
  * output rebased to the root. With an edge the dependant adds to its
@@ -1146,9 +1146,12 @@ function collideAcross(
1146
1146
  exclusive: boolean,
1147
1147
  ): void {
1148
1148
  if (neverWritesLocally(ws) || neverWritesLocally(files)) return
1149
+ const wsBack = takenBackOf(ws, 'workspaceFiles')
1150
+ const filesBack = takenBackOf(files, 'files').map((g) => (rel === '' ? g : `${rel}/${g}`))
1149
1151
  for (const ga of wsGlobs) {
1150
1152
  for (const gb of rebased) {
1151
- if (!outputsOverlap(ga, gb)) continue
1153
+ if (!outputsOverlap(ga, gb) || outputTakenBack(gb, wsBack) || outputTakenBack(ga, filesBack))
1154
+ continue
1152
1155
  const head =
1153
1156
  `${ws.id} declares the output ${JSON.stringify(ga)} in cache.outputs.workspaceFiles, ` +
1154
1157
  `inside ${files.id}'s ${JSON.stringify(gb.slice(rel === '' ? 0 : rel.length + 1))} in ` +
@@ -1405,9 +1408,12 @@ function collide(
1405
1408
  exclusive: boolean,
1406
1409
  ): void {
1407
1410
  if (neverWritesLocally(a) || neverWritesLocally(b)) return
1411
+ const aBack = takenBackOf(a, field)
1412
+ const bBack = takenBackOf(b, field)
1408
1413
  for (const ga of aGlobs ?? []) {
1409
1414
  for (const gb of bGlobs ?? []) {
1410
- if (!outputsOverlap(ga, gb)) continue
1415
+ if (!outputsOverlap(ga, gb) || outputTakenBack(gb, aBack) || outputTakenBack(ga, bBack))
1416
+ continue
1411
1417
  const head =
1412
1418
  `${a.id} and ${b.id} both declare the output ${JSON.stringify(ga)}` +
1413
1419
  (ga === gb ? '' : ` / ${JSON.stringify(gb)}`) +
@@ -1478,10 +1484,11 @@ export function outputTakenBack(output: string, negatives: readonly string[]): b
1478
1484
  */
1479
1485
  function refuseSelfClean(node: TaskNode): void {
1480
1486
  const cache = node.config.cache
1481
- if (cache === undefined) return
1487
+ const declared = node.config.inputs
1488
+ if (cache === undefined || declared === undefined) return
1482
1489
  for (const field of ['files', 'workspaceFiles'] as const) {
1483
- if (!coversAny(cache.inputs[field], cache.outputs[field])) continue
1484
- const inputs = splitNegations(cache.inputs[field] ?? [])
1490
+ if (!coversAny(declared[field], cache.outputs[field])) continue
1491
+ const inputs = splitNegations(declared[field] ?? [])
1485
1492
  const out = splitNegations(cache.outputs[field] ?? [])
1486
1493
  const outputs = out.positive
1487
1494
  if (outputs.length === 0) continue
@@ -1500,7 +1507,7 @@ function refuseSelfClean(node: TaskNode): void {
1500
1507
  if (outputTakenBack(gi, out.negative)) continue
1501
1508
  if (!outputTakenBack(gi, near)) continue
1502
1509
  throw new UserError(
1503
- `${node.id}: every file ${JSON.stringify(gi)} in cache.inputs.${field} selects is also ` +
1510
+ `${node.id}: every file ${JSON.stringify(gi)} in inputs.${field} selects is also ` +
1504
1511
  `its own output — vx removes a task's outputs before it runs, so the task would ` +
1505
1512
  `delete its own sources. A task that rewrites files in place (a formatter) declares ` +
1506
1513
  `no outputs.`,
@@ -1550,16 +1557,19 @@ interface Side {
1550
1557
  reads: boolean
1551
1558
  /** The project's root-relative dir for rebased `files` globs. */
1552
1559
  rel?: string
1560
+ /** The root-relative dir project-relative `globs` sit in, for git. */
1561
+ anchor?: string | undefined
1553
1562
  }
1554
1563
 
1555
1564
  /**
1556
- * `run.rules.upfrontKeys` (X-54): refuse a task whose input globs can match
1557
- * another task's declared outputs. Such a key reads bytes a producer writes
1558
- * this run, so it is not known until the producer ran (`stable-keys.ts`
1559
- * keeps it waiting); refused, every key reads only what no task writes and
1560
- * can be derived before anything runs (M, 2026-10-07: "hash ahead of time,
1561
- * not waterfall"). A dependency's own key already cascades through
1562
- * `dependsOn`, so the bytes it wrote add nothing a key needs.
1565
+ * Refuse a task whose input globs can match another task's declared
1566
+ * outputs (X-54; no opt-out, owner 2026-10-16). Such a key reads bytes a
1567
+ * producer writes this run, so it is not known until the producer ran;
1568
+ * refused, every key reads only what no task writes and can be derived
1569
+ * before anything runs (M, 2026-10-07: "hash ahead of time, not
1570
+ * waterfall"). A dependency's own key already cascades through
1571
+ * `dependsOn`, so the bytes it wrote add nothing a key needs; the one
1572
+ * exception is `refuseOverlaps`'s unfolded dependency.
1563
1573
  *
1564
1574
  * Proven overlaps only (`outputsOverlap`), as for outputs: a pair the rule
1565
1575
  * cannot decide passes, and the stability gate still keeps it waiting. The
@@ -1583,6 +1593,7 @@ function detectInputOverlaps(nodes: Map<string, TaskNode>, workspaceRoot?: strin
1583
1593
  let projectReaders = false
1584
1594
  let rootReaders = false
1585
1595
  const memo: TakeBackMemo = { bySide: new Map(), byList: new Map() }
1596
+ const found: Found = new Map()
1586
1597
  const bucket = (name: string): Side[] => {
1587
1598
  let list = byProject.get(name)
1588
1599
  if (list === undefined) byProject.set(name, (list = []))
@@ -1590,11 +1601,13 @@ function detectInputOverlaps(nodes: Map<string, TaskNode>, workspaceRoot?: strin
1590
1601
  }
1591
1602
  for (const n of nodes.values()) {
1592
1603
  const cache = n.config.cache
1593
- if (cache === undefined) continue
1594
- const outFiles = positives(cache.outputs.files)
1595
- const outWs = positives(cache.outputs.workspaceFiles ?? [])
1604
+ const inputs = n.config.inputs
1605
+ if (inputs === undefined) continue
1606
+ const outFiles = positives(cache?.outputs.files ?? [])
1607
+ const outWs = positives(cache?.outputs.workspaceFiles ?? [])
1596
1608
  if (outFiles.length > 0) {
1597
- bucket(n.projectName).push({ node: n, globs: outFiles, reads: false })
1609
+ const anchor = workspaceRoot === undefined ? undefined : relPosix(workspaceRoot, n.projectDir)
1610
+ bucket(n.projectName).push({ node: n, globs: outFiles, reads: false, anchor })
1598
1611
  filesWriters.push(n)
1599
1612
  }
1600
1613
  if (outWs.length > 0) {
@@ -1603,10 +1616,11 @@ function detectInputOverlaps(nodes: Map<string, TaskNode>, workspaceRoot?: strin
1603
1616
  wsWriters.push(side)
1604
1617
  }
1605
1618
  if (isGroupTask(n)) continue
1606
- const inFiles = positives(cache.inputs.files)
1607
- const inWs = positives(cache.inputs.workspaceFiles ?? [])
1619
+ const inFiles = positives(inputs.files)
1620
+ const inWs = positives(inputs.workspaceFiles ?? [])
1608
1621
  if (inFiles.length > 0) {
1609
- bucket(n.projectName).push({ node: n, globs: inFiles, reads: true })
1622
+ const anchor = workspaceRoot === undefined ? undefined : relPosix(workspaceRoot, n.projectDir)
1623
+ bucket(n.projectName).push({ node: n, globs: inFiles, reads: true, anchor })
1610
1624
  filesReaders.push(n)
1611
1625
  projectReaders = true
1612
1626
  }
@@ -1623,7 +1637,7 @@ function detectInputOverlaps(nodes: Map<string, TaskNode>, workspaceRoot?: strin
1623
1637
  (s) => s.globs,
1624
1638
  (s) => s.reads,
1625
1639
  )) {
1626
- readsOutputs(memo, sides[i]!, sides[j]!, 'files')
1640
+ readsOutputs(memo, found, sides[i]!, sides[j]!, 'files')
1627
1641
  }
1628
1642
  }
1629
1643
  }
@@ -1631,7 +1645,7 @@ function detectInputOverlaps(nodes: Map<string, TaskNode>, workspaceRoot?: strin
1631
1645
  const sides = [...wsWriters]
1632
1646
  for (const node of filesReaders) {
1633
1647
  const rel = relPosix(workspaceRoot, node.projectDir)
1634
- const globs = positives(node.config.cache!.inputs.files).map((g) =>
1648
+ const globs = positives(node.config.inputs!.files).map((g) =>
1635
1649
  rel === '' ? g : `${rel}/${g}`,
1636
1650
  )
1637
1651
  sides.push({ node, globs, reads: true, rel })
@@ -1641,10 +1655,20 @@ function detectInputOverlaps(nodes: Map<string, TaskNode>, workspaceRoot?: strin
1641
1655
  (s) => s.globs,
1642
1656
  (s) => s.reads,
1643
1657
  )) {
1644
- readsOutputs(memo, sides[i]!, sides[j]!, 'files')
1658
+ readsOutputs(memo, found, sides[i]!, sides[j]!, 'files')
1645
1659
  }
1646
1660
  }
1647
- if (!rootReaders) return
1661
+ if (rootReaders) readsRootedOutputs(memo, found, rooted, filesWriters, workspaceRoot)
1662
+ refuseOverlaps(found, nodes, workspaceRoot)
1663
+ }
1664
+
1665
+ function readsRootedOutputs(
1666
+ memo: TakeBackMemo,
1667
+ found: Found,
1668
+ rooted: Side[],
1669
+ filesWriters: readonly TaskNode[],
1670
+ workspaceRoot: string | undefined,
1671
+ ): void {
1648
1672
  if (workspaceRoot !== undefined) {
1649
1673
  for (const node of filesWriters) {
1650
1674
  const rel = relPosix(workspaceRoot, node.projectDir)
@@ -1659,10 +1683,104 @@ function detectInputOverlaps(nodes: Map<string, TaskNode>, workspaceRoot?: strin
1659
1683
  (s) => s.globs,
1660
1684
  (s) => s.reads,
1661
1685
  )) {
1662
- readsOutputs(memo, rooted[i]!, rooted[j]!, 'workspaceFiles')
1686
+ readsOutputs(memo, found, rooted[i]!, rooted[j]!, 'workspaceFiles')
1663
1687
  }
1664
1688
  }
1665
1689
 
1690
+ /** One reader of another task's output: the reader's glob's fixed prefix, root-relative. */
1691
+ interface Overlap {
1692
+ reader: TaskNode
1693
+ writer: TaskNode
1694
+ prefix: string
1695
+ refuse: () => UserError
1696
+ }
1697
+
1698
+ /** Each overlapped output, root-relative, and every reader of it. */
1699
+ type Found = Map<string, Overlap[]>
1700
+
1701
+ /**
1702
+ * Which overlaps stand. A reader may read the outputs of a task in its
1703
+ * `dependsOn` closure whose key its own does not fold (it sets
1704
+ * `inputs.tasks` and leaves the producer out, also through the tasks it
1705
+ * folds): its key waits for the producer and hashes the bytes (owner,
1706
+ * 2026-10-16). Otherwise an output git ignores whole is no overlap (M,
1707
+ * 2026-10-15), a key reading only what git lists, unless the reader's
1708
+ * glob starts in an ignored path (`dist/**`): naming it is asking for it.
1709
+ * Git is asked once, for every output and prefix together; one it cannot
1710
+ * speak for is refused. The answer holds for the lock; a run's stability
1711
+ * gate asks git again for itself.
1712
+ */
1713
+ function refuseOverlaps(
1714
+ found: Found,
1715
+ nodes: ReadonlyMap<string, TaskNode>,
1716
+ workspaceRoot: string | undefined,
1717
+ ): void {
1718
+ if (found.size === 0) return
1719
+ const reads = dependencyReads(nodes)
1720
+ const left = new Map<string, Overlap[]>()
1721
+ for (const [g, list] of found) {
1722
+ const refused = list.filter((o) => !reads(o.reader, o.writer))
1723
+ if (refused.length > 0) left.set(g, refused)
1724
+ }
1725
+ if (left.size === 0) return
1726
+ if (workspaceRoot === undefined) throw left.values().next().value![0]!.refuse()
1727
+ const asks = [...left.keys()].map(ignoreProbes)
1728
+ const prefixes = new Set<string>()
1729
+ for (const list of left.values()) {
1730
+ for (const o of list) if (o.prefix !== '') prefixes.add(o.prefix).add(`${o.prefix}/`)
1731
+ }
1732
+ const ignored = checkIgnoreSync(workspaceRoot, [
1733
+ ...asks.flatMap((a) => a?.paths ?? []),
1734
+ ...prefixes,
1735
+ ])
1736
+ let listed: string[] | null | undefined
1737
+ const pass = wholeIgnored(left.keys(), ignored, (g) => {
1738
+ listed ??= listedSync(workspaceRoot, [
1739
+ ...new Set(asks.flatMap((a) => (a?.deep === true ? [a.dir] : []))),
1740
+ ])
1741
+ return listed === null || listed.some((f) => taskGlob(g).match(f))
1742
+ })
1743
+ const named = (o: Overlap): boolean =>
1744
+ o.prefix !== '' && ignored !== null && (ignored.has(o.prefix) || ignored.has(`${o.prefix}/`))
1745
+ for (const [g, list] of left) {
1746
+ for (const o of list) if (!pass.has(g) || named(o)) throw o.refuse()
1747
+ }
1748
+ }
1749
+
1750
+ /**
1751
+ * Whether a reader may read a writer's outputs: the writer is in the
1752
+ * reader's `dependsOn` closure and not in its folded closure. Each closure
1753
+ * walked once per reader.
1754
+ */
1755
+ function dependencyReads(
1756
+ nodes: ReadonlyMap<string, TaskNode>,
1757
+ ): (reader: TaskNode, writer: TaskNode) => boolean {
1758
+ const nodeOf = (id: string): TaskNode => nodes.get(id)!
1759
+ const deps = new Map<string, Set<string>>()
1760
+ const folded = new Map<string, Set<string>>()
1761
+ const closure = (
1762
+ memo: Map<string, Set<string>>,
1763
+ root: TaskNode,
1764
+ next: (n: TaskNode) => Iterable<TaskNode>,
1765
+ ): Set<string> => {
1766
+ let out = memo.get(root.id)
1767
+ if (out !== undefined) return out
1768
+ out = new Set()
1769
+ const stack = [...next(root)]
1770
+ while (stack.length > 0) {
1771
+ const n = stack.pop()!
1772
+ if (out.has(n.id)) continue
1773
+ out.add(n.id)
1774
+ stack.push(...next(n))
1775
+ }
1776
+ memo.set(root.id, out)
1777
+ return out
1778
+ }
1779
+ return (reader, writer) =>
1780
+ closure(deps, reader, (n) => n.deps.flatMap((d) => nodes.get(d) ?? [])).has(writer.id) &&
1781
+ !closure(folded, reader, (n) => foldedDeps(n, nodeOf).map((c) => c.node)).has(writer.id)
1782
+ }
1783
+
1666
1784
  /**
1667
1785
  * Whether a reader's take-back list covers an output glob, asked once per
1668
1786
  * list and glob: a Turbo-mapped repo gives two thousand readers one
@@ -1683,16 +1801,15 @@ function takenBack(
1683
1801
  ): boolean {
1684
1802
  let tb = memo.bySide.get(reader)
1685
1803
  if (tb === undefined) {
1686
- const cache = reader.node.config.cache!
1804
+ const inputs = reader.node.config.inputs!
1805
+ const cache = reader.node.config.cache
1687
1806
  // A reader rebased to the root (X-135) speaks in its project's terms.
1688
1807
  const at = reader.rel === undefined || reader.rel === '' ? '' : `${reader.rel}/`
1689
1808
  // What the reader's key never reads: its `!` entries and its own outputs.
1690
1809
  const list = [
1810
+ ...splitNegations(field === 'files' ? inputs.files : (inputs.workspaceFiles ?? [])).negative,
1691
1811
  ...splitNegations(
1692
- field === 'files' ? cache.inputs.files : (cache.inputs.workspaceFiles ?? []),
1693
- ).negative,
1694
- ...splitNegations(
1695
- field === 'files' ? cache.outputs.files : (cache.outputs.workspaceFiles ?? []),
1812
+ field === 'files' ? (cache?.outputs.files ?? []) : (cache?.outputs.workspaceFiles ?? []),
1696
1813
  ).positive,
1697
1814
  ].map((g) => at + g)
1698
1815
  const key = list.join('\0')
@@ -1707,6 +1824,7 @@ function takenBack(
1707
1824
 
1708
1825
  function readsOutputs(
1709
1826
  memo: TakeBackMemo,
1827
+ found: Found,
1710
1828
  x: Side,
1711
1829
  y: Side,
1712
1830
  field: 'files' | 'workspaceFiles',
@@ -1714,22 +1832,36 @@ function readsOutputs(
1714
1832
  if (x.reads === y.reads || x.node === y.node) return
1715
1833
  const [reader, writer] = x.reads ? [x, y] : [y, x]
1716
1834
  const at = reader.rel === undefined || reader.rel === '' ? '' : `${reader.rel}/`
1835
+ const readerAt = reader.anchor === undefined || reader.anchor === '' ? '' : `${reader.anchor}/`
1717
1836
  for (const go of writer.globs) {
1837
+ const rooted =
1838
+ writer.anchor === undefined || writer.anchor === '' ? go : `${writer.anchor}/${go}`
1839
+ let list = found.get(rooted)
1840
+ if (list?.some((o) => o.reader === reader.node) === true) continue
1718
1841
  if (takenBack(memo, reader, field, go)) continue
1719
1842
  for (const gi of reader.globs) {
1720
1843
  if (!outputsOverlap(gi, go)) continue
1721
1844
  const shown =
1722
1845
  writer.rel === undefined || writer.rel === '' ? go : go.slice(writer.rel.length + 1)
1723
1846
  const out = at !== '' && go.startsWith(at) ? go.slice(at.length) : go
1724
- throw new UserError(
1725
- `${reader.node.id} reads ${JSON.stringify(gi.slice(at.length))} in cache.inputs.${field}, which matches ` +
1726
- `${writer.node.id}'s output ${JSON.stringify(shown)} — a task's key must not read ` +
1727
- `another task's outputs (the dependency's key already cascades through dependsOn). ` +
1728
- `Exclude it: add ${JSON.stringify(`!${out}`)} to ${reader.node.id}'s ` +
1729
- `cache.inputs.${field}, or set run.rules.upfrontKeys: false in .vx/workspace.ts to ` +
1730
- `let it wait for its producer.`,
1731
- 'VX_E_INPUT_IS_OUTPUT',
1732
- )
1847
+ const prefix = staticPrefix(readerAt + gi)
1848
+ if (list === undefined) found.set(rooted, (list = []))
1849
+ list.push({
1850
+ reader: reader.node,
1851
+ writer: writer.node,
1852
+ prefix: prefix === '.' ? '' : prefix,
1853
+ refuse: () =>
1854
+ new UserError(
1855
+ `${reader.node.id} reads ${JSON.stringify(gi.slice(at.length))} in inputs.${field}, which matches ` +
1856
+ `${writer.node.id}'s output ${JSON.stringify(shown)} — a task's key must not read ` +
1857
+ `the outputs of a task it does not depend on, or whose key it already folds. ` +
1858
+ `Exclude it: add ${JSON.stringify(`!${out}`)} to ${reader.node.id}'s ` +
1859
+ `inputs.${field}, or depend on ${writer.node.id} and leave it out of ` +
1860
+ `${reader.node.id}'s inputs.tasks, so its key hashes those bytes instead.`,
1861
+ 'VX_E_INPUT_IS_OUTPUT',
1862
+ ),
1863
+ })
1864
+ break
1733
1865
  }
1734
1866
  }
1735
1867
  }