@vzn/vx 0.0.634 → 0.0.636
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/README.md +51 -52
- package/docs/caching.md +14 -2
- package/docs/cli.md +94 -36
- package/docs/execution.md +1 -1
- package/docs/features.md +4 -3
- package/docs/patterns.md +8 -7
- package/docs/schema.md +73 -3
- package/docs/security.md +7 -1
- package/launcher.cjs +0 -0
- package/launcher.sh +50 -0
- package/package.json +17 -8
- package/schemas/last.json +5 -1
- package/schemas/show.json +8 -0
- package/schemas/why.json +4 -1
- package/src/cache/layered-cache.ts +3 -1
- package/src/cli/help.ts +3 -1
- package/src/cli/last.ts +9 -1
- package/src/cli/plugin-templates.ts +1 -1
- package/src/cli/run.ts +35 -7
- package/src/cli/select.ts +17 -11
- package/src/cli/show.ts +2 -2
- package/src/config.ts +36 -3
- package/src/graph/scheduler.ts +14 -3
- package/src/graph/task-graph.ts +22 -5
- package/src/index.ts +7 -1
- package/src/orchestrator/execute-task.ts +62 -6
- package/src/orchestrator/framed-output.ts +27 -1
- package/src/orchestrator/history.ts +75 -1
- package/src/orchestrator/index.ts +1 -0
- package/src/orchestrator/options.ts +2 -0
- package/src/orchestrator/path-links.ts +5 -3
- package/src/orchestrator/plugin.ts +3 -1
- package/src/orchestrator/prepare.ts +17 -14
- package/src/orchestrator/run-failures.ts +20 -3
- package/src/orchestrator/run.ts +25 -2
- package/src/orchestrator/sandbox-fix.ts +123 -0
- package/src/orchestrator/task-hash.ts +7 -1
- package/src/workspace/affected.ts +16 -4
- package/src/workspace/config-schema.ts +85 -4
- package/src/workspace/index.ts +2 -0
- package/src/workspace/migration.ts +51 -4
- package/src/workspace/project-loader.ts +3 -1
|
@@ -9,6 +9,7 @@ import { mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync
|
|
|
9
9
|
import path from 'node:path'
|
|
10
10
|
import { isGroupTask, type TaskOutcome } from '../graph/index.js'
|
|
11
11
|
import { decodeOutputLog } from './output-log.js'
|
|
12
|
+
import { PASS_STATUSES } from './history.js'
|
|
12
13
|
import { splitTaskId } from '../util/index.js'
|
|
13
14
|
import { fileLocations, fileMemo, type OutputLocation } from './path-links.js'
|
|
14
15
|
|
|
@@ -23,6 +24,10 @@ export interface TaskFailure {
|
|
|
23
24
|
/** Plain text, bounded, secrets masked; '' when the run kept none. */
|
|
24
25
|
output: string
|
|
25
26
|
locations: OutputLocation[]
|
|
27
|
+
/** The `exec.sandbox.allow` entries that would have granted its denials. */
|
|
28
|
+
sandboxFix?: NonNullable<TaskOutcome['sandboxFix']>
|
|
29
|
+
/** The first later run where this task passed (ran or hit): the failure is history. */
|
|
30
|
+
fixedIn?: string
|
|
26
31
|
}
|
|
27
32
|
|
|
28
33
|
export interface RunFailures {
|
|
@@ -56,6 +61,7 @@ function failuresOf(outcomes: readonly TaskOutcome[]): StoredFailure[] {
|
|
|
56
61
|
...(o.timedOut === true ? { timedOut: true as const } : {}),
|
|
57
62
|
output,
|
|
58
63
|
locations: fileLocations(output, o.node.projectDir, isFile),
|
|
64
|
+
...(o.sandboxFix !== undefined ? { sandboxFix: o.sandboxFix } : {}),
|
|
59
65
|
})
|
|
60
66
|
}
|
|
61
67
|
return out
|
|
@@ -110,10 +116,19 @@ export function runFailures(cacheDir: string, db: Database, runId?: string): Run
|
|
|
110
116
|
}
|
|
111
117
|
const rows = db
|
|
112
118
|
.query(
|
|
113
|
-
`SELECT project, task, exit_code AS exitCode, timed_out AS timedOut
|
|
114
|
-
|
|
119
|
+
`SELECT r.project, r.task, r.exit_code AS exitCode, r.timed_out AS timedOut,
|
|
120
|
+
(SELECT s.run_id FROM runs s WHERE s.project = r.project AND s.task = r.task
|
|
121
|
+
AND s.id > r.id AND s.status IN ${PASS_STATUSES}
|
|
122
|
+
ORDER BY s.id LIMIT 1) AS fixedIn
|
|
123
|
+
FROM runs r WHERE r.run_id = ? AND r.status = 'failed' ORDER BY r.id`,
|
|
115
124
|
)
|
|
116
|
-
.all(id) as {
|
|
125
|
+
.all(id) as {
|
|
126
|
+
project: string
|
|
127
|
+
task: string
|
|
128
|
+
exitCode: number
|
|
129
|
+
timedOut: number | null
|
|
130
|
+
fixedIn: string | null
|
|
131
|
+
}[]
|
|
117
132
|
return {
|
|
118
133
|
runId: id,
|
|
119
134
|
tasks: rows.map((r) => {
|
|
@@ -125,6 +140,8 @@ export function runFailures(cacheDir: string, db: Database, runId?: string): Run
|
|
|
125
140
|
...(r.timedOut === 1 ? { timedOut: true as const } : {}),
|
|
126
141
|
output: k?.output ?? '',
|
|
127
142
|
locations: k?.locations ?? [],
|
|
143
|
+
...(k?.sandboxFix !== undefined ? { sandboxFix: k.sandboxFix } : {}),
|
|
144
|
+
...(r.fixedIn !== null ? { fixedIn: r.fixedIn } : {}),
|
|
128
145
|
}
|
|
129
146
|
}),
|
|
130
147
|
}
|
package/src/orchestrator/run.ts
CHANGED
|
@@ -3,7 +3,12 @@
|
|
|
3
3
|
// so the layers can be swapped without touching the others.
|
|
4
4
|
|
|
5
5
|
import type { ProjectEntry } from '../workspace/index.js'
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
inputStabilityEnv,
|
|
8
|
+
loadWorkspace,
|
|
9
|
+
unreachedHint,
|
|
10
|
+
unreachedPackages,
|
|
11
|
+
} from '../workspace/index.js'
|
|
7
12
|
import { realpathSync } from 'node:fs'
|
|
8
13
|
import path from 'node:path'
|
|
9
14
|
import {
|
|
@@ -610,6 +615,13 @@ async function runOnBus(
|
|
|
610
615
|
// ignored (a bad timeout must not silently disable a task's own limit).
|
|
611
616
|
const taskTimeoutDefault =
|
|
612
617
|
options.timeout ?? readTaskTimeoutEnv() ?? workspaceConfig?.timeout ?? undefined
|
|
618
|
+
// Read once, before any task: a bad value is refused here, not mid-run.
|
|
619
|
+
const inputStability = {
|
|
620
|
+
run: inputStabilityEnv(),
|
|
621
|
+
...(workspaceConfig?.inputStability !== undefined
|
|
622
|
+
? { workspace: workspaceConfig.inputStability }
|
|
623
|
+
: {}),
|
|
624
|
+
}
|
|
613
625
|
|
|
614
626
|
// Run-scoped registries of live subprocesses:
|
|
615
627
|
// - `liveChildren`: in-flight children. The runner adds/removes
|
|
@@ -1056,6 +1068,7 @@ async function runOnBus(
|
|
|
1056
1068
|
}
|
|
1057
1069
|
if (options.retries !== undefined) a.retries = options.retries
|
|
1058
1070
|
if (taskTimeoutDefault !== undefined) a.timeout = taskTimeoutDefault
|
|
1071
|
+
a.inputStability = inputStability
|
|
1059
1072
|
if (download.modeOf.get(node.id) === 'deferred') a.download = 'deferred'
|
|
1060
1073
|
if (placements.remoteOnlyNoop.has(node.id)) a.remoteOnlyNoop = true
|
|
1061
1074
|
if (placements.remoteOnly.has(node.id)) a.remoteOnly = true
|
|
@@ -1089,6 +1102,14 @@ async function runOnBus(
|
|
|
1089
1102
|
})
|
|
1090
1103
|
|
|
1091
1104
|
mark('classify + probe')
|
|
1105
|
+
// A run whose every task is a confirmed local hit dispatches only
|
|
1106
|
+
// restores, which no weight orders: the `schedule` stage is not asked.
|
|
1107
|
+
const priorities = [...nodes.values()].every(
|
|
1108
|
+
(n) => n.config.exec?.command === undefined || shortCircuit.restoreTier.has(n.id),
|
|
1109
|
+
)
|
|
1110
|
+
? new Map<string, number>()
|
|
1111
|
+
: await prepared.priorities()
|
|
1112
|
+
mark('schedule')
|
|
1092
1113
|
// Judged as each task finishes, against the history BEFORE this run's
|
|
1093
1114
|
// rows land, so the row that prints next carries the verdict: nothing
|
|
1094
1115
|
// prints below the footer. A hit or a skip asks nothing; a green miss
|
|
@@ -1172,7 +1193,7 @@ async function runOnBus(
|
|
|
1172
1193
|
: {}),
|
|
1173
1194
|
// A `schedule` plugin's weights; the scheduler keeps its structural
|
|
1174
1195
|
// baseline as the tie-break. Empty map → baseline only.
|
|
1175
|
-
...(
|
|
1196
|
+
...(priorities.size > 0 ? { priorities } : {}),
|
|
1176
1197
|
// The baseline under them; `most-work` when the workspace names none.
|
|
1177
1198
|
...(prepared.workspaceConfig?.schedule !== undefined
|
|
1178
1199
|
? { strategy: prepared.workspaceConfig.schedule }
|
|
@@ -1718,6 +1739,8 @@ export async function planRun(options: RunOptions): Promise<RunPlan> {
|
|
|
1718
1739
|
if (prepared.empty === 'none-affected')
|
|
1719
1740
|
return { tasks: [], noneAffected: noneAffected(prepared, options) }
|
|
1720
1741
|
if (prepared.empty !== null) return { tasks: [] }
|
|
1742
|
+
// The plan orders nothing, but it refuses what the run would refuse.
|
|
1743
|
+
await prepared.priorities()
|
|
1721
1744
|
// Its own mark: a dry run's plan (every task's hash, the cache lookups,
|
|
1722
1745
|
// the history p50s) was booked under `close`, the next mark, and read
|
|
1723
1746
|
// as 105 ms of closing a cache at 1,000 projects (item 601).
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// The `exec.sandbox.allow` entries that would have granted what a failed
|
|
2
|
+
// task was denied, so the report names the line to add (CLI, JSON, MCP)
|
|
3
|
+
// rather than leaving the user to turn absolute paths into grants. Built on
|
|
4
|
+
// the failure path only: a passing run never calls it.
|
|
5
|
+
|
|
6
|
+
import { lstatSync } from 'node:fs'
|
|
7
|
+
import os from 'node:os'
|
|
8
|
+
import path from 'node:path'
|
|
9
|
+
import type { SandboxViolation } from '../exec/index.js'
|
|
10
|
+
import type { TaskOutcome } from '../graph/index.js'
|
|
11
|
+
import { isLiteralPattern } from '../util/index.js'
|
|
12
|
+
|
|
13
|
+
/** Grants to add under `exec.sandbox.allow`, each list sorted and unique. */
|
|
14
|
+
type SandboxFix = NonNullable<TaskOutcome['sandboxFix']>
|
|
15
|
+
type Kind = Exclude<keyof SandboxFix, 'withheld'>
|
|
16
|
+
|
|
17
|
+
// Where credentials live. A denial here is never turned into a grant: an
|
|
18
|
+
// agent applies `sandboxFix` as given, so a dependency probing ~/.ssh would
|
|
19
|
+
// get its grant suggested and applied (Security, 2026-10-12).
|
|
20
|
+
const CREDENTIAL_DIRS = ['.ssh', '.aws', '.gnupg', '.kube', '.config/gh', '.config/gcloud']
|
|
21
|
+
const CREDENTIAL_FILES = ['.netrc', '.npmrc', '.docker/config.json', '.git-credentials', '.pypirc']
|
|
22
|
+
|
|
23
|
+
function isCredential(abs: string): boolean {
|
|
24
|
+
const base = path.basename(abs)
|
|
25
|
+
if (base === '.env' || base.startsWith('.env.')) return true
|
|
26
|
+
const rel = path.relative(os.homedir(), abs)
|
|
27
|
+
if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) return false
|
|
28
|
+
return (
|
|
29
|
+
CREDENTIAL_FILES.includes(rel) ||
|
|
30
|
+
CREDENTIAL_DIRS.some((d) => rel === d || rel.startsWith(d + path.sep))
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A path as a grant spells it: project-relative inside the project, `~/…`
|
|
36
|
+
* under home, else absolute.
|
|
37
|
+
*/
|
|
38
|
+
function grantPath(abs: string, projectDir: string): string {
|
|
39
|
+
const rel = path.relative(projectDir, abs)
|
|
40
|
+
if (rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel)) return rel
|
|
41
|
+
const home = os.homedir()
|
|
42
|
+
return abs.startsWith(home + path.sep) ? `~/${abs.slice(home.length + 1)}` : abs
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A write grant covers the top directory the write landed in: granting
|
|
47
|
+
* `dist/a.js` alone fails the run again on `dist/b.js`. Not when the task
|
|
48
|
+
* reads that directory as an input: the grant would let it write its own
|
|
49
|
+
* inputs, so it names the path alone.
|
|
50
|
+
*/
|
|
51
|
+
function writeGrant(grant: string, isDir: boolean, inputs: readonly string[]): string {
|
|
52
|
+
const slash = grant.indexOf('/')
|
|
53
|
+
const exact = isDir ? `${grant}/` : grant
|
|
54
|
+
if (grant.startsWith('~/') || path.isAbsolute(grant) || slash === -1) return exact
|
|
55
|
+
const top = grant.slice(0, slash)
|
|
56
|
+
return inputs.some((g) => readsDir(g, top)) ? exact : `${top}/`
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Whether input glob `g` can match under top directory `top`. */
|
|
60
|
+
function readsDir(g: string, top: string): boolean {
|
|
61
|
+
if (g.startsWith('!')) return false
|
|
62
|
+
const first = g.replace(/^\.\//, '').split('/')[0] ?? ''
|
|
63
|
+
return first === top || !isLiteralPattern(first)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function kindOf(v: SandboxViolation): Kind | undefined {
|
|
67
|
+
const can = v.ignorable ?? []
|
|
68
|
+
if (can.length === 1) return can[0]
|
|
69
|
+
// Linux reports a refused `openat` as either: the flags the parse drops
|
|
70
|
+
// decide. A file the host has was hidden from a read; one it lacks was
|
|
71
|
+
// being created.
|
|
72
|
+
if (can.includes('read') && can.includes('write') && v.path !== undefined)
|
|
73
|
+
return lstatSync(v.path, { throwIfNoEntry: false }) !== undefined ? 'read' : 'write'
|
|
74
|
+
return undefined
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function sandboxFix(
|
|
78
|
+
violations: readonly SandboxViolation[],
|
|
79
|
+
projectDir: string,
|
|
80
|
+
mask: (text: string) => string = (t) => t,
|
|
81
|
+
/** The task's `cache.inputs.files`: a write under one is granted exactly. */
|
|
82
|
+
inputs: readonly string[] = [],
|
|
83
|
+
): SandboxFix | undefined {
|
|
84
|
+
const fix: Record<Kind | 'withheld', Set<string>> = {
|
|
85
|
+
read: new Set(),
|
|
86
|
+
write: new Set(),
|
|
87
|
+
network: new Set(),
|
|
88
|
+
systemInfo: new Set(),
|
|
89
|
+
withheld: new Set(),
|
|
90
|
+
}
|
|
91
|
+
for (const v of violations) {
|
|
92
|
+
if (v.hint === true || v.target === undefined) continue
|
|
93
|
+
const kind = kindOf(v)
|
|
94
|
+
if (kind === undefined) continue
|
|
95
|
+
if (kind === 'network') fix.network.add(v.target.replace(/:\d+$/, ''))
|
|
96
|
+
else if (kind === 'systemInfo') fix.systemInfo.add(v.target)
|
|
97
|
+
else if (v.path === undefined) continue
|
|
98
|
+
else if (isCredential(v.path)) fix.withheld.add(grantPath(v.path, projectDir))
|
|
99
|
+
else if (kind === 'read') fix.read.add(grantPath(v.path, projectDir))
|
|
100
|
+
else {
|
|
101
|
+
const isDir =
|
|
102
|
+
v.line.startsWith('mkdir') ||
|
|
103
|
+
lstatSync(v.path, { throwIfNoEntry: false })?.isDirectory() === true
|
|
104
|
+
fix.write.add(writeGrant(grantPath(v.path, projectDir), isDir, inputs))
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
const out: SandboxFix = {}
|
|
108
|
+
for (const k of ['read', 'write', 'network', 'systemInfo', 'withheld'] as const)
|
|
109
|
+
if (fix[k].size > 0) out[k] = [...new Set([...fix[k]].map(mask))].sort()
|
|
110
|
+
return Object.keys(out).length > 0 ? out : undefined
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The fix as the config line it is: `exec.sandbox.allow: { read: ['a'] }`,
|
|
115
|
+
* or undefined when every denial was a withheld credential path.
|
|
116
|
+
*/
|
|
117
|
+
export function sandboxFixLine({ withheld: _, ...fix }: SandboxFix): string | undefined {
|
|
118
|
+
if (Object.keys(fix).length === 0) return undefined
|
|
119
|
+
const parts = Object.entries(fix).map(
|
|
120
|
+
([k, list]: [string, string[]]) => `${k}: [${list.map((p) => `'${p}'`).join(', ')}]`,
|
|
121
|
+
)
|
|
122
|
+
return `exec.sandbox.allow: { ${parts.join(', ')} }`
|
|
123
|
+
}
|
|
@@ -238,11 +238,15 @@ export async function describeTaskInputs(args: ComputeHashArgs): Promise<{
|
|
|
238
238
|
* input changed and changed BACK while the command ran matched its digest
|
|
239
239
|
* again, and the entry saved the other content's output under the key (item
|
|
240
240
|
* 1015). A write before it — an upstream's — is judged by content, as before.
|
|
241
|
+
* `writesByBytes` (`inputStability.strategy: 'mutable'`) judges a write
|
|
242
|
+
* during the command by content too: the task says the same bytes are its
|
|
243
|
+
* own identical rewrite (tsdown's package.json), not an edit undone.
|
|
241
244
|
*/
|
|
242
245
|
export async function movedInput(
|
|
243
246
|
facts: readonly InputFact[],
|
|
244
247
|
cache: CacheLayer,
|
|
245
248
|
commandFrom?: number,
|
|
249
|
+
writesByBytes = false,
|
|
246
250
|
): Promise<string | undefined> {
|
|
247
251
|
const suspects: InputFact[] = []
|
|
248
252
|
for (const f of facts) {
|
|
@@ -260,7 +264,9 @@ export async function movedInput(
|
|
|
260
264
|
// an even one, FAT's tick), one during the command included
|
|
261
265
|
// (`racyWindowMs`).
|
|
262
266
|
if (commandFrom !== undefined && ctimeMs >= commandFrom - racyWindowMs(ctimeMs, 0)) {
|
|
263
|
-
return f.path
|
|
267
|
+
if (!writesByBytes) return f.path
|
|
268
|
+
suspects.push(f)
|
|
269
|
+
continue
|
|
264
270
|
}
|
|
265
271
|
if (ctimeMs >= f.since - racyWindowMs(ctimeMs, FILE_HASH_RACY_MS)) suspects.push(f)
|
|
266
272
|
}
|
|
@@ -173,6 +173,7 @@ export async function affectedChanges(
|
|
|
173
173
|
if (since.length === 0 || since.startsWith('-')) {
|
|
174
174
|
throw new UserError(
|
|
175
175
|
`git ref "${since}" is not a ref: a base cannot be empty or start with "-".`,
|
|
176
|
+
'VX_E_AFFECTED_BASE',
|
|
176
177
|
)
|
|
177
178
|
}
|
|
178
179
|
// `A..B` / `A...B` reached `rev-parse --verify`, which refuses a range, and
|
|
@@ -183,6 +184,7 @@ export async function affectedChanges(
|
|
|
183
184
|
throw new UserError(
|
|
184
185
|
`git ref "${since}" is a range: ranges are not supported — pass the base alone ` +
|
|
185
186
|
`("${since.slice(0, range) || 'HEAD'}"); vx diffs it against the working tree.`,
|
|
187
|
+
'VX_E_AFFECTED_BASE',
|
|
186
188
|
)
|
|
187
189
|
}
|
|
188
190
|
// `^main` is rev-list's exclusion, not a ref: merge-base refused it, it
|
|
@@ -191,6 +193,7 @@ export async function affectedChanges(
|
|
|
191
193
|
if (since.startsWith('^')) {
|
|
192
194
|
throw new UserError(
|
|
193
195
|
`git ref "${since}" is an exclusion, not a ref: pass the base alone ("${since.replace(/^\^+/, '') || 'HEAD'}").`,
|
|
196
|
+
'VX_E_AFFECTED_BASE',
|
|
194
197
|
)
|
|
195
198
|
}
|
|
196
199
|
// Diff from the MERGE BASE of `since` and HEAD, not from `since` itself:
|
|
@@ -556,12 +559,13 @@ async function gitBlobsAt(
|
|
|
556
559
|
proc.exited,
|
|
557
560
|
])
|
|
558
561
|
if (exit !== 0) {
|
|
559
|
-
throw new UserError(`git cat-file --batch failed (exit ${exit}): ${stderr.trim()}
|
|
562
|
+
throw new UserError(`git cat-file --batch failed (exit ${exit}): ${stderr.trim()}`, 'VX_E_GIT')
|
|
560
563
|
}
|
|
561
564
|
let at = 0
|
|
562
565
|
for (const file of batched) {
|
|
563
566
|
const eol = out.indexOf(0x0a, at)
|
|
564
|
-
if (eol === -1)
|
|
567
|
+
if (eol === -1)
|
|
568
|
+
throw new UserError(`git cat-file --batch ended before ${ref}:./${file}`, 'VX_E_GIT')
|
|
565
569
|
const header = new TextDecoder().decode(out.subarray(at, eol))
|
|
566
570
|
at = eol + 1
|
|
567
571
|
// `<oid> <type> <size>`, or `<spec> missing` / `<spec> ambiguous`.
|
|
@@ -636,7 +640,10 @@ async function gitBytesAt(
|
|
|
636
640
|
// "exists on disk but not in <ref>" and "path does not exist in <ref>"
|
|
637
641
|
// are the file being absent at the ref; anything else is git failing.
|
|
638
642
|
if (/does not exist in|exists on disk, but not in/.test(stderr)) return null
|
|
639
|
-
throw new UserError(
|
|
643
|
+
throw new UserError(
|
|
644
|
+
`git show ${ref}:./${file} failed (exit ${exit}): ${stderr.trim()}`,
|
|
645
|
+
'VX_E_GIT',
|
|
646
|
+
)
|
|
640
647
|
}
|
|
641
648
|
|
|
642
649
|
async function bytesOrNull(file: string): Promise<Uint8Array | null> {
|
|
@@ -654,7 +661,7 @@ async function gitPaths(workspaceRoot: string, cmd: string[]): Promise<string[]>
|
|
|
654
661
|
const stderr = await new Response(proc.stderr).text()
|
|
655
662
|
const exit = await proc.exited
|
|
656
663
|
if (exit !== 0) {
|
|
657
|
-
throw new UserError(`git ${cmd[0]} failed (exit ${exit}): ${stderr.trim()}
|
|
664
|
+
throw new UserError(`git ${cmd[0]} failed (exit ${exit}): ${stderr.trim()}`, 'VX_E_GIT')
|
|
658
665
|
}
|
|
659
666
|
return stdout.split('\0').filter((s) => s.length > 0)
|
|
660
667
|
}
|
|
@@ -724,6 +731,7 @@ export async function defaultAffectedBase(workspaceRoot: string): Promise<string
|
|
|
724
731
|
'--affected has no base here: origin/HEAD is not set (or names a branch that is gone) and HEAD has no parent to compare ' +
|
|
725
732
|
'with — a shallow clone? Fetch history (actions/checkout: fetch-depth: 0) or name the ' +
|
|
726
733
|
'base: --affected=origin/main',
|
|
734
|
+
'VX_E_AFFECTED_BASE',
|
|
727
735
|
)
|
|
728
736
|
}
|
|
729
737
|
return 'HEAD~1'
|
|
@@ -742,6 +750,7 @@ function noHistoryRefusal(workspaceRoot: string): UserError | undefined {
|
|
|
742
750
|
if (revParse(workspaceRoot, 'HEAD') === undefined) {
|
|
743
751
|
return new UserError(
|
|
744
752
|
'--affected has no base here: this repository has no commit yet. Commit first, or run without --affected.',
|
|
753
|
+
'VX_E_AFFECTED_BASE',
|
|
745
754
|
)
|
|
746
755
|
}
|
|
747
756
|
return undefined
|
|
@@ -832,6 +841,7 @@ async function mergeBase(workspaceRoot: string, ref: string): Promise<string> {
|
|
|
832
841
|
throw new UserError(
|
|
833
842
|
`git ref "${ref}" names what is at ${sub[2]}, not a commit: vx diffs the whole ` +
|
|
834
843
|
`workspace, so pass the commit alone ("${sub[1] || 'HEAD'}").`,
|
|
844
|
+
'VX_E_AFFECTED_BASE',
|
|
835
845
|
)
|
|
836
846
|
}
|
|
837
847
|
return ref
|
|
@@ -857,6 +867,7 @@ async function verifyRef(workspaceRoot: string, ref: string): Promise<void> {
|
|
|
857
867
|
throw new UserError(
|
|
858
868
|
`git rev-parse failed (exit ${proc.exitCode}) in ${workspaceRoot}` +
|
|
859
869
|
(stderr.length > 0 ? `: ${stderr}` : ''),
|
|
870
|
+
'VX_E_GIT',
|
|
860
871
|
)
|
|
861
872
|
}
|
|
862
873
|
// A shallow clone (CI's checkout fetches one commit by default) has no
|
|
@@ -868,6 +879,7 @@ async function verifyRef(workspaceRoot: string, ref: string): Promise<void> {
|
|
|
868
879
|
(isShallow
|
|
869
880
|
? ' This clone is shallow: fetch the history the base needs (`git fetch --unshallow`, or `fetch-depth: 0` on actions/checkout).'
|
|
870
881
|
: ''),
|
|
882
|
+
'VX_E_AFFECTED_BASE',
|
|
871
883
|
)
|
|
872
884
|
}
|
|
873
885
|
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
PLUGIN_FUNCTION_HOOKS,
|
|
11
11
|
PLUGIN_HOOKS,
|
|
12
12
|
PLUGIN_PACKAGE,
|
|
13
|
+
type InputStability,
|
|
13
14
|
type Plugin,
|
|
14
15
|
type ProjectConfig,
|
|
15
16
|
type WorkspaceConfig,
|
|
@@ -43,9 +44,65 @@ export function isCacheScope(v: unknown): v is string {
|
|
|
43
44
|
return typeof v === 'string' && /^[\w.@/-]{1,128}$/.test(v)
|
|
44
45
|
}
|
|
45
46
|
|
|
46
|
-
/** Why `VX_CACHE_SCOPE` is refused, in `cacheScope`'s words. */
|
|
47
|
+
/** Why a `VX_CACHE_SCOPE` or `--cache-scope` value is refused, in `cacheScope`'s words. */
|
|
48
|
+
export function cacheScopeError(source = 'VX_CACHE_SCOPE'): string {
|
|
49
|
+
return `${source} ${CACHE_SCOPE_RULE}`
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Why `VX_CACHE_SCOPE` is refused, as the run's error. */
|
|
47
53
|
export function cacheScopeEnvError(): UserError {
|
|
48
|
-
return new UserError(
|
|
54
|
+
return new UserError(cacheScopeError())
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const STRATEGIES = ['immutable', 'mutable', 'unchecked'] as const
|
|
58
|
+
const SEVERITIES = ['warn', 'error'] as const
|
|
59
|
+
const INPUT_STABILITY_FIELDS = new Set(['strategy', 'severity'])
|
|
60
|
+
const oneOf = (values: readonly string[]): string =>
|
|
61
|
+
`must be ${values
|
|
62
|
+
.map((v) => `'${v}'`)
|
|
63
|
+
.join(', ')
|
|
64
|
+
.replace(/, ([^,]*)$/, ' or $1')}`
|
|
65
|
+
|
|
66
|
+
function envOneOf<T extends string>(
|
|
67
|
+
name: string,
|
|
68
|
+
raw: string | undefined,
|
|
69
|
+
values: readonly T[],
|
|
70
|
+
): T | undefined {
|
|
71
|
+
if (raw === undefined || raw === '') return undefined
|
|
72
|
+
if (!(values as readonly string[]).includes(raw)) throw new UserError(`${name} ${oneOf(values)}`)
|
|
73
|
+
return raw as T
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function validateInputStability(stability: unknown, at: string): void {
|
|
77
|
+
if (typeof stability !== 'object' || stability === null || Array.isArray(stability)) {
|
|
78
|
+
throw new UserError(`${at} must be an object — \`inputStability: { strategy: 'mutable' }\``)
|
|
79
|
+
}
|
|
80
|
+
assertKnownFields(stability, INPUT_STABILITY_FIELDS, at)
|
|
81
|
+
const { strategy, severity } = stability as Record<string, unknown>
|
|
82
|
+
if (strategy !== undefined && !STRATEGIES.includes(strategy as never)) {
|
|
83
|
+
throw new UserError(`${at}.strategy ${oneOf(STRATEGIES)}`)
|
|
84
|
+
}
|
|
85
|
+
if (severity !== undefined && !SEVERITIES.includes(severity as never)) {
|
|
86
|
+
throw new UserError(`${at}.severity ${oneOf(SEVERITIES)}`)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* `VX_INPUT_STABILITY` and `VX_INPUT_STABILITY_SEVERITY`, which override
|
|
92
|
+
* every task's `cache.inputStability` for the run: each undefined when
|
|
93
|
+
* unset or empty, refused when it names no value.
|
|
94
|
+
*/
|
|
95
|
+
export function inputStabilityEnv(): InputStability {
|
|
96
|
+
const strategy = envOneOf('VX_INPUT_STABILITY', process.env['VX_INPUT_STABILITY'], STRATEGIES)
|
|
97
|
+
const severity = envOneOf(
|
|
98
|
+
'VX_INPUT_STABILITY_SEVERITY',
|
|
99
|
+
process.env['VX_INPUT_STABILITY_SEVERITY'],
|
|
100
|
+
SEVERITIES,
|
|
101
|
+
)
|
|
102
|
+
return {
|
|
103
|
+
...(strategy !== undefined ? { strategy } : {}),
|
|
104
|
+
...(severity !== undefined ? { severity } : {}),
|
|
105
|
+
}
|
|
49
106
|
}
|
|
50
107
|
|
|
51
108
|
const WORKSPACE_FIELDS = new Set([
|
|
@@ -57,6 +114,7 @@ const WORKSPACE_FIELDS = new Set([
|
|
|
57
114
|
'cacheScope',
|
|
58
115
|
'rules',
|
|
59
116
|
'schedule',
|
|
117
|
+
'inputStability',
|
|
60
118
|
'plugins',
|
|
61
119
|
])
|
|
62
120
|
|
|
@@ -136,6 +194,11 @@ function validateRetention(retention: unknown, configPath: string): void {
|
|
|
136
194
|
}
|
|
137
195
|
}
|
|
138
196
|
|
|
197
|
+
/** A `plugins` entry that stands for no plugin: `undefined`, `null` or `false`. */
|
|
198
|
+
export function isSkippedPlugin(p: unknown): p is undefined | null | false {
|
|
199
|
+
return p === undefined || p === null || p === false
|
|
200
|
+
}
|
|
201
|
+
|
|
139
202
|
export function validateWorkspace(config: WorkspaceConfig, configPath: string): void {
|
|
140
203
|
// No JSON-data rule here, unlike `validateProjectConfig`: `plugins` holds
|
|
141
204
|
// objects of functions by design, the file is evaluated in-process on
|
|
@@ -193,6 +256,9 @@ export function validateWorkspace(config: WorkspaceConfig, configPath: string):
|
|
|
193
256
|
throw new UserError(`${configPath}: \`cacheScope\` ${CACHE_SCOPE_RULE}`)
|
|
194
257
|
}
|
|
195
258
|
if (config.rules !== undefined) validateRules(config.rules, configPath)
|
|
259
|
+
if (config.inputStability !== undefined) {
|
|
260
|
+
validateInputStability(config.inputStability, `${configPath}: \`inputStability\``)
|
|
261
|
+
}
|
|
196
262
|
if (config.schedule !== undefined && !Object.hasOwn(SCHEDULES, config.schedule)) {
|
|
197
263
|
throw new UserError(
|
|
198
264
|
`${configPath}: \`schedule\` must be one of ${Object.keys(SCHEDULES)
|
|
@@ -206,7 +272,10 @@ export function validateWorkspace(config: WorkspaceConfig, configPath: string):
|
|
|
206
272
|
}
|
|
207
273
|
const verbOwners = new Map<string, string>()
|
|
208
274
|
const fileClaimants = new Map<string, { name: string; at: number }>()
|
|
209
|
-
for (const [i, p] of config.plugins.entries()) {
|
|
275
|
+
for (const [i, p] of (config.plugins as readonly unknown[]).entries()) {
|
|
276
|
+
// A factory that declines returns nothing, and `cond && plugin()` is
|
|
277
|
+
// false: both are skipped, as Vite skips them.
|
|
278
|
+
if (isSkippedPlugin(p)) continue
|
|
210
279
|
if (p === null || typeof p !== 'object') {
|
|
211
280
|
// Nx's nx.json lists plugins by module name (`'@nx/vite/plugin'`);
|
|
212
281
|
// a vx plugin is the value its package's function returns.
|
|
@@ -660,6 +729,9 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
|
|
|
660
729
|
)
|
|
661
730
|
}
|
|
662
731
|
assertKnownFields(cache, CACHE_FIELDS, `${where}.cache`)
|
|
732
|
+
const stability = (cache as { inputStability?: unknown }).inputStability
|
|
733
|
+
if (stability !== undefined)
|
|
734
|
+
validateInputStability(stability, `${where}.cache.inputStability`)
|
|
663
735
|
const inputs = (cache as { inputs?: unknown }).inputs
|
|
664
736
|
const outputs = (cache as { outputs?: unknown }).outputs
|
|
665
737
|
// A present field of the wrong shape (`outputs: 'dist'`) was told it
|
|
@@ -896,7 +968,7 @@ const EXEC_FIELDS = new Set([
|
|
|
896
968
|
])
|
|
897
969
|
const PERSISTENT_FIELDS = new Set(['readyWhen'])
|
|
898
970
|
const ENV_FIELDS = new Set(['passThrough', 'define', 'secret'])
|
|
899
|
-
const CACHE_FIELDS = new Set(['inputs', 'outputs'])
|
|
971
|
+
const CACHE_FIELDS = new Set(['inputs', 'outputs', 'inputStability'])
|
|
900
972
|
const CACHE_INPUT_FIELDS = new Set([
|
|
901
973
|
'files',
|
|
902
974
|
'workspaceFiles',
|
|
@@ -939,6 +1011,15 @@ export const REMOVED_FIELDS: ReadonlyMap<
|
|
|
939
1011
|
},
|
|
940
1012
|
},
|
|
941
1013
|
],
|
|
1014
|
+
[
|
|
1015
|
+
CACHE_FIELDS,
|
|
1016
|
+
{
|
|
1017
|
+
rewrittenInputs: {
|
|
1018
|
+
removedIn: '0.0.636',
|
|
1019
|
+
use: "`inputStability: { strategy: 'immutable' | 'mutable' }` ('withhold', 'compare')",
|
|
1020
|
+
},
|
|
1021
|
+
},
|
|
1022
|
+
],
|
|
942
1023
|
])
|
|
943
1024
|
|
|
944
1025
|
/**
|
package/src/workspace/index.ts
CHANGED
|
@@ -377,6 +377,18 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
|
|
|
377
377
|
// One reason, its tasks: on a Turbo template the same two-line cache TODO
|
|
378
378
|
// printed five times and the persistent one eight (the first-five-minutes
|
|
379
379
|
// walk, 2026-09-28), and the list read as noise.
|
|
380
|
+
// A file .gitignore drops is lost at the first commit, unnoticed: vueuse
|
|
381
|
+
// ignores `packages/**/*.mjs`, so `--mjs` wrote configs git never saw.
|
|
382
|
+
const ignored = gitIgnored(
|
|
383
|
+
root,
|
|
384
|
+
files.map((f) => f.relPath),
|
|
385
|
+
)
|
|
386
|
+
const ignoredNote =
|
|
387
|
+
ignored.length === 0
|
|
388
|
+
? []
|
|
389
|
+
: [
|
|
390
|
+
`.gitignore drops ${ignored.length} file${ignored.length === 1 ? '' : 's'} this ${dry ? 'would write' : 'wrote'} (${ignored.slice(0, TODO_IDS_SHOWN).join(', ')}${ignored.length > TODO_IDS_SHOWN ? ', …' : ''}): add ${[...new Set(ignored.map((f) => `!${path.posix.basename(f)}`))].join(' and ')} to .gitignore, or commit them with git add -f`,
|
|
391
|
+
]
|
|
380
392
|
const todos = new Map<string, string[]>()
|
|
381
393
|
let todoCount = 0
|
|
382
394
|
let clean = 0
|
|
@@ -423,7 +435,7 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
|
|
|
423
435
|
args.unmapped === true
|
|
424
436
|
? `${verb}: no package.json scripts became tasks.`
|
|
425
437
|
: `${verb}: no package.json scripts to turn into tasks.`,
|
|
426
|
-
...[...noMembers, ...(args.notes ?? [])].map((n) => `note: ${n}`),
|
|
438
|
+
...[...noMembers, ...(args.notes ?? []), ...ignoredNote].map((n) => `note: ${n}`),
|
|
427
439
|
hasWorkspaceFile
|
|
428
440
|
? `${workspaceName} already exists.`
|
|
429
441
|
: dry
|
|
@@ -438,7 +450,7 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
|
|
|
438
450
|
)
|
|
439
451
|
} else {
|
|
440
452
|
report.push(`${verb}: ${source} → ${configName}`)
|
|
441
|
-
for (const n of args.notes ?? []) report.push(`note: ${n}`)
|
|
453
|
+
for (const n of [...(args.notes ?? []), ...ignoredNote]) report.push(`note: ${n}`)
|
|
442
454
|
for (const n of plan.headerNotes) report.push(`note: ${n}`)
|
|
443
455
|
report.push(
|
|
444
456
|
'',
|
|
@@ -470,7 +482,10 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
|
|
|
470
482
|
// A run of tasks none of which caches is never a hit, and a first try
|
|
471
483
|
// that runs twice to see the cache work saw it run twice.
|
|
472
484
|
if (!cached && [...todos.keys()].some(isCacheTodo)) {
|
|
473
|
-
report.push(
|
|
485
|
+
report.push(
|
|
486
|
+
'',
|
|
487
|
+
'no task caches yet: uncomment the cache line in each TODO task with its real globs, and a second run hits',
|
|
488
|
+
)
|
|
474
489
|
}
|
|
475
490
|
const installed = existsSync(path.join(root, 'node_modules', '@vzn', 'vx', 'package.json'))
|
|
476
491
|
// The line is the next thing to type, so it names what that run would
|
|
@@ -495,6 +510,7 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
|
|
|
495
510
|
...(empty && unreached.length > 0 ? [unreachedHint(unreached, root)] : []),
|
|
496
511
|
...(empty ? noMembers : []),
|
|
497
512
|
...(args.notes ?? []),
|
|
513
|
+
...ignoredNote,
|
|
498
514
|
...plan.headerNotes,
|
|
499
515
|
...plan.notes,
|
|
500
516
|
],
|
|
@@ -508,6 +524,28 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
|
|
|
508
524
|
return 0
|
|
509
525
|
}
|
|
510
526
|
|
|
527
|
+
/** The paths (root-relative) git's ignore rules drop; none outside a work tree. */
|
|
528
|
+
function gitIgnored(root: string, relPaths: readonly string[]): string[] {
|
|
529
|
+
if (relPaths.length === 0) return []
|
|
530
|
+
try {
|
|
531
|
+
const r = Bun.spawnSync({
|
|
532
|
+
cmd: [executablePath('git'), 'check-ignore', '--stdin', '-z'],
|
|
533
|
+
cwd: root,
|
|
534
|
+
stdin: Buffer.from(relPaths.map((p) => `${p}\0`).join('')),
|
|
535
|
+
stdout: 'pipe',
|
|
536
|
+
stderr: 'ignore',
|
|
537
|
+
})
|
|
538
|
+
// 0: some ignored; 1: none; anything else (no work tree): nothing to say.
|
|
539
|
+
if (r.exitCode !== 0) return []
|
|
540
|
+
return r.stdout
|
|
541
|
+
.toString()
|
|
542
|
+
.split('\0')
|
|
543
|
+
.filter((p) => p !== '')
|
|
544
|
+
} catch {
|
|
545
|
+
return []
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
511
549
|
function inGitWorkTree(root: string): boolean {
|
|
512
550
|
try {
|
|
513
551
|
return (
|
|
@@ -660,7 +698,16 @@ function renderConfigFile(p: GeneratedProject, format: MigrationFormat): string
|
|
|
660
698
|
: IDENT.test(t.name)
|
|
661
699
|
? t.name
|
|
662
700
|
: quoteTsLiteral(t.name)
|
|
663
|
-
|
|
701
|
+
const rendered = renderValue(t.task, ' ')
|
|
702
|
+
// The suggested block sits in the task, commented: deleting `// `
|
|
703
|
+
// after setting the real globs caches it, no copying from the TODO.
|
|
704
|
+
const block =
|
|
705
|
+
t.task['cache'] === undefined
|
|
706
|
+
? t.todos.find(isCacheTodo)?.match(/^cache: add `(.+?)`/)?.[1]
|
|
707
|
+
: undefined
|
|
708
|
+
lines.push(
|
|
709
|
+
` ${key}: ${block === undefined ? rendered : rendered.replace('{\n', `{\n // ${block},\n`)},`,
|
|
710
|
+
)
|
|
664
711
|
}
|
|
665
712
|
lines.push(' },', format === 'ts' ? '} satisfies ProjectConfig' : '}', '')
|
|
666
713
|
return lines.join('\n')
|
|
@@ -2,7 +2,7 @@ import { existsSync, realpathSync, statSync } from 'node:fs'
|
|
|
2
2
|
import path from 'node:path'
|
|
3
3
|
import type { ProjectConfig, WorkspaceConfig } from '../config.js'
|
|
4
4
|
import { UserError, xxh3hex } from '../util/index.js'
|
|
5
|
-
import { validateProjectConfig, validateWorkspace } from './config-schema.js'
|
|
5
|
+
import { isSkippedPlugin, validateProjectConfig, validateWorkspace } from './config-schema.js'
|
|
6
6
|
import {
|
|
7
7
|
beginEvalRound,
|
|
8
8
|
builtinsChangedBy,
|
|
@@ -1163,6 +1163,8 @@ export async function loadWorkspaceConfig(root: string): Promise<WorkspaceConfig
|
|
|
1163
1163
|
// config at all (D-6).
|
|
1164
1164
|
assertDefaultObject(mod, 'Workspace', configPath)
|
|
1165
1165
|
validateWorkspace(mod, configPath)
|
|
1166
|
+
if (mod.plugins?.some(isSkippedPlugin) === true)
|
|
1167
|
+
mod = { ...mod, plugins: mod.plugins.filter((p) => !isSkippedPlugin(p)) }
|
|
1166
1168
|
// Set by a load that succeeded: a failed one may have left nothing in
|
|
1167
1169
|
// the registry, and its fix must not be refused.
|
|
1168
1170
|
if (since === undefined) workspaceLoadedAt.set(configPath, startedAt)
|