@vzn/vx 0.0.578 → 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.578",
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.578",
25
- "@vzn/vx-linux-arm64": "0.0.578",
26
- "@vzn/vx-darwin-x64": "0.0.578",
27
- "@vzn/vx-darwin-arm64": "0.0.578"
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
@@ -46,6 +46,12 @@ export interface WorkspaceConfig {
46
46
  * folded into a cache key.
47
47
  */
48
48
  cacheScope?: string
49
+ /**
50
+ * Graph rules a run checks before it schedules anything. Each is on unless
51
+ * set to `false`; turning one off allows a shape vx handles correctly but
52
+ * more slowly. Never folded into a cache key.
53
+ */
54
+ rules?: WorkspaceRules
49
55
  /**
50
56
  * Plugins registered for this workspace, consulted in this order once
51
57
  * per `vx run`: each fills any of the pipeline stages and capabilities
@@ -55,6 +61,23 @@ export interface WorkspaceConfig {
55
61
  plugins?: readonly Plugin[]
56
62
  }
57
63
 
64
+ /** `WorkspaceConfig.rules`. */
65
+ export interface WorkspaceRules {
66
+ /**
67
+ * Refuse two tasks whose declared outputs overlap even when a `dependsOn`
68
+ * edge orders them. Off, the dependant adds to its upstream's tree and
69
+ * owns only what its run added (`caching.md` § Additive outputs). Two
70
+ * overlapping tasks with no edge are refused either way.
71
+ */
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
79
+ }
80
+
58
81
  /**
59
82
  * The runtime brand `definePlugin` stamps on a plugin: the name of the
60
83
  * package it was defined in. The workspace loader accepts a plugin only
@@ -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,
@@ -1,5 +1,5 @@
1
1
  import path from 'node:path'
2
- import type { ProjectConfig, TaskConfig } from '../config.js'
2
+ import type { ProjectConfig, TaskConfig, WorkspaceRules } from '../config.js'
3
3
  import {
4
4
  asTrees,
5
5
  isLiteralPattern,
@@ -267,6 +267,8 @@ export interface BuildGraphOptions {
267
267
  * with other root-anchored ones.
268
268
  */
269
269
  workspaceRoot?: string
270
+ /** The workspace's `rules`; each is on unless set to `false`. */
271
+ rules?: WorkspaceRules | undefined
270
272
  }
271
273
 
272
274
  /**
@@ -519,7 +521,7 @@ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode
519
521
  }
520
522
  }
521
523
 
522
- checkGraph(nodes, options.workspaceRoot)
524
+ checkGraph(nodes, options.workspaceRoot, options.rules)
523
525
  return nodes
524
526
  }
525
527
 
@@ -534,7 +536,11 @@ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode
534
536
  * marks are derived here, so they are cleared first and follow the edges
535
537
  * the graph has now.
536
538
  */
537
- export function checkGraph(nodes: Map<string, TaskNode>, workspaceRoot?: string): void {
539
+ export function checkGraph(
540
+ nodes: Map<string, TaskNode>,
541
+ workspaceRoot?: string,
542
+ rules?: WorkspaceRules,
543
+ ): void {
538
544
  for (const [key, node] of nodes) {
539
545
  if (node.id !== key) {
540
546
  throw new Error(`the task ${node.id} is stored under '${key}', not its own id`)
@@ -548,7 +554,8 @@ export function checkGraph(nodes: Map<string, TaskNode>, workspaceRoot?: string)
548
554
  delete node.outputsAddedToBy
549
555
  }
550
556
  detectCycle(nodes)
551
- detectOutputCollisions(nodes, workspaceRoot)
557
+ detectOutputCollisions(nodes, workspaceRoot, rules?.exclusiveOutputs !== false)
558
+ if (rules?.upfrontKeys !== false) detectInputOverlaps(nodes, workspaceRoot)
552
559
  }
553
560
 
554
561
  /**
@@ -770,7 +777,11 @@ function covers(tree: string, glob: string): boolean {
770
777
  * project boundaries by design, so ANY two tasks can. No cache key changes —
771
778
  * this only refuses a graph that was already destroying files.
772
779
  */
773
- function detectOutputCollisions(nodes: Map<string, TaskNode>, workspaceRoot?: string): void {
780
+ function detectOutputCollisions(
781
+ nodes: Map<string, TaskNode>,
782
+ workspaceRoot: string | undefined,
783
+ exclusive: boolean,
784
+ ): void {
774
785
  // Does `from` reach `to` through deps? Asked only for a colliding pair,
775
786
  // so the walk is rare; memoised per source across the detector's calls.
776
787
  const reachMemo = new Map<string, Set<string>>()
@@ -834,14 +845,14 @@ function detectOutputCollisions(nodes: Map<string, TaskNode>, workspaceRoot?: st
834
845
  for (const [i, j] of overlapCandidates(bucket, filesOf)) {
835
846
  const a = bucket[i]!
836
847
  const b = bucket[j]!
837
- collide(a, b, filesOf(a), filesOf(b), 'files', reaches)
848
+ collide(a, b, filesOf(a), filesOf(b), 'files', reaches, exclusive)
838
849
  }
839
850
  }
840
851
  if (wsDeclarers.length >= 2) {
841
852
  for (const [i, j] of overlapCandidates(wsDeclarers, wsFilesOf)) {
842
853
  const a = wsDeclarers[i]!
843
854
  const b = wsDeclarers[j]!
844
- collide(a, b, wsFilesOf(a), wsFilesOf(b), 'workspaceFiles', reaches)
855
+ collide(a, b, wsFilesOf(a), wsFilesOf(b), 'workspaceFiles', reaches, exclusive)
845
856
  }
846
857
  }
847
858
  // A root-anchored output that reaches into ANOTHER project's `files`
@@ -870,7 +881,7 @@ function detectOutputCollisions(nodes: Map<string, TaskNode>, workspaceRoot?: st
870
881
  const y = entries[j]!
871
882
  if ((x.rel === undefined) === (y.rel === undefined) || x.node === y.node) continue
872
883
  const [ws, files] = x.rel === undefined ? [x, y] : [y, x]
873
- collideAcross(ws.node, ws.globs, files.node, files.globs, files.rel!, reaches)
884
+ collideAcross(ws.node, ws.globs, files.node, files.globs, files.rel!, reaches, exclusive)
874
885
  }
875
886
  }
876
887
 
@@ -889,11 +900,20 @@ function collideAcross(
889
900
  rebased: readonly string[],
890
901
  rel: string,
891
902
  reaches: (from: string, to: string) => boolean,
903
+ exclusive: boolean,
892
904
  ): void {
893
905
  if (neverWritesLocally(ws) || neverWritesLocally(files)) return
894
906
  for (const ga of wsGlobs) {
895
907
  for (const gb of rebased) {
896
908
  if (!outputsOverlap(ga, gb)) continue
909
+ const head =
910
+ `${ws.id} declares the output ${JSON.stringify(ga)} in cache.outputs.workspaceFiles, ` +
911
+ `inside ${files.id}'s ${JSON.stringify(gb.slice(rel === '' ? 0 : rel.length + 1))} in ` +
912
+ `cache.outputs.files — vx cleans a task's declared outputs before it runs and before a ` +
913
+ `cache-hit restore, so whichever of these runs second DELETES the other's output`
914
+ if (exclusive && (reaches(files.id, ws.id) || reaches(ws.id, files.id))) {
915
+ throw new UserError(`${head}. ${EXCLUSIVE_OUTPUTS_FIX}`)
916
+ }
897
917
  if (reaches(files.id, ws.id)) {
898
918
  ;(files.addsToOutputsOf ??= []).push(ws.id)
899
919
  ;(ws.outputsAddedToBy ??= []).push(...rebased)
@@ -910,12 +930,8 @@ function collideAcross(
910
930
  }
911
931
  }
912
932
  throw new UserError(
913
- `${ws.id} declares the output ${JSON.stringify(ga)} in cache.outputs.workspaceFiles, ` +
914
- `inside ${files.id}'s ${JSON.stringify(gb.slice(rel === '' ? 0 : rel.length + 1))} in ` +
915
- `cache.outputs.files — vx cleans a task's declared outputs before it runs and before a ` +
916
- `cache-hit restore, so whichever of these runs second DELETES the other's output and ` +
917
- `the run still reports success. Give each task its own output path, or make one ` +
918
- `depend on the other.`,
933
+ `${head} and the run still reports success. Give each task its own output path` +
934
+ (exclusive ? '.' : ', or make one depend on the other.'),
919
935
  )
920
936
  }
921
937
  }
@@ -1039,6 +1055,15 @@ function neverWritesLocally(n: TaskNode): boolean {
1039
1055
  return n.config.exec?.remote === 'only'
1040
1056
  }
1041
1057
 
1058
+ /**
1059
+ * The way out of an edge-ordered overlap under `rules.exclusiveOutputs`: an
1060
+ * ordered pair is correct (the addition shape, item 588), only slower to
1061
+ * clean and restore, so the rule may be turned off.
1062
+ */
1063
+ const EXCLUSIVE_OUTPUTS_FIX =
1064
+ 'Give each task its own output path, or set rules: { exclusiveOutputs: false } in ' +
1065
+ "vx.workspace.ts to let a dependant add to its upstream's outputs."
1066
+
1042
1067
  function collide(
1043
1068
  a: TaskNode,
1044
1069
  b: TaskNode,
@@ -1046,17 +1071,26 @@ function collide(
1046
1071
  bGlobs: readonly string[] | undefined,
1047
1072
  field: 'files' | 'workspaceFiles',
1048
1073
  reaches: (from: string, to: string) => boolean,
1074
+ exclusive: boolean,
1049
1075
  ): void {
1050
1076
  if (neverWritesLocally(a) || neverWritesLocally(b)) return
1051
1077
  for (const ga of aGlobs ?? []) {
1052
1078
  for (const gb of bGlobs ?? []) {
1053
1079
  if (!outputsOverlap(ga, gb)) continue
1080
+ const head =
1081
+ `${a.id} and ${b.id} both declare the output ${JSON.stringify(ga)}` +
1082
+ (ga === gb ? '' : ` / ${JSON.stringify(gb)}`) +
1083
+ ` in cache.outputs.${field} — vx cleans a task's declared outputs before it runs and ` +
1084
+ `before a cache-hit restore, so whichever of these runs second DELETES the other's ` +
1085
+ `output`
1054
1086
  // An overlap WITH an edge is the addition shape (item 588): the
1055
1087
  // dependant runs after its upstream and adds to that tree, so the
1056
1088
  // order is fixed and the dependant's own set can be told apart from
1057
- // what it found. Marked on both, and the pair is allowed. Without an
1058
- // edge the two run in either order, and the refusal below stands.
1089
+ // what it found. Marked on both, and the pair is allowed when
1090
+ // `rules.exclusiveOutputs` is off (X-53). Without an edge the two run
1091
+ // in either order, and the refusal below stands.
1059
1092
  const [up, down] = reaches(b.id, a.id) ? [a, b] : reaches(a.id, b.id) ? [b, a] : []
1093
+ if (up !== undefined && exclusive) throw new UserError(`${head}. ${EXCLUSIVE_OUTPUTS_FIX}`)
1060
1094
  if (up !== undefined && down !== undefined) {
1061
1095
  const downGlobs = splitNegations(
1062
1096
  (field === 'files'
@@ -1068,11 +1102,137 @@ function collide(
1068
1102
  return
1069
1103
  }
1070
1104
  throw new UserError(
1071
- `${a.id} and ${b.id} both declare the output ${JSON.stringify(ga)}` +
1072
- (ga === gb ? '' : ` / ${JSON.stringify(gb)}`) +
1073
- ` in cache.outputs.${field} — vx cleans a task's declared outputs before it runs and ` +
1074
- `before a cache-hit restore, so whichever of these runs second DELETES the other's ` +
1075
- `output and the run still reports success. Give each task its own output path.`,
1105
+ `${head} and the run still reports success. Give each task its own output path.`,
1106
+ )
1107
+ }
1108
+ }
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.`,
1076
1236
  )
1077
1237
  }
1078
1238
  }
@@ -10,7 +10,7 @@
10
10
  import { ChainedCache, type CacheLayer } from '../cache/index.js'
11
11
  import { localExecutor, type TaskExecutor } from '../exec/index.js'
12
12
  import { settleWithin, teardownTimeoutMs, UserError } from '../util/index.js'
13
- import type { ProjectConfig, WorkspaceConfig } from '../config.js'
13
+ import type { ProjectConfig, WorkspaceConfig, WorkspaceRules } from '../config.js'
14
14
  import { checkGraph, type TaskNode } from '../graph/index.js'
15
15
  import type {
16
16
  CacheContext,
@@ -178,6 +178,7 @@ export async function applyGraphHooks(
178
178
  ctx: GraphHookContext,
179
179
  /** Runs after EACH plugin's edit, so a refused task config names the plugin that wrote it. */
180
180
  afterEach?: (plugin: VxPlugin) => void,
181
+ rules?: WorkspaceRules,
181
182
  ): Promise<void> {
182
183
  let last: VxPlugin | undefined
183
184
  for (const plugin of plugins) {
@@ -188,7 +189,7 @@ export async function applyGraphHooks(
188
189
  last = plugin
189
190
  }
190
191
  if (last === undefined) return
191
- await safe(last, 'graph', () => checkGraph(nodes, ctx.workspaceRoot))
192
+ await safe(last, 'graph', () => checkGraph(nodes, ctx.workspaceRoot, rules))
192
193
  }
193
194
 
194
195
  /**
@@ -515,6 +515,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
515
515
  packageGraph,
516
516
  requested: req,
517
517
  workspaceRoot,
518
+ rules: workspaceConfig?.rules,
518
519
  ...(projects.size < projectsWithConfigs.length
519
520
  ? { undeclaredDeps: (id: string, name: string) => void unproven.push([id, name]) }
520
521
  : {}),
@@ -628,6 +629,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
628
629
  )
629
630
  }
630
631
  },
632
+ workspaceConfig?.rules,
631
633
  )
632
634
  }
633
635
  // Args after `--` go to requested commands only; with none among them
@@ -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).
@@ -54,9 +54,25 @@ const WORKSPACE_FIELDS = new Set([
54
54
  'cacheRetention',
55
55
  'affectedBase',
56
56
  'cacheScope',
57
+ 'rules',
57
58
  'plugins',
58
59
  ])
59
60
 
61
+ const RULE_FIELDS = new Set(['exclusiveOutputs', 'upfrontKeys'])
62
+
63
+ function validateRules(rules: unknown, configPath: string): void {
64
+ const where = `${configPath}: \`rules\``
65
+ if (rules === null || typeof rules !== 'object' || Array.isArray(rules)) {
66
+ throw new UserError(`${where} must be { exclusiveOutputs?: boolean; upfrontKeys?: boolean }`)
67
+ }
68
+ assertKnownFields(rules as Record<string, unknown>, RULE_FIELDS, where)
69
+ for (const [name, value] of Object.entries(rules)) {
70
+ if (value !== undefined && typeof value !== 'boolean') {
71
+ throw new UserError(`${where}.${name} must be true or false`)
72
+ }
73
+ }
74
+ }
75
+
60
76
  const RETENTION_FIELDS = new Set(['olderThan', 'maxSize'])
61
77
 
62
78
  function validateRetention(retention: unknown, configPath: string): void {
@@ -142,6 +158,7 @@ export function validateWorkspace(config: WorkspaceConfig, configPath: string):
142
158
  if (config.cacheScope !== undefined && !isCacheScope(config.cacheScope)) {
143
159
  throw new UserError(`${configPath}: \`cacheScope\` ${CACHE_SCOPE_RULE}`)
144
160
  }
161
+ if (config.rules !== undefined) validateRules(config.rules, configPath)
145
162
  if (config.plugins !== undefined) {
146
163
  if (!Array.isArray(config.plugins)) {
147
164
  throw new UserError(`${configPath}: \`plugins\` must be an array of plugin objects`)