@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.
- package/config-schema.ts +432 -0
- package/deep-merge.ts +38 -0
- package/gate-overrides-contract.ts +120 -0
- package/integration-types.ts +124 -0
- package/package.json +39 -0
- package/plugin-consent-schema.ts +80 -0
- package/plugin-enable-state-schema.ts +30 -0
- package/plugin-manifest-schema.ts +179 -0
- package/plugin-manifest.ts +55 -0
- package/plugin-runtime-types.ts +50 -0
- package/provision-descriptor-schema.ts +103 -0
- package/testbench-canonicalize.ts +129 -0
- package/testbench-contracts.ts +271 -0
- package/testbench-domain-types.ts +128 -0
- package/testbench-domain.ts +232 -0
- package/testbench-targeting-schema.ts +132 -0
- package/types.ts +1975 -0
- package/work-units-contract.ts +155 -0
package/config-schema.ts
ADDED
|
@@ -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
|
+
}
|