@roubo/shared 0.1.0

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.
@@ -0,0 +1,432 @@
1
+ import { z } from "zod";
2
+
3
+ // ── Sub-schemas ──
4
+
5
+ export const JigSettingsSchema = z.object({
6
+ autoInject: z.boolean(),
7
+ autoExecute: z.boolean(),
8
+ defaultJigId: z.string().optional(),
9
+ issueTypeMappings: z.record(z.string(), z.string()).optional(),
10
+ });
11
+ export type JigSettings = z.infer<typeof JigSettingsSchema>;
12
+
13
+ export const ProjectConfigSchema = z
14
+ .object({
15
+ name: z
16
+ .string()
17
+ .regex(/^[a-z0-9-]+$/, "Must contain only lowercase letters, numbers, and hyphens"),
18
+ displayName: z.string().min(1, "Required"),
19
+ // FR-070 (WU-057): repo and github.project moved to the plugin Configure
20
+ // modal. Optional here so a fresh project saves cleanly with name +
21
+ // displayName only; the user fills these in from the active plugin's tab
22
+ // afterwards.
23
+ repo: z.string().min(1, "Required").optional(),
24
+ github: z.object({ project: z.int() }).strict().optional(),
25
+ jigSettings: JigSettingsSchema.optional(),
26
+ })
27
+ .strict();
28
+ export type ProjectConfig = z.infer<typeof ProjectConfigSchema>;
29
+
30
+ export const LayoutConfigSchema = z
31
+ .object({
32
+ type: z.enum(["meta-repo", "monorepo", "single-repo"]),
33
+ submodules: z.record(z.string(), z.string()).optional(),
34
+ })
35
+ .strict();
36
+ export type LayoutConfig = z.infer<typeof LayoutConfigSchema>;
37
+
38
+ export const DockerComponentConfigSchema = z
39
+ .object({
40
+ composeFile: z.string(),
41
+ service: z.string(),
42
+ initService: z.string().optional(),
43
+ portEnvVar: z.string().optional(),
44
+ })
45
+ .strict();
46
+ export type DockerComponentConfig = z.infer<typeof DockerComponentConfigSchema>;
47
+
48
+ export const MigrationConfigSchema = z
49
+ .object({
50
+ command: z.string(),
51
+ args: z.array(z.string()).optional(),
52
+ })
53
+ .strict();
54
+ export type MigrationConfig = z.infer<typeof MigrationConfigSchema>;
55
+
56
+ export const ConnectionConfigSchema = z
57
+ .object({
58
+ template: z.string(),
59
+ })
60
+ .strict();
61
+ export type ConnectionConfig = z.infer<typeof ConnectionConfigSchema>;
62
+
63
+ // Component-to-plugin binding reference (issue #608, FR-010). This is the
64
+ // plugin REFERENCE: a component points at a `component`-kind plugin by `id`
65
+ // (with an optional `source`). The host's ComponentPluginRegistry reads
66
+ // `plugin.id` off the parsed component to resolve the live JSON-RPC connection.
67
+ // This is the reference shape; the full components-map ENTRY (the opaque
68
+ // per-plugin `config` block + `dependsOn`) is `ComponentConfigSchema` below.
69
+ export const ComponentBindingSchema = z
70
+ .object({
71
+ id: z.string().min(1, "Required"),
72
+ source: z.string().min(1).optional(),
73
+ })
74
+ .strict();
75
+ export type ComponentBinding = z.infer<typeof ComponentBindingSchema>;
76
+
77
+ // ── Components-map entry (FR-003 / #609): the canonical component binding ──
78
+ //
79
+ // A component binds to a component plugin (`plugin: { id, source? }`) plus an
80
+ // opaque `config` block the plugin's own manifest `configSchema` validates
81
+ // (host-side, see `validateComponentBindings`). `config` is opaque to core,
82
+ // exactly like the integration `advanced` block above. `dependsOn` stays at the
83
+ // entry level so start/stop ordering (FR-003) is preserved without reaching
84
+ // into the plugin-owned config. The two-value `type: database|process` enum and
85
+ // the inline docker/process fields are intentionally gone from this schema
86
+ // (NFR-005: no config back-compat). The legacy inline fields survive only on
87
+ // the TS `ComponentConfig` type as a transition shim (see below): core
88
+ // consumers (bench-manager, config-parser) and the setup wizard still read
89
+ // `.type` / `.docker` / `.command` etc. off a parsed component until that
90
+ // behavioural dispatch moves onto the plugin contract in #612 (F1.11) and the
91
+ // live-config migration of #614 (F1.13); both are out of scope for #609. Do NOT
92
+ // grow new behaviour onto this shim: new component behaviour rides the plugin
93
+ // contract.
94
+ export const ComponentConfigSchema = z
95
+ .object({
96
+ plugin: ComponentBindingSchema,
97
+ // Opaque to roubo-core; validated against the bound plugin's configSchema
98
+ // by `validateComponentBindings` once the component plugin manifests load.
99
+ config: z.record(z.string(), z.unknown()).default({}),
100
+ dependsOn: z.array(z.string()).optional(),
101
+ })
102
+ .strict();
103
+
104
+ // The legacy two-value component discriminator. The `type` enum is GONE from
105
+ // the zod schema (#609); this type is retained ONLY for the transition shim so
106
+ // the setup wizard and repo-scanner keep type-checking while their legacy
107
+ // inline-component editing/scanning is ported to the plugin contract (#612 /
108
+ // #614, out of scope here).
109
+ export type ComponentType = "database" | "process";
110
+
111
+ // The optional legacy inline component descriptor. These fields are NOT in the
112
+ // zod schema (it is `.strict()` to the new binding shape) and are never
113
+ // populated at parse time once configs migrate; they exist purely as a TS
114
+ // transition shim for core consumers (bench-manager, config-parser) and the
115
+ // setup wizard, which still read `.type` / `.docker` / `.command` etc. off a
116
+ // parsed component until #612 (F1.11) moves that dispatch onto the plugin
117
+ // contract and #614 (F1.13) migrates the live configs.
118
+ export interface LegacyComponentInline {
119
+ type?: ComponentType;
120
+ command?: string;
121
+ setup?: string;
122
+ docker?: DockerComponentConfig;
123
+ migration?: MigrationConfig;
124
+ connection?: ConnectionConfig;
125
+ env?: Record<string, string>;
126
+ directory?: string;
127
+ envFile?: string;
128
+ envVars?: Record<string, string>;
129
+ image?: string;
130
+ }
131
+
132
+ // The components-map entry TS type. `plugin` + `config` + `dependsOn` are the
133
+ // canonical binding fields the zod `ComponentConfigSchema` validates; they are
134
+ // typed loosely here (plugin/config optional) so the #609-deferred consumers
135
+ // and the live-config migration (#614, F1.13) keep type-checking against
136
+ // pre-migration fixtures and in-progress wizard drafts during the transition.
137
+ // The legacy inline fields ride alongside as the transition shim described
138
+ // above. The zod schema never populates the legacy keys, so they are
139
+ // `undefined` at runtime once configs migrate.
140
+ export type ComponentConfig = {
141
+ plugin?: ComponentBinding;
142
+ config?: Record<string, unknown>;
143
+ dependsOn?: string[];
144
+ } & LegacyComponentInline;
145
+
146
+ export const PortConfigSchema = z
147
+ .object({
148
+ base: z.int().min(1).max(65535),
149
+ https: z.boolean().optional(),
150
+ })
151
+ .strict();
152
+ export type PortConfig = z.infer<typeof PortConfigSchema>;
153
+
154
+ const LoginStepFillSchema = z
155
+ .object({
156
+ selector: z.string(),
157
+ action: z.literal("fill"),
158
+ value: z.string().min(1),
159
+ })
160
+ .strict();
161
+
162
+ const LoginStepClickSchema = z
163
+ .object({
164
+ selector: z.string(),
165
+ action: z.literal("click"),
166
+ value: z.string().min(1).optional(),
167
+ })
168
+ .strict();
169
+
170
+ export const LoginStepSchema = z.discriminatedUnion("action", [
171
+ LoginStepFillSchema,
172
+ LoginStepClickSchema,
173
+ ]);
174
+ export type LoginStep = z.infer<typeof LoginStepSchema>;
175
+
176
+ export const LoginConfigSchema = z
177
+ .object({
178
+ steps: z.array(LoginStepSchema).min(1),
179
+ })
180
+ .strict();
181
+ export type LoginConfig = z.infer<typeof LoginConfigSchema>;
182
+
183
+ const BrowserToolConfigSchema = z
184
+ .object({
185
+ type: z.literal("browser"),
186
+ name: z.string(),
187
+ icon: z.string(),
188
+ url: z.string().optional(),
189
+ requires: z.string().optional(),
190
+ login: LoginConfigSchema.optional(),
191
+ })
192
+ .strict();
193
+
194
+ const ShellToolConfigSchema = z
195
+ .object({
196
+ type: z.literal("shell"),
197
+ name: z.string(),
198
+ icon: z.string(),
199
+ command: z.string().optional(),
200
+ requires: z.string().optional(),
201
+ })
202
+ .strict();
203
+
204
+ export const ToolConfigSchema = z.discriminatedUnion("type", [
205
+ BrowserToolConfigSchema,
206
+ ShellToolConfigSchema,
207
+ ]);
208
+ // Flat type for backward compat: Zod validates using the strict discriminated union above.
209
+ export type ToolConfig = {
210
+ type: "browser" | "shell";
211
+ name: string;
212
+ icon: string;
213
+ url?: string;
214
+ command?: string;
215
+ requires?: string;
216
+ login?: LoginConfig;
217
+ };
218
+
219
+ export const InspectionConfigSchema = z
220
+ .object({
221
+ framework: z.string(),
222
+ directory: z.string(),
223
+ command: z.string(),
224
+ env: z.record(z.string(), z.string()).optional(),
225
+ })
226
+ .strict();
227
+ export type InspectionConfig = z.infer<typeof InspectionConfigSchema>;
228
+
229
+ export const BenchesConfigSchema = z
230
+ .object({
231
+ max: z.int().min(1).max(99),
232
+ setup: z.string().optional(),
233
+ enforceIssueDependencies: z.boolean().optional(),
234
+ })
235
+ .strict();
236
+ export type BenchesConfig = z.infer<typeof BenchesConfigSchema>;
237
+
238
+ export const JigsConfigSchema = z
239
+ .object({
240
+ defaultJig: z.string().optional(),
241
+ issueTypeMappings: z.record(z.string(), z.string()).optional(),
242
+ })
243
+ .strict();
244
+ export type JigsConfig = z.infer<typeof JigsConfigSchema>;
245
+
246
+ export const UserConfigSchema = z
247
+ .object({
248
+ name: z.string().min(1),
249
+ properties: z.record(z.string(), z.string()),
250
+ })
251
+ .strict();
252
+ export type UserConfig = z.infer<typeof UserConfigSchema>;
253
+
254
+ // Plugin-defined, opaque-to-roubo sub-block (e.g. `allowSelfSignedTls`, Jira
255
+ // link-type names). Validated against the plugin's manifest configSchema once
256
+ // the active plugin is loaded, mirroring how Roubo treats jig
257
+ // frontmatter.
258
+ export const IntegrationAdvancedSchema = z.record(z.string(), z.unknown());
259
+ export type IntegrationAdvanced = z.infer<typeof IntegrationAdvancedSchema>;
260
+
261
+ // Identity captured from `plugin.getCurrentUser` at the last successful
262
+ // `validateConfig` round-trip (FR-035). Persisted per-project so subsequent
263
+ // `assignIssue` calls targeting "me" use the resolved external id.
264
+ export const CapturedUserIdSchema = z
265
+ .object({
266
+ externalId: z.string().min(1),
267
+ displayName: z.string().min(1),
268
+ })
269
+ .strict();
270
+ export type CapturedUserId = z.infer<typeof CapturedUserIdSchema>;
271
+
272
+ // Per-source entries accept either the legacy primitive form (`"owner/repo"`
273
+ // or `42`) or an object form that carries Roubo-core-reserved per-source
274
+ // fields like `excludedStatuses` (FR-062, FR-063) and the bundled
275
+ // github.com / GHE alert-category booleans (FR-074). Plugins MUST NOT use
276
+ // any of these reserved keys in their own configSchema.
277
+ export const SourceEntrySchema = z.union([
278
+ z.string(),
279
+ z.number(),
280
+ z
281
+ .object({
282
+ externalId: z.union([z.string(), z.number()]),
283
+ // Human-readable display name + secondary line, captured at pick time so
284
+ // the UI can show the source's name on reload without re-fetching. Display
285
+ // only; the plugin ignores them.
286
+ label: z.string().optional(),
287
+ sublabel: z.string().optional(),
288
+ // Jira project key the source is scoped to (project-first model). Also
289
+ // present on the synthetic `mine` source when its scope is in-project.
290
+ project: z.string().optional(),
291
+ // Board sources: active sprint only vs the whole board's backing filter.
292
+ boardMode: z.enum(["active-sprint", "whole-board"]).optional(),
293
+ // "Assigned to me" synthetic source: scoped to the project or instance-wide.
294
+ mineScope: z.enum(["in-project", "anywhere"]).optional(),
295
+ excludedStatuses: z.array(z.string().min(1)).optional(),
296
+ includeCodeQLAlerts: z.boolean().optional(),
297
+ includeSecretScanningAlerts: z.boolean().optional(),
298
+ includeDependabotAlerts: z.boolean().optional(),
299
+ })
300
+ .strict(),
301
+ ]);
302
+ export type SourceEntry = z.infer<typeof SourceEntrySchema>;
303
+
304
+ export const IntegrationConfigSchema = z
305
+ .object({
306
+ plugin: z.string().optional(),
307
+ instance: z.string().optional(),
308
+ sources: z.record(z.string(), z.array(SourceEntrySchema)).optional(),
309
+ advanced: IntegrationAdvancedSchema.optional(),
310
+ pluginSource: z.string().optional(),
311
+ // Page size forwarded to the plugin's listIssues call. Default 50 (FR-022, NFR-005).
312
+ pageSize: z.number().int().positive().optional(),
313
+ capturedUserId: CapturedUserIdSchema.optional(),
314
+ // Per-project layer of the three-layer excludedStatuses merge (FR-062).
315
+ // Plugin-global defaults live in plugin manifests; per-source overrides
316
+ // ride alongside `sources[<cat>][<i>]` object entries and are resolved
317
+ // by `applyPerSourceExcludedStatuses`.
318
+ excludedStatuses: z.array(z.string().min(1)).optional(),
319
+ // Category-first default exclusion (FR-010): a user-editable list of Jira
320
+ // status *categories* (e.g. "Done") applied in the query so excluded issues
321
+ // never reach a result page. Plugin-global default is seeded in the manifest
322
+ // and resolved at the root level by `resolveRootExclusion`; the jira plugin
323
+ // emits `statusCategory not in (...)` and falls back to `excludedStatuses`
324
+ // names when the instance does not support `statusCategory` in JQL.
325
+ excludedStatusCategories: z.array(z.string().min(1)).optional(),
326
+ // Cut-list sort selection (CLI-FR-009/CLI-FR-013/CLI-FR-017). `sortBy` is a
327
+ // field id the active plugin declared via `getSortFields`; `sortDir` is the
328
+ // direction. Persisted at the project level and overridable per user (the
329
+ // override file rides the same IntegrationConfigSchema), resolved at query
330
+ // time and forwarded into `listIssues` so the plugin orders source-side.
331
+ // Absent means the plugin's natural order (key-ascending fallback).
332
+ sortBy: z.string().min(1).optional(),
333
+ sortDir: z.enum(["asc", "desc"]).optional(),
334
+ })
335
+ .strict();
336
+ export type IntegrationConfig = z.infer<typeof IntegrationConfigSchema>;
337
+
338
+ // Per-user override file at `~/.roubo/integrations/<projectId>.yaml`. The
339
+ // envelope versions the file so a future shape change fails loudly on the
340
+ // `schemaVersion` literal rather than silently mis-merging.
341
+ export const IntegrationOverrideSchema = z
342
+ .object({
343
+ schemaVersion: z.literal(1),
344
+ integration: IntegrationConfigSchema,
345
+ })
346
+ .strict();
347
+ export type IntegrationOverride = z.infer<typeof IntegrationOverrideSchema>;
348
+
349
+ // components and ports are optional: a project may be just a worktree with jigs
350
+ // and tools and no long-running services. Both default to {} so downstream
351
+ // consumers always see a real (possibly empty) object.
352
+ const ComponentsMapSchema = z.record(z.string(), ComponentConfigSchema);
353
+
354
+ const PortsMapSchema = z.record(z.string(), PortConfigSchema);
355
+
356
+ const UsersArraySchema = z.array(UserConfigSchema).superRefine((users, ctx) => {
357
+ const seen = new Set<string>();
358
+ for (let i = 0; i < users.length; i++) {
359
+ const key =
360
+ users[i].name +
361
+ "\0" +
362
+ JSON.stringify(Object.fromEntries(Object.entries(users[i].properties).sort()));
363
+ if (seen.has(key)) {
364
+ ctx.addIssue({
365
+ code: z.ZodIssueCode.custom,
366
+ path: [i],
367
+ message: "Duplicate user entries are not allowed",
368
+ });
369
+ return;
370
+ }
371
+ seen.add(key);
372
+ }
373
+ });
374
+
375
+ export const RouboConfigSchema = z
376
+ .object({
377
+ project: ProjectConfigSchema,
378
+ layout: LayoutConfigSchema,
379
+ components: ComponentsMapSchema.default({}),
380
+ ports: PortsMapSchema.default({}),
381
+ tools: z.array(ToolConfigSchema).optional(),
382
+ inspection: InspectionConfigSchema.optional(),
383
+ benches: BenchesConfigSchema,
384
+ jigs: JigsConfigSchema.optional(),
385
+ integration: IntegrationConfigSchema.optional(),
386
+ users: UsersArraySchema.optional(),
387
+ })
388
+ .strict()
389
+ .superRefine((val, ctx) => {
390
+ const submodules = val.layout?.submodules;
391
+ if (submodules && "." in submodules) {
392
+ ctx.addIssue({
393
+ code: z.ZodIssueCode.custom,
394
+ path: ["layout", "submodules"],
395
+ message:
396
+ 'submodule key "." is reserved for the meta-repo root work unit and cannot be declared in roubo.yaml',
397
+ });
398
+ }
399
+ });
400
+ // Use the flat ToolConfig type for the tools field so callers don't need to
401
+ // narrow the discriminated union. `components` is widened to `ComponentConfig`
402
+ // (the binding fields + the legacy inline-descriptor shim) so #609-deferred
403
+ // consumers (#612 / #614) keep type-checking against `.docker` / `.type` etc.
404
+ export type RouboConfig = Omit<z.infer<typeof RouboConfigSchema>, "tools" | "components"> & {
405
+ tools?: ToolConfig[];
406
+ components: Record<string, ComponentConfig>;
407
+ };
408
+
409
+ // ── Helpers ──
410
+
411
+ export interface ConfigFieldError {
412
+ path: string;
413
+ message: string;
414
+ }
415
+
416
+ export function zodIssuesToValidationErrors(issues: z.ZodIssue[]): ConfigFieldError[] {
417
+ return issues.map((issue) => ({
418
+ path: issue.path.join("."),
419
+ message: issue.message,
420
+ }));
421
+ }
422
+
423
+ export function zodIssuesToFieldMap(issues: z.ZodIssue[]): Record<string, string> {
424
+ const map: Record<string, string> = {};
425
+ for (const issue of issues) {
426
+ const key = issue.path.join(".");
427
+ if (key && !(key in map)) {
428
+ map[key] = issue.message;
429
+ }
430
+ }
431
+ return map;
432
+ }
package/deep-merge.ts ADDED
@@ -0,0 +1,38 @@
1
+ // Hand-rolled deep-merge for the per-user integration override (FR-023).
2
+ //
3
+ // Semantics:
4
+ // - Plain object + plain object: recurse, merging keys per-field.
5
+ // - Array on either side: REPLACE wholesale. An empty array in the override
6
+ // is a valid replacement, not "unset" (TC-065).
7
+ // - Primitive in override: REPLACE.
8
+ // - `undefined` in override: treated as "not present", base wins.
9
+ // - `null` in override: treated as present, override wins.
10
+ //
11
+ // Intentionally not a general-purpose library: the integration block has a
12
+ // tiny, known shape, so a 25-line walker beats pulling in `lodash.merge`
13
+ // (which concats arrays, the opposite of what we need).
14
+
15
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
16
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
17
+ const proto = Object.getPrototypeOf(value);
18
+ return proto === null || proto === Object.prototype;
19
+ }
20
+
21
+ export function deepMergeIntegration<T extends Record<string, unknown>>(
22
+ base: T,
23
+ override: Record<string, unknown>,
24
+ ): T {
25
+ const result: Record<string, unknown> = { ...base };
26
+ for (const key of Object.keys(override)) {
27
+ const overrideValue = override[key];
28
+ if (overrideValue === undefined) continue;
29
+
30
+ const baseValue = result[key];
31
+ if (isPlainObject(baseValue) && isPlainObject(overrideValue)) {
32
+ result[key] = deepMergeIntegration(baseValue, overrideValue);
33
+ } else {
34
+ result[key] = overrideValue;
35
+ }
36
+ }
37
+ return result as T;
38
+ }
@@ -0,0 +1,120 @@
1
+ // Gate-overrides contract: the single, compile-time source of truth (in
2
+ // `shared/`) for the Roubo-owned override document that records an operator's
3
+ // batch merge / split regroupings (#703, FR-002, US-007). This mirrors the
4
+ // work-units contract (work-units-contract.ts): a zod source schema, the
5
+ // inferred type, the runtime validator, and the versioned `$id` constant.
6
+ //
7
+ // Why a separate, Roubo-owned document: gates are `kind: "verify"` work units
8
+ // loaded read-only from each spec's externally-authored work-units.json (the
9
+ // `breakdown` plugin writes that file; Roubo never does, see
10
+ // work-unit-loader.ts). Merge / split must NOT mutate work-units.json, so the
11
+ // operator's regroupings live here, in a per-project store Roubo controls, and
12
+ // are applied as a pure transform over the loaded verify units before
13
+ // evaluation (server/lib/gate-overrides.ts).
14
+ //
15
+ // The document is a flat, ordered list of operations. Each op names the SOURCE
16
+ // gate ids it consumes (which must currently exist among the loaded verify
17
+ // units) and the synthetic gate(s) it produces. Applying the list reconciles
18
+ // defensively: an op that references a now-missing source gate is dropped, never
19
+ // fatal (the external breakdown may re-file gates under different ids).
20
+
21
+ import { z } from "zod";
22
+
23
+ // ── Versioned schema identifier ──
24
+ //
25
+ // A breaking change ships a major bump; additive optional fields do not bump.
26
+ export const GATE_OVERRIDES_SCHEMA_ID = "https://roubo.dev/schema/gate-overrides/v1.0.0.json";
27
+ export const GATE_OVERRIDES_SCHEMA_VERSION = "1.0.0";
28
+
29
+ // ── Merge op ──
30
+ //
31
+ // Replaces N (>= 2) source gates with one synthetic gate whose gating set is the
32
+ // deduped union of the sources' test_case_ids and whose `covers` is the deduped
33
+ // union of the sources' `covers`. The synthetic gate's id is minted
34
+ // deterministically from the sorted source ids (see gate-overrides.ts).
35
+ export const MergeOpSchema = z
36
+ .object({
37
+ op: z.literal("merge"),
38
+ // The source gate ids consumed by this merge. At least two; deduped.
39
+ gateIds: z.array(z.string()).min(2),
40
+ })
41
+ .strict();
42
+ export type MergeOp = z.infer<typeof MergeOpSchema>;
43
+
44
+ // One part of a split: a label plus the source gate's `covers` WU- ids assigned
45
+ // to this part. The part's gating set is computed by mapping each WU- id to the
46
+ // test_case_ids the non-verify unit of that id implements (gate-overrides.ts).
47
+ export const SplitPartSchema = z
48
+ .object({
49
+ // A short stable label used to mint the part's synthetic gate id.
50
+ label: z.string().min(1),
51
+ // The WU- ids (a subset of the source gate's `covers`) assigned to this part.
52
+ coversWorkUnitIds: z.array(z.string()).min(1),
53
+ })
54
+ .strict();
55
+ export type SplitPart = z.infer<typeof SplitPartSchema>;
56
+
57
+ // ── Split op ──
58
+ //
59
+ // Replaces one source gate with M (>= 2) synthetic gates. The parts partition
60
+ // the source gate's `covers` with no loss and no overlap (validated at apply
61
+ // time against the live gate, where the WU- -> test_case_ids map is available).
62
+ export const SplitOpSchema = z
63
+ .object({
64
+ op: z.literal("split"),
65
+ // The single source gate id consumed by this split.
66
+ gateId: z.string(),
67
+ // The parts the source is split into. At least two.
68
+ parts: z.array(SplitPartSchema).min(2),
69
+ })
70
+ .strict();
71
+ export type SplitOp = z.infer<typeof SplitOpSchema>;
72
+
73
+ export const GateOverrideOpSchema = z.discriminatedUnion("op", [MergeOpSchema, SplitOpSchema]);
74
+ export type GateOverrideOp = z.infer<typeof GateOverrideOpSchema>;
75
+
76
+ // ── gate-overrides.json (the versioned envelope) ──
77
+ //
78
+ // `$schema` is constrained to the literal id, and `schemaVersion` must match the
79
+ // constant so the two stay consistent (mirrors the work-units envelope).
80
+ export const GateOverridesFileSchema = z
81
+ .object({
82
+ $schema: z.literal(GATE_OVERRIDES_SCHEMA_ID),
83
+ schemaVersion: z.literal(GATE_OVERRIDES_SCHEMA_VERSION),
84
+ ops: z.array(GateOverrideOpSchema),
85
+ })
86
+ .strict()
87
+ .meta({ $id: GATE_OVERRIDES_SCHEMA_ID });
88
+ export type GateOverridesFile = z.infer<typeof GateOverridesFileSchema>;
89
+
90
+ // An empty, valid document: no operator regroupings recorded yet.
91
+ export function emptyGateOverrides(): GateOverridesFile {
92
+ return {
93
+ $schema: GATE_OVERRIDES_SCHEMA_ID,
94
+ schemaVersion: GATE_OVERRIDES_SCHEMA_VERSION,
95
+ ops: [],
96
+ };
97
+ }
98
+
99
+ // ── Runtime validator ──
100
+ //
101
+ // Wraps `safeParse` (never throws) and returns a discriminated result, mirroring
102
+ // validateWorkUnits. On failure each zod issue becomes a clear `path: message`
103
+ // string keyed by the field that failed.
104
+
105
+ export type ValidationResult<T> = { ok: true; data: T } | { ok: false; errors: string[] };
106
+
107
+ function zodIssuesToFieldErrors(issues: z.ZodIssue[]): string[] {
108
+ return issues.map((issue) => {
109
+ const path = issue.path.join(".");
110
+ return path ? `${path}: ${issue.message}` : issue.message;
111
+ });
112
+ }
113
+
114
+ export function validateGateOverrides(raw: unknown): ValidationResult<GateOverridesFile> {
115
+ const parsed = GateOverridesFileSchema.safeParse(raw);
116
+ if (!parsed.success) {
117
+ return { ok: false, errors: zodIssuesToFieldErrors(parsed.error.issues) };
118
+ }
119
+ return { ok: true, data: parsed.data };
120
+ }