@vzn/vx 0.0.634 → 0.0.635

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.
@@ -21,7 +21,8 @@ const DEFAULT_RECENT = 50
21
21
 
22
22
  // A hit is recorded as `cache-hit` / `cache-hit-remote` and replays a
23
23
  // success; the list is derived so a new status cannot miss it.
24
- const PASS_STATUSES = `(${TASK_STATUSES.filter(isPassStatus)
24
+ /** The pass set as SQL, `(…)`: one definition, `isPassStatus`. */
25
+ export const PASS_STATUSES = `(${TASK_STATUSES.filter(isPassStatus)
25
26
  .map((s) => `'${s}'`)
26
27
  .join(', ')})`
27
28
 
@@ -58,6 +59,12 @@ export interface TaskHistory {
58
59
  /** Map keyed by `project#task`. */
59
60
  export type HistoryTable = ReadonlyMap<string, TaskHistory>
60
61
 
62
+ /** What a scheduler learns from a task's history: its p50 and its resource maxima. */
63
+ export type TaskResources = Pick<
64
+ TaskHistory,
65
+ 'p50DurationMs' | 'maxPeakRssBytes' | 'maxCpuParallelism'
66
+ >
67
+
61
68
  export interface HistoryProvider {
62
69
  loadFor(taskIds: readonly string[]): Promise<HistoryTable>
63
70
  /**
@@ -213,6 +220,73 @@ export class LocalHistoryProvider implements HistoryProvider {
213
220
  return out
214
221
  }
215
222
 
223
+ /**
224
+ * `loadFor`'s p50 and resource maxima over the same window, without its
225
+ * rates. The rates group every row of the window and join each hit to
226
+ * its entry: 407 ms at 168,000 rows (8,002 tasks, 21 runs), most of a
227
+ * nothing-changed run's schedule stage. Here the executed successes are
228
+ * grouped alone, and the hits only as distinct keys, each joined to its
229
+ * entry once: 148 ms on the same table, the same numbers.
230
+ */
231
+ async resourcesFor(taskIds: readonly string[]): Promise<ReadonlyMap<string, TaskResources>> {
232
+ const out = new Map<string, TaskResources>()
233
+ if (taskIds.length === 0) return out
234
+ const floor = this.floor()
235
+ const wanted = new Set(taskIds)
236
+ type Executed = {
237
+ project: string
238
+ task: string
239
+ ds: string
240
+ rss: number | null
241
+ cpu: number | null
242
+ }
243
+ const executed = this.db
244
+ .query(
245
+ `SELECT project, task, GROUP_CONCAT(duration_ms) AS ds, MAX(peak_rss_bytes) AS rss,
246
+ MAX(CASE WHEN cpu_ms IS NOT NULL AND duration_ms > 0
247
+ THEN cpu_ms * 1.0 / duration_ms END) AS cpu
248
+ FROM runs
249
+ WHERE id >= ? AND status = 'success' AND (cache_hit IS NULL OR cache_hit = 0)
250
+ GROUP BY project, task`,
251
+ )
252
+ .all(floor) as Executed[]
253
+ for (const row of executed) {
254
+ const key = `${row.project}#${row.task}`
255
+ if (!wanted.has(key)) continue
256
+ const durations = row.ds.split(',').map(Number)
257
+ durations.sort((a, b) => a - b)
258
+ out.set(key, {
259
+ p50DurationMs: pickPercentile(durations, 0.5),
260
+ ...(row.rss !== null ? { maxPeakRssBytes: row.rss } : {}),
261
+ ...(row.cpu !== null ? { maxCpuParallelism: row.cpu } : {}),
262
+ })
263
+ }
264
+ type Hit = { project: string; task: string; rss: number | null; cpu: number | null }
265
+ const hits = this.db
266
+ .query(
267
+ `SELECT h.project, h.task, MAX(e.peak_rss_bytes) AS rss,
268
+ MAX(CASE WHEN e.cpu_ms IS NOT NULL AND e.duration_ms > 0
269
+ THEN e.cpu_ms * 1.0 / e.duration_ms END) AS cpu
270
+ FROM (SELECT DISTINCT project, task, hash FROM runs WHERE id >= ? AND cache_hit = 1) h
271
+ JOIN entries e ON e.hash = h.hash
272
+ GROUP BY h.project, h.task`,
273
+ )
274
+ .all(floor) as Hit[]
275
+ for (const row of hits) {
276
+ const key = `${row.project}#${row.task}`
277
+ if (!wanted.has(key) || (row.rss === null && row.cpu === null)) continue
278
+ const had = out.get(key)
279
+ const rss = Math.max(had?.maxPeakRssBytes ?? -Infinity, row.rss ?? -Infinity)
280
+ const cpu = Math.max(had?.maxCpuParallelism ?? -Infinity, row.cpu ?? -Infinity)
281
+ out.set(key, {
282
+ p50DurationMs: had?.p50DurationMs,
283
+ ...(rss !== -Infinity ? { maxPeakRssBytes: rss } : {}),
284
+ ...(cpu !== -Infinity ? { maxCpuParallelism: cpu } : {}),
285
+ })
286
+ }
287
+ return out
288
+ }
289
+
216
290
  /** The first `runs` id of the newest `recent` invocations (see `loadFor`). */
217
291
  private floor(): number {
218
292
  const row = this.db
@@ -56,6 +56,7 @@ export {
56
56
  type HistoryTable,
57
57
  LocalHistoryProvider,
58
58
  type TaskHistory,
59
+ type TaskResources,
59
60
  } from './history.js'
60
61
  export {
61
62
  detectFlaky,
@@ -35,7 +35,9 @@ export function linkPaths(text: string, dir: string, isFile: (abs: string) => bo
35
35
 
36
36
  /**
37
37
  * The files `text` names that exist (resolved against `dir`), each with the
38
- * line and column it gave, unique, in order of first appearance.
38
+ * line and column it gave, unique, in order of first appearance. A file also
39
+ * named with a line drops its bare mention: bun test heads a failure with
40
+ * `app.test.js:` and gives the line only in the stack below it.
39
41
  */
40
42
  export function fileLocations(
41
43
  text: string,
@@ -56,9 +58,9 @@ export function fileLocations(
56
58
  ...(line !== undefined ? { line: Number(line) } : {}),
57
59
  ...(col !== undefined ? { col: Number(col) } : {}),
58
60
  })
59
- if (out.length === LOCATIONS_CAP) break
60
61
  }
61
- return out
62
+ const lined = new Set(out.filter((l) => l.line !== undefined).map((l) => l.file))
63
+ return out.filter((l) => l.line !== undefined || !lined.has(l.file)).slice(0, LOCATIONS_CAP)
62
64
  }
63
65
 
64
66
  /** A file test that asks the file system once per path. */
@@ -127,7 +127,9 @@ export interface VxPlugin {
127
127
  /**
128
128
  * Scheduling priorities: task id → weight, higher runs first among READY
129
129
  * tasks (merged over the structural baseline, which stays the tie-break).
130
- * Runs once, after the graph is final. A later plugin's weight for a task
130
+ * Runs once, after the graph is final and the local hits are known, and
131
+ * only when a task will start: a run of confirmed local hits restores
132
+ * in no order a weight changes. A later plugin's weight for a task
131
133
  * overrides an earlier one's. Return undefined to leave the baseline.
132
134
  */
133
135
  schedule?(
@@ -102,9 +102,11 @@ export interface PreparedRun {
102
102
  /**
103
103
  * Scheduling priorities from plugins' `schedule` stage (task id → weight,
104
104
  * merged over the structural baseline by the scheduler). Empty when no
105
- * plugin declares the stage.
105
+ * plugin declares the stage. Asked only by a run with a task to start:
106
+ * the stage reads history (148 ms at 8,002 tasks) to order work a
107
+ * nothing-changed run never dispatches. Runs the stage once.
106
108
  */
107
- priorities: ReadonlyMap<string, number>
109
+ priorities: () => Promise<ReadonlyMap<string, number>>
108
110
  nodes: Map<string, TaskNode>
109
111
  /**
110
112
  * The tasks `--exclude-dependencies` took out of the schedule: keyed as a
@@ -491,7 +493,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
491
493
  localCache,
492
494
  hasRemoteLayer,
493
495
  cachePolicy: policy,
494
- priorities: new Map(),
496
+ priorities: () => Promise.resolve(new Map()),
495
497
  nodes: new Map(),
496
498
  keyOnly: new Map(),
497
499
  unresolvedTasks,
@@ -687,15 +689,16 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
687
689
  })
688
690
  }
689
691
  }
690
- let priorities: ReadonlyMap<string, number> = new Map()
691
- if (hasHook(plugins, 'schedule')) {
692
- priorities = await applyScheduleHooks(plugins, nodes, {
693
- workspaceRoot,
694
- cacheDir,
695
- warn: (m) => log.status(m),
696
- localCache,
697
- })
698
- }
692
+ let scheduled: Promise<ReadonlyMap<string, number>> | undefined
693
+ const priorities = (): Promise<ReadonlyMap<string, number>> =>
694
+ (scheduled ??= hasHook(plugins, 'schedule')
695
+ ? applyScheduleHooks(plugins, nodes, {
696
+ workspaceRoot,
697
+ cacheDir,
698
+ warn: (m) => log.status(m),
699
+ localCache,
700
+ })
701
+ : Promise.resolve(new Map()))
699
702
 
700
703
  return {
701
704
  workspaceRoot,
@@ -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 FROM runs
114
- WHERE run_id = ? AND status = 'failed' ORDER BY id`,
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 { project: string; task: string; exitCode: number; timedOut: number | null }[]
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
  }
@@ -1089,6 +1089,14 @@ async function runOnBus(
1089
1089
  })
1090
1090
 
1091
1091
  mark('classify + probe')
1092
+ // A run whose every task is a confirmed local hit dispatches only
1093
+ // restores, which no weight orders: the `schedule` stage is not asked.
1094
+ const priorities = [...nodes.values()].every(
1095
+ (n) => n.config.exec?.command === undefined || shortCircuit.restoreTier.has(n.id),
1096
+ )
1097
+ ? new Map<string, number>()
1098
+ : await prepared.priorities()
1099
+ mark('schedule')
1092
1100
  // Judged as each task finishes, against the history BEFORE this run's
1093
1101
  // rows land, so the row that prints next carries the verdict: nothing
1094
1102
  // prints below the footer. A hit or a skip asks nothing; a green miss
@@ -1172,7 +1180,7 @@ async function runOnBus(
1172
1180
  : {}),
1173
1181
  // A `schedule` plugin's weights; the scheduler keeps its structural
1174
1182
  // baseline as the tie-break. Empty map → baseline only.
1175
- ...(prepared.priorities.size > 0 ? { priorities: prepared.priorities } : {}),
1183
+ ...(priorities.size > 0 ? { priorities } : {}),
1176
1184
  // The baseline under them; `most-work` when the workspace names none.
1177
1185
  ...(prepared.workspaceConfig?.schedule !== undefined
1178
1186
  ? { strategy: prepared.workspaceConfig.schedule }
@@ -1718,6 +1726,8 @@ export async function planRun(options: RunOptions): Promise<RunPlan> {
1718
1726
  if (prepared.empty === 'none-affected')
1719
1727
  return { tasks: [], noneAffected: noneAffected(prepared, options) }
1720
1728
  if (prepared.empty !== null) return { tasks: [] }
1729
+ // The plan orders nothing, but it refuses what the run would refuse.
1730
+ await prepared.priorities()
1721
1731
  // Its own mark: a dry run's plan (every task's hash, the cache lookups,
1722
1732
  // the history p50s) was booked under `close`, the next mark, and read
1723
1733
  // 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` (`cache.rewrittenInputs: 'compare'`) 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) throw new UserError(`git cat-file --batch ended before ${ref}:./${file}`)
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(`git show ${ref}:./${file} failed (exit ${exit}): ${stderr.trim()}`)
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
 
@@ -136,6 +136,11 @@ function validateRetention(retention: unknown, configPath: string): void {
136
136
  }
137
137
  }
138
138
 
139
+ /** A `plugins` entry that stands for no plugin: `undefined`, `null` or `false`. */
140
+ export function isSkippedPlugin(p: unknown): p is undefined | null | false {
141
+ return p === undefined || p === null || p === false
142
+ }
143
+
139
144
  export function validateWorkspace(config: WorkspaceConfig, configPath: string): void {
140
145
  // No JSON-data rule here, unlike `validateProjectConfig`: `plugins` holds
141
146
  // objects of functions by design, the file is evaluated in-process on
@@ -206,7 +211,10 @@ export function validateWorkspace(config: WorkspaceConfig, configPath: string):
206
211
  }
207
212
  const verbOwners = new Map<string, string>()
208
213
  const fileClaimants = new Map<string, { name: string; at: number }>()
209
- for (const [i, p] of config.plugins.entries()) {
214
+ for (const [i, p] of (config.plugins as readonly unknown[]).entries()) {
215
+ // A factory that declines returns nothing, and `cond && plugin()` is
216
+ // false: both are skipped, as Vite skips them.
217
+ if (isSkippedPlugin(p)) continue
210
218
  if (p === null || typeof p !== 'object') {
211
219
  // Nx's nx.json lists plugins by module name (`'@nx/vite/plugin'`);
212
220
  // a vx plugin is the value its package's function returns.
@@ -660,6 +668,10 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
660
668
  )
661
669
  }
662
670
  assertKnownFields(cache, CACHE_FIELDS, `${where}.cache`)
671
+ const rewritten = (cache as { rewrittenInputs?: unknown }).rewrittenInputs
672
+ if (rewritten !== undefined && rewritten !== 'withhold' && rewritten !== 'compare') {
673
+ throw new UserError(`${where}.cache.rewrittenInputs must be 'withhold' or 'compare'`)
674
+ }
663
675
  const inputs = (cache as { inputs?: unknown }).inputs
664
676
  const outputs = (cache as { outputs?: unknown }).outputs
665
677
  // A present field of the wrong shape (`outputs: 'dist'`) was told it
@@ -896,7 +908,7 @@ const EXEC_FIELDS = new Set([
896
908
  ])
897
909
  const PERSISTENT_FIELDS = new Set(['readyWhen'])
898
910
  const ENV_FIELDS = new Set(['passThrough', 'define', 'secret'])
899
- const CACHE_FIELDS = new Set(['inputs', 'outputs'])
911
+ const CACHE_FIELDS = new Set(['inputs', 'outputs', 'rewrittenInputs'])
900
912
  const CACHE_INPUT_FIELDS = new Set([
901
913
  'files',
902
914
  'workspaceFiles',