@vzn/vx 0.0.579 → 0.0.581

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.581",
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.581",
25
+ "@vzn/vx-linux-arm64": "0.0.581",
26
+ "@vzn/vx-darwin-x64": "0.0.581",
27
+ "@vzn/vx-darwin-arm64": "0.0.581"
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,
@@ -553,8 +553,10 @@ export function checkGraph(
553
553
  delete node.addsToOutputsOf
554
554
  delete node.outputsAddedToBy
555
555
  }
556
+ for (const node of nodes.values()) refuseSelfClean(node)
556
557
  detectCycle(nodes)
557
558
  detectOutputCollisions(nodes, workspaceRoot, rules?.exclusiveOutputs !== false)
559
+ if (rules?.upfrontKeys !== false) detectInputOverlaps(nodes, workspaceRoot)
558
560
  }
559
561
 
560
562
  /**
@@ -1107,6 +1109,174 @@ function collide(
1107
1109
  }
1108
1110
  }
1109
1111
 
1112
+ /**
1113
+ * Does the `!` set `negatives` take back every path the output glob
1114
+ * `output` can select? Each of the output's `asTrees` forms must be one of
1115
+ * the negatives' forms, sit inside a negative whole subtree (`covers`), or,
1116
+ * as a literal, be matched by one. Sound and no more: an output only partly
1117
+ * taken back answers false.
1118
+ */
1119
+ export function outputTakenBack(output: string, negatives: readonly string[]): boolean {
1120
+ if (negatives.length === 0) return false
1121
+ const neg = asTrees(negatives)
1122
+ return asTrees([output]).every((t) =>
1123
+ neg.some((n) => n === t || covers(n, t) || (isLiteralPattern(t) && taskGlob(n).match(t))),
1124
+ )
1125
+ }
1126
+
1127
+ /**
1128
+ * A task whose input entry its own outputs take back whole cleans its own
1129
+ * sources: vx removes a task's outputs before it runs, so a formatter
1130
+ * declaring `src/**` as both deleted every committed file under `src`,
1131
+ * and its key read nothing (hunt 8). Always refused, whatever the rules.
1132
+ */
1133
+ function refuseSelfClean(node: TaskNode): void {
1134
+ const cache = node.config.cache
1135
+ if (cache === undefined) return
1136
+ for (const field of ['files', 'workspaceFiles'] as const) {
1137
+ const inputs = splitNegations(cache.inputs[field] ?? [])
1138
+ const out = splitNegations(cache.outputs[field] ?? [])
1139
+ const outputs = out.positive
1140
+ if (outputs.length === 0) continue
1141
+ const roots = outputs.map((g) => rootless(staticPrefix(g)))
1142
+ for (const gi of inputs.positive) {
1143
+ // Only an output rooted at or above the input's prefix can cover it;
1144
+ // the prefix test keeps the glob matchers off the common disjoint pair.
1145
+ const pi = rootless(staticPrefix(gi))
1146
+ const near = outputs.filter((_, k) => {
1147
+ const r = roots[k]!
1148
+ return r === '' || pi === r || pi.startsWith(`${r}/`)
1149
+ })
1150
+ // Taken back by the task's own `!` inputs, or by an output `!` (no
1151
+ // output, so never cleaned: A-44), the entry reads what it says.
1152
+ if (near.length === 0 || outputTakenBack(gi, inputs.negative)) continue
1153
+ if (outputTakenBack(gi, out.negative)) continue
1154
+ if (!outputTakenBack(gi, near)) continue
1155
+ throw new UserError(
1156
+ `${node.id}: every file ${JSON.stringify(gi)} in cache.inputs.${field} selects is also ` +
1157
+ `its own output — vx removes a task's outputs before it runs, so the task would ` +
1158
+ `delete its own sources. A task that rewrites files in place (a formatter) declares ` +
1159
+ `no outputs.`,
1160
+ )
1161
+ }
1162
+ }
1163
+ }
1164
+
1165
+ /** One task's inputs (`reads`) or outputs in one namespace, for `detectInputOverlaps`. */
1166
+ interface Side {
1167
+ node: TaskNode
1168
+ globs: readonly string[]
1169
+ reads: boolean
1170
+ /** The project's root-relative dir for a rebased `files` output. */
1171
+ rel?: string
1172
+ }
1173
+
1174
+ /**
1175
+ * `rules.upfrontKeys` (X-54): refuse a task whose input globs can match
1176
+ * another task's declared outputs. Such a key reads bytes a producer writes
1177
+ * this run, so it is not known until the producer ran (`stable-keys.ts`
1178
+ * keeps it waiting); refused, every key reads only what no task writes and
1179
+ * can be derived before anything runs (M, 2026-10-07: "hash ahead of time,
1180
+ * not waterfall"). A dependency's own key already cascades through
1181
+ * `dependsOn`, so the bytes it wrote add nothing a key needs.
1182
+ *
1183
+ * Proven overlaps only (`outputsOverlap`), as for outputs: a pair the rule
1184
+ * cannot decide passes, and the stability gate still keeps it waiting. The
1185
+ * scope mirrors `detectOutputCollisions`: `inputs.files` against another
1186
+ * same-project task's `outputs.files`; root-anchored `inputs.workspaceFiles`
1187
+ * against every other task's `outputs.workspaceFiles` and, with the root
1188
+ * known, its `outputs.files` rebased to the root. A task's own outputs are
1189
+ * already subtracted from its inputs, and an output the reader's `!` entries
1190
+ * take back whole is no overlap. A keyed group is exempt: the default
1191
+ * `build` reads `**` of a config-less dependency and runs nothing, and its
1192
+ * key waits as before.
1193
+ */
1194
+ function detectInputOverlaps(nodes: Map<string, TaskNode>, workspaceRoot?: string): void {
1195
+ const byProject = new Map<string, Side[]>()
1196
+ const rooted: Side[] = []
1197
+ const filesWriters: TaskNode[] = []
1198
+ let projectReaders = false
1199
+ let rootReaders = false
1200
+ const bucket = (name: string): Side[] => {
1201
+ let list = byProject.get(name)
1202
+ if (list === undefined) byProject.set(name, (list = []))
1203
+ return list
1204
+ }
1205
+ for (const n of nodes.values()) {
1206
+ const cache = n.config.cache
1207
+ if (cache === undefined) continue
1208
+ const outFiles = splitNegations(cache.outputs.files).positive
1209
+ const outWs = splitNegations(cache.outputs.workspaceFiles ?? []).positive
1210
+ if (outFiles.length > 0) {
1211
+ bucket(n.projectName).push({ node: n, globs: outFiles, reads: false })
1212
+ filesWriters.push(n)
1213
+ }
1214
+ if (outWs.length > 0) rooted.push({ node: n, globs: outWs, reads: false })
1215
+ if (isGroupTask(n)) continue
1216
+ const inFiles = splitNegations(cache.inputs.files).positive
1217
+ const inWs = splitNegations(cache.inputs.workspaceFiles ?? []).positive
1218
+ if (inFiles.length > 0) {
1219
+ bucket(n.projectName).push({ node: n, globs: inFiles, reads: true })
1220
+ projectReaders = true
1221
+ }
1222
+ if (inWs.length > 0) {
1223
+ rooted.push({ node: n, globs: inWs, reads: true })
1224
+ rootReaders = true
1225
+ }
1226
+ }
1227
+ if (projectReaders) {
1228
+ for (const sides of byProject.values()) {
1229
+ if (sides.length < 2) continue
1230
+ for (const [i, j] of overlapCandidates(sides, (s) => s.globs)) {
1231
+ readsOutputs(sides[i]!, sides[j]!, 'files')
1232
+ }
1233
+ }
1234
+ }
1235
+ if (!rootReaders) return
1236
+ if (workspaceRoot !== undefined) {
1237
+ for (const node of filesWriters) {
1238
+ const rel = path.relative(workspaceRoot, node.projectDir).split(path.sep).join('/')
1239
+ const globs = splitNegations(node.config.cache!.outputs.files).positive.map((g) =>
1240
+ rel === '' ? g : `${rel}/${g}`,
1241
+ )
1242
+ rooted.push({ node, globs, reads: false, rel })
1243
+ }
1244
+ }
1245
+ for (const [i, j] of overlapCandidates(rooted, (s) => s.globs)) {
1246
+ readsOutputs(rooted[i]!, rooted[j]!, 'workspaceFiles')
1247
+ }
1248
+ }
1249
+
1250
+ function readsOutputs(x: Side, y: Side, field: 'files' | 'workspaceFiles'): void {
1251
+ if (x.reads === y.reads || x.node === y.node) return
1252
+ const [reader, writer] = x.reads ? [x, y] : [y, x]
1253
+ const cache = reader.node.config.cache!
1254
+ // What the reader's key never reads: its `!` entries and its own outputs.
1255
+ const takeBack = [
1256
+ ...splitNegations(field === 'files' ? cache.inputs.files : (cache.inputs.workspaceFiles ?? []))
1257
+ .negative,
1258
+ ...splitNegations(
1259
+ field === 'files' ? cache.outputs.files : (cache.outputs.workspaceFiles ?? []),
1260
+ ).positive,
1261
+ ]
1262
+ for (const go of writer.globs) {
1263
+ if (outputTakenBack(go, takeBack)) continue
1264
+ for (const gi of reader.globs) {
1265
+ if (!outputsOverlap(gi, go)) continue
1266
+ const shown =
1267
+ writer.rel === undefined || writer.rel === '' ? go : go.slice(writer.rel.length + 1)
1268
+ throw new UserError(
1269
+ `${reader.node.id} reads ${JSON.stringify(gi)} in cache.inputs.${field}, which matches ` +
1270
+ `${writer.node.id}'s output ${JSON.stringify(shown)} — a task's key must not read ` +
1271
+ `another task's outputs (the dependency's key already cascades through dependsOn). ` +
1272
+ `Exclude it: add ${JSON.stringify(`!${go}`)} to ${reader.node.id}'s ` +
1273
+ `cache.inputs.${field}, or set rules: { upfrontKeys: false } in vx.workspace.ts to ` +
1274
+ `let it wait for its producer.`,
1275
+ )
1276
+ }
1277
+ }
1278
+ }
1279
+
1110
1280
  function detectCycle(nodes: Map<string, TaskNode>): void {
1111
1281
  // Iterative DFS over dense-indexed colors. Recursion + `Map<string,
1112
1282
  // 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)) {