@vzn/vx 0.0.579 → 0.0.580

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vzn/vx",
3
- "version": "0.0.579",
3
+ "version": "0.0.580",
4
4
  "description": "The fastest task runner and build cache for JavaScript monorepos.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -21,10 +21,10 @@
21
21
  "node": ">=18"
22
22
  },
23
23
  "optionalDependencies": {
24
- "@vzn/vx-linux-x64": "0.0.579",
25
- "@vzn/vx-linux-arm64": "0.0.579",
26
- "@vzn/vx-darwin-x64": "0.0.579",
27
- "@vzn/vx-darwin-arm64": "0.0.579"
24
+ "@vzn/vx-linux-x64": "0.0.580",
25
+ "@vzn/vx-linux-arm64": "0.0.580",
26
+ "@vzn/vx-darwin-x64": "0.0.580",
27
+ "@vzn/vx-darwin-arm64": "0.0.580"
28
28
  },
29
29
  "dependencies": {
30
30
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
package/src/config.ts CHANGED
@@ -70,6 +70,12 @@ export interface WorkspaceRules {
70
70
  * overlapping tasks with no edge are refused either way.
71
71
  */
72
72
  exclusiveOutputs?: boolean
73
+ /**
74
+ * Refuse a task whose input globs can match another task's declared
75
+ * outputs, so every key is derived before any task runs. Off, such a key
76
+ * waits for its producer (`caching.md` § Local restore tier).
77
+ */
78
+ upfrontKeys?: boolean
73
79
  }
74
80
 
75
81
  /**
@@ -26,6 +26,7 @@ export {
26
26
  isGroupTask,
27
27
  markSurfacedDeps,
28
28
  outputsOverlap,
29
+ outputTakenBack,
29
30
  splitTaskId,
30
31
  type TaskNode,
31
32
  undeclaredDepsError,
@@ -555,6 +555,7 @@ export function checkGraph(
555
555
  }
556
556
  detectCycle(nodes)
557
557
  detectOutputCollisions(nodes, workspaceRoot, rules?.exclusiveOutputs !== false)
558
+ if (rules?.upfrontKeys !== false) detectInputOverlaps(nodes, workspaceRoot)
558
559
  }
559
560
 
560
561
  /**
@@ -1107,6 +1108,136 @@ function collide(
1107
1108
  }
1108
1109
  }
1109
1110
 
1111
+ /**
1112
+ * Does the `!` set `negatives` take back every path the output glob
1113
+ * `output` can select? Each of the output's `asTrees` forms must be one of
1114
+ * the negatives' forms, sit inside a negative whole subtree (`covers`), or,
1115
+ * as a literal, be matched by one. Sound and no more: an output only partly
1116
+ * taken back answers false.
1117
+ */
1118
+ export function outputTakenBack(output: string, negatives: readonly string[]): boolean {
1119
+ if (negatives.length === 0) return false
1120
+ const neg = asTrees(negatives)
1121
+ return asTrees([output]).every((t) =>
1122
+ neg.some((n) => n === t || covers(n, t) || (isLiteralPattern(t) && taskGlob(n).match(t))),
1123
+ )
1124
+ }
1125
+
1126
+ /** One task's inputs (`reads`) or outputs in one namespace, for `detectInputOverlaps`. */
1127
+ interface Side {
1128
+ node: TaskNode
1129
+ globs: readonly string[]
1130
+ reads: boolean
1131
+ /** The project's root-relative dir for a rebased `files` output. */
1132
+ rel?: string
1133
+ }
1134
+
1135
+ /**
1136
+ * `rules.upfrontKeys` (X-54): refuse a task whose input globs can match
1137
+ * another task's declared outputs. Such a key reads bytes a producer writes
1138
+ * this run, so it is not known until the producer ran (`stable-keys.ts`
1139
+ * keeps it waiting); refused, every key reads only what no task writes and
1140
+ * can be derived before anything runs (M, 2026-10-07: "hash ahead of time,
1141
+ * not waterfall"). A dependency's own key already cascades through
1142
+ * `dependsOn`, so the bytes it wrote add nothing a key needs.
1143
+ *
1144
+ * Proven overlaps only (`outputsOverlap`), as for outputs: a pair the rule
1145
+ * cannot decide passes, and the stability gate still keeps it waiting. The
1146
+ * scope mirrors `detectOutputCollisions`: `inputs.files` against another
1147
+ * same-project task's `outputs.files`; root-anchored `inputs.workspaceFiles`
1148
+ * against every other task's `outputs.workspaceFiles` and, with the root
1149
+ * known, its `outputs.files` rebased to the root. A task's own outputs are
1150
+ * already subtracted from its inputs, and an output the reader's `!` entries
1151
+ * take back whole is no overlap. A keyed group is exempt: the default
1152
+ * `build` reads `**` of a config-less dependency and runs nothing, and its
1153
+ * key waits as before.
1154
+ */
1155
+ function detectInputOverlaps(nodes: Map<string, TaskNode>, workspaceRoot?: string): void {
1156
+ const byProject = new Map<string, Side[]>()
1157
+ const rooted: Side[] = []
1158
+ const filesWriters: TaskNode[] = []
1159
+ let projectReaders = false
1160
+ let rootReaders = false
1161
+ const bucket = (name: string): Side[] => {
1162
+ let list = byProject.get(name)
1163
+ if (list === undefined) byProject.set(name, (list = []))
1164
+ return list
1165
+ }
1166
+ for (const n of nodes.values()) {
1167
+ const cache = n.config.cache
1168
+ if (cache === undefined) continue
1169
+ const outFiles = splitNegations(cache.outputs.files).positive
1170
+ const outWs = splitNegations(cache.outputs.workspaceFiles ?? []).positive
1171
+ if (outFiles.length > 0) {
1172
+ bucket(n.projectName).push({ node: n, globs: outFiles, reads: false })
1173
+ filesWriters.push(n)
1174
+ }
1175
+ if (outWs.length > 0) rooted.push({ node: n, globs: outWs, reads: false })
1176
+ if (isGroupTask(n)) continue
1177
+ const inFiles = splitNegations(cache.inputs.files).positive
1178
+ const inWs = splitNegations(cache.inputs.workspaceFiles ?? []).positive
1179
+ if (inFiles.length > 0) {
1180
+ bucket(n.projectName).push({ node: n, globs: inFiles, reads: true })
1181
+ projectReaders = true
1182
+ }
1183
+ if (inWs.length > 0) {
1184
+ rooted.push({ node: n, globs: inWs, reads: true })
1185
+ rootReaders = true
1186
+ }
1187
+ }
1188
+ if (projectReaders) {
1189
+ for (const sides of byProject.values()) {
1190
+ if (sides.length < 2) continue
1191
+ for (const [i, j] of overlapCandidates(sides, (s) => s.globs)) {
1192
+ readsOutputs(sides[i]!, sides[j]!, 'files')
1193
+ }
1194
+ }
1195
+ }
1196
+ if (!rootReaders) return
1197
+ if (workspaceRoot !== undefined) {
1198
+ for (const node of filesWriters) {
1199
+ const rel = path.relative(workspaceRoot, node.projectDir).split(path.sep).join('/')
1200
+ const globs = splitNegations(node.config.cache!.outputs.files).positive.map((g) =>
1201
+ rel === '' ? g : `${rel}/${g}`,
1202
+ )
1203
+ rooted.push({ node, globs, reads: false, rel })
1204
+ }
1205
+ }
1206
+ for (const [i, j] of overlapCandidates(rooted, (s) => s.globs)) {
1207
+ readsOutputs(rooted[i]!, rooted[j]!, 'workspaceFiles')
1208
+ }
1209
+ }
1210
+
1211
+ function readsOutputs(x: Side, y: Side, field: 'files' | 'workspaceFiles'): void {
1212
+ if (x.reads === y.reads || x.node === y.node) return
1213
+ const [reader, writer] = x.reads ? [x, y] : [y, x]
1214
+ const cache = reader.node.config.cache!
1215
+ // What the reader's key never reads: its `!` entries and its own outputs.
1216
+ const takeBack = [
1217
+ ...splitNegations(field === 'files' ? cache.inputs.files : (cache.inputs.workspaceFiles ?? []))
1218
+ .negative,
1219
+ ...splitNegations(
1220
+ field === 'files' ? cache.outputs.files : (cache.outputs.workspaceFiles ?? []),
1221
+ ).positive,
1222
+ ]
1223
+ for (const go of writer.globs) {
1224
+ if (outputTakenBack(go, takeBack)) continue
1225
+ for (const gi of reader.globs) {
1226
+ if (!outputsOverlap(gi, go)) continue
1227
+ const shown =
1228
+ writer.rel === undefined || writer.rel === '' ? go : go.slice(writer.rel.length + 1)
1229
+ throw new UserError(
1230
+ `${reader.node.id} reads ${JSON.stringify(gi)} in cache.inputs.${field}, which matches ` +
1231
+ `${writer.node.id}'s output ${JSON.stringify(shown)} — a task's key must not read ` +
1232
+ `another task's outputs (the dependency's key already cascades through dependsOn). ` +
1233
+ `Exclude it: add ${JSON.stringify(`!${go}`)} to ${reader.node.id}'s ` +
1234
+ `cache.inputs.${field}, or set rules: { upfrontKeys: false } in vx.workspace.ts to ` +
1235
+ `let it wait for its producer.`,
1236
+ )
1237
+ }
1238
+ }
1239
+ }
1240
+
1110
1241
  function detectCycle(nodes: Map<string, TaskNode>): void {
1111
1242
  // Iterative DFS over dense-indexed colors. Recursion + `Map<string,
1112
1243
  // number>` worked, but deep workspaces (long chains of `dependsOn`)
@@ -11,7 +11,7 @@
11
11
  // fall back to lazy read-through in execute-task, which is always
12
12
  // correct. The check is conservative: when unsure, treat as unstable.
13
13
 
14
- import { isGroupTask, type TaskNode, type TaskOutcome } from '../graph/index.js'
14
+ import { isGroupTask, outputTakenBack, type TaskNode, type TaskOutcome } from '../graph/index.js'
15
15
  import type { CacheLayer, GitFilesCache } from '../cache/index.js'
16
16
  import { isLiteralPattern, normalizeGlob, relPosix } from '../util/index.js'
17
17
  import { commandWriteReach, mayWriteFingerprint, undeclaredWriteReach } from './sandbox-request.js'
@@ -458,7 +458,13 @@ export function dependsOnSiblingOutputs(
458
458
  const inputs = cache.inputs?.files
459
459
  const outputs = sameProject.outputsOf.get(node.projectName) ?? []
460
460
  if (inputs === undefined || outputs.length === 0) return true
461
- if (workspaceInputsReach(inputs, outputs.map(literalPrefix))) return true
461
+ // An output the reader's own `!` inputs take back whole is not in its
462
+ // key: `['**/*', '!dist/**']` beside a `dist/**` producer keys the same
463
+ // before and after it, which is what `rules.upfrontKeys` asks a config
464
+ // to write (X-54).
465
+ const back = inputs.filter((g) => g.startsWith('!')).map((g) => g.slice(1))
466
+ const reach = back.length === 0 ? outputs : outputs.filter((o) => !outputTakenBack(o, back))
467
+ if (reach.length > 0 && workspaceInputsReach(inputs, reach.map(literalPrefix))) return true
462
468
  // Nor where it WRITES inside another task's tree: restored ahead of
463
469
  // that producer, its restore raced the producer's clean and extract
464
470
  // (M-5; invariant 2 of docs/design/overlapping-outputs-2026-09.md).
@@ -58,12 +58,12 @@ const WORKSPACE_FIELDS = new Set([
58
58
  'plugins',
59
59
  ])
60
60
 
61
- const RULE_FIELDS = new Set(['exclusiveOutputs'])
61
+ const RULE_FIELDS = new Set(['exclusiveOutputs', 'upfrontKeys'])
62
62
 
63
63
  function validateRules(rules: unknown, configPath: string): void {
64
64
  const where = `${configPath}: \`rules\``
65
65
  if (rules === null || typeof rules !== 'object' || Array.isArray(rules)) {
66
- throw new UserError(`${where} must be { exclusiveOutputs?: boolean }`)
66
+ throw new UserError(`${where} must be { exclusiveOutputs?: boolean; upfrontKeys?: boolean }`)
67
67
  }
68
68
  assertKnownFields(rules as Record<string, unknown>, RULE_FIELDS, where)
69
69
  for (const [name, value] of Object.entries(rules)) {