@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.
|
|
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.
|
|
25
|
-
"@vzn/vx-linux-arm64": "0.0.
|
|
26
|
-
"@vzn/vx-darwin-x64": "0.0.
|
|
27
|
-
"@vzn/vx-darwin-arm64": "0.0.
|
|
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
|
/**
|
package/src/graph/index.ts
CHANGED
package/src/graph/task-graph.ts
CHANGED
|
@@ -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
|
-
|
|
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)) {
|