@intentius/chant-lexicon-gitlab 0.0.1

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.
Files changed (56) hide show
  1. package/package.json +27 -0
  2. package/src/codegen/__snapshots__/snapshot.test.ts.snap +33 -0
  3. package/src/codegen/docs-cli.ts +3 -0
  4. package/src/codegen/docs.ts +962 -0
  5. package/src/codegen/fetch.ts +73 -0
  6. package/src/codegen/generate-cli.ts +41 -0
  7. package/src/codegen/generate-lexicon.ts +53 -0
  8. package/src/codegen/generate-typescript.ts +144 -0
  9. package/src/codegen/generate.ts +166 -0
  10. package/src/codegen/naming.ts +52 -0
  11. package/src/codegen/package.ts +64 -0
  12. package/src/codegen/parse.test.ts +195 -0
  13. package/src/codegen/parse.ts +531 -0
  14. package/src/codegen/patches.test.ts +99 -0
  15. package/src/codegen/patches.ts +100 -0
  16. package/src/codegen/rollback.ts +26 -0
  17. package/src/codegen/snapshot.test.ts +109 -0
  18. package/src/coverage.test.ts +39 -0
  19. package/src/coverage.ts +52 -0
  20. package/src/generated/index.d.ts +248 -0
  21. package/src/generated/index.ts +23 -0
  22. package/src/generated/lexicon-gitlab.json +77 -0
  23. package/src/generated/runtime.ts +4 -0
  24. package/src/import/generator.test.ts +151 -0
  25. package/src/import/generator.ts +173 -0
  26. package/src/import/parser.test.ts +160 -0
  27. package/src/import/parser.ts +282 -0
  28. package/src/import/roundtrip.test.ts +89 -0
  29. package/src/index.ts +25 -0
  30. package/src/intrinsics.test.ts +42 -0
  31. package/src/intrinsics.ts +40 -0
  32. package/src/lint/post-synth/post-synth.test.ts +155 -0
  33. package/src/lint/post-synth/wgl010.ts +41 -0
  34. package/src/lint/post-synth/wgl011.ts +54 -0
  35. package/src/lint/post-synth/yaml-helpers.ts +88 -0
  36. package/src/lint/rules/artifact-no-expiry.ts +62 -0
  37. package/src/lint/rules/deprecated-only-except.ts +53 -0
  38. package/src/lint/rules/index.ts +8 -0
  39. package/src/lint/rules/missing-script.ts +65 -0
  40. package/src/lint/rules/missing-stage.ts +62 -0
  41. package/src/lint/rules/rules.test.ts +146 -0
  42. package/src/lsp/completions.test.ts +85 -0
  43. package/src/lsp/completions.ts +18 -0
  44. package/src/lsp/hover.test.ts +60 -0
  45. package/src/lsp/hover.ts +36 -0
  46. package/src/plugin.test.ts +228 -0
  47. package/src/plugin.ts +380 -0
  48. package/src/serializer.test.ts +309 -0
  49. package/src/serializer.ts +226 -0
  50. package/src/testdata/ci-schema-fixture.json +2184 -0
  51. package/src/testdata/create-fixture.ts +46 -0
  52. package/src/testdata/load-fixtures.ts +23 -0
  53. package/src/validate-cli.ts +19 -0
  54. package/src/validate.test.ts +43 -0
  55. package/src/validate.ts +125 -0
  56. package/src/variables.ts +27 -0
@@ -0,0 +1,531 @@
1
+ /**
2
+ * GitLab CI JSON Schema parser.
3
+ *
4
+ * Parses the single CI schema into multiple entity results — one per CI
5
+ * entity (Job, Pipeline, Artifacts, Cache, etc.). The schema is a single
6
+ * document unlike CloudFormation which has one schema per resource type.
7
+ */
8
+
9
+ import type { PropertyConstraints } from "@intentius/chant/codegen/json-schema";
10
+ import {
11
+ extractConstraints as coreExtractConstraints,
12
+ constraintsIsEmpty as coreConstraintsIsEmpty,
13
+ primaryType,
14
+ type JsonSchemaProperty,
15
+ type JsonSchemaDocument,
16
+ } from "@intentius/chant/codegen/json-schema";
17
+
18
+ // ── Types ──────────────────────────────────────────────────────────
19
+
20
+ export type { PropertyConstraints };
21
+ export { coreConstraintsIsEmpty as constraintsIsEmpty };
22
+
23
+ export interface ParsedProperty {
24
+ name: string;
25
+ tsType: string;
26
+ required: boolean;
27
+ description?: string;
28
+ enum?: string[];
29
+ constraints: PropertyConstraints;
30
+ }
31
+
32
+ export interface ParsedPropertyType {
33
+ name: string;
34
+ /** The definition name in the original schema */
35
+ defType: string;
36
+ properties: ParsedProperty[];
37
+ }
38
+
39
+ export interface ParsedEnum {
40
+ name: string;
41
+ values: string[];
42
+ }
43
+
44
+ export interface ParsedResource {
45
+ typeName: string;
46
+ description?: string;
47
+ properties: ParsedProperty[];
48
+ attributes: Array<{ name: string; tsType: string }>;
49
+ }
50
+
51
+ export interface GitLabParseResult {
52
+ resource: ParsedResource;
53
+ propertyTypes: ParsedPropertyType[];
54
+ enums: ParsedEnum[];
55
+ /** Whether this entity is a "property" type (nested inside resources) */
56
+ isProperty?: boolean;
57
+ }
58
+
59
+ // ── Schema types ──────────────────────────────────────────────────
60
+
61
+ interface CISchemaDefinition {
62
+ type?: string | string[];
63
+ description?: string;
64
+ properties?: Record<string, CISchemaProperty>;
65
+ required?: string[];
66
+ enum?: string[];
67
+ oneOf?: CISchemaProperty[];
68
+ anyOf?: CISchemaProperty[];
69
+ $ref?: string;
70
+ items?: CISchemaProperty;
71
+ const?: unknown;
72
+ default?: unknown;
73
+ additionalProperties?: boolean | CISchemaProperty;
74
+ patternProperties?: Record<string, CISchemaProperty>;
75
+ minimum?: number;
76
+ maximum?: number;
77
+ minLength?: number;
78
+ maxLength?: number;
79
+ pattern?: string;
80
+ format?: string;
81
+ }
82
+
83
+ interface CISchemaProperty extends CISchemaDefinition {
84
+ // Same shape as definition
85
+ }
86
+
87
+ interface CISchema {
88
+ definitions?: Record<string, CISchemaDefinition>;
89
+ properties?: Record<string, CISchemaProperty>;
90
+ patternProperties?: Record<string, CISchemaProperty>;
91
+ additionalProperties?: boolean | CISchemaProperty;
92
+ required?: string[];
93
+ [key: string]: unknown;
94
+ }
95
+
96
+ // ── Entity extraction mapping ──────────────────────────────────────
97
+
98
+ /**
99
+ * Top-level entities (resources) to extract from the schema.
100
+ */
101
+ const RESOURCE_ENTITIES: Array<{
102
+ typeName: string;
103
+ /** Schema path — "root" for root object, or "#/definitions/<name>" */
104
+ source: string;
105
+ description?: string;
106
+ }> = [
107
+ {
108
+ typeName: "GitLab::CI::Job",
109
+ source: "#/definitions/job_template",
110
+ description: "A GitLab CI/CD job definition",
111
+ },
112
+ {
113
+ typeName: "GitLab::CI::Default",
114
+ source: "root:default",
115
+ description: "Default settings inherited by all jobs",
116
+ },
117
+ {
118
+ typeName: "GitLab::CI::Workflow",
119
+ source: "root:workflow",
120
+ description: "Pipeline-level workflow configuration",
121
+ },
122
+ ];
123
+
124
+ /**
125
+ * Property types (nested) to extract from definitions.
126
+ */
127
+ const PROPERTY_ENTITIES: Array<{
128
+ typeName: string;
129
+ source: string;
130
+ description?: string;
131
+ }> = [
132
+ { typeName: "GitLab::CI::Artifacts", source: "#/definitions/artifacts", description: "Job artifact configuration" },
133
+ { typeName: "GitLab::CI::Cache", source: "#/definitions/cache_item", description: "Cache configuration" },
134
+ { typeName: "GitLab::CI::Image", source: "#/definitions/image", description: "Docker image for a job" },
135
+ { typeName: "GitLab::CI::Rule", source: "#/definitions/rules:item", description: "Conditional rule for job execution" },
136
+ { typeName: "GitLab::CI::Retry", source: "#/definitions/retry", description: "Job retry configuration" },
137
+ { typeName: "GitLab::CI::AllowFailure", source: "#/definitions/allow_failure", description: "Allow failure configuration" },
138
+ { typeName: "GitLab::CI::Parallel", source: "#/definitions/parallel", description: "Parallel job configuration" },
139
+ { typeName: "GitLab::CI::Include", source: "#/definitions/include_item", description: "Include configuration item" },
140
+ { typeName: "GitLab::CI::Release", source: "job_template:release", description: "Release configuration" },
141
+ { typeName: "GitLab::CI::Environment", source: "job_template:environment", description: "Deployment environment" },
142
+ { typeName: "GitLab::CI::Trigger", source: "job_template:trigger", description: "Trigger downstream pipeline" },
143
+ { typeName: "GitLab::CI::AutoCancel", source: "#/definitions/workflowAutoCancel", description: "Auto-cancel configuration" },
144
+ ];
145
+
146
+ /**
147
+ * Enum types to extract.
148
+ */
149
+ const ENUM_ENTITIES: Array<{
150
+ name: string;
151
+ source: string;
152
+ }> = [
153
+ { name: "When", source: "#/definitions/when" },
154
+ { name: "RetryError", source: "#/definitions/retry_errors" },
155
+ ];
156
+
157
+ // ── Parser ─────────────────────────────────────────────────────────
158
+
159
+ /**
160
+ * Parse the GitLab CI JSON Schema into multiple entity results.
161
+ * Returns one result per CI entity with its properties and nested types.
162
+ */
163
+ export function parseCISchema(data: string | Buffer): GitLabParseResult[] {
164
+ const schema: CISchema = JSON.parse(typeof data === "string" ? data : data.toString("utf-8"));
165
+ const results: GitLabParseResult[] = [];
166
+
167
+ // Extract resource entities
168
+ for (const entity of RESOURCE_ENTITIES) {
169
+ const result = extractResourceEntity(schema, entity);
170
+ if (result) results.push(result);
171
+ }
172
+
173
+ // Extract property entities as pseudo-resources (each gets its own ParseResult)
174
+ for (const entity of PROPERTY_ENTITIES) {
175
+ const result = extractPropertyEntity(schema, entity);
176
+ if (result) {
177
+ result.isProperty = true;
178
+ results.push(result);
179
+ }
180
+ }
181
+
182
+ return results;
183
+ }
184
+
185
+ /**
186
+ * Extract a resource entity from the schema.
187
+ */
188
+ function extractResourceEntity(
189
+ schema: CISchema,
190
+ entity: { typeName: string; source: string; description?: string },
191
+ ): GitLabParseResult | null {
192
+ const def = resolveSource(schema, entity.source);
193
+ if (!def) return null;
194
+
195
+ // Find the object variant if it's a oneOf/anyOf
196
+ const objectDef = findObjectVariant(def);
197
+ if (!objectDef?.properties) return null;
198
+
199
+ const requiredSet = new Set<string>(objectDef.required ?? []);
200
+ const properties = parseProperties(objectDef.properties, requiredSet, schema);
201
+ const shortName = gitlabShortName(entity.typeName);
202
+
203
+ // Extract nested property types from definition properties
204
+ const { propertyTypes, enums } = extractNestedTypes(objectDef, shortName, schema);
205
+
206
+ return {
207
+ resource: {
208
+ typeName: entity.typeName,
209
+ description: entity.description ?? objectDef.description,
210
+ properties,
211
+ attributes: [], // CI entities have no read-only attributes
212
+ },
213
+ propertyTypes,
214
+ enums,
215
+ };
216
+ }
217
+
218
+ /**
219
+ * Extract a property entity — represented as a ParseResult with the entity
220
+ * as the "resource" (the pipeline treats all parsed results uniformly).
221
+ */
222
+ function extractPropertyEntity(
223
+ schema: CISchema,
224
+ entity: { typeName: string; source: string; description?: string },
225
+ ): GitLabParseResult | null {
226
+ const def = resolveSource(schema, entity.source);
227
+ if (!def) return null;
228
+
229
+ const objectDef = findObjectVariant(def);
230
+ if (!objectDef?.properties) {
231
+ // Some entities like Parallel might be simple types
232
+ // Create a minimal entry with no properties
233
+ return {
234
+ resource: {
235
+ typeName: entity.typeName,
236
+ description: entity.description ?? def.description,
237
+ properties: [],
238
+ attributes: [],
239
+ },
240
+ propertyTypes: [],
241
+ enums: [],
242
+ };
243
+ }
244
+
245
+ const requiredSet = new Set<string>(objectDef.required ?? []);
246
+ const properties = parseProperties(objectDef.properties, requiredSet, schema);
247
+ const shortName = gitlabShortName(entity.typeName);
248
+
249
+ const { propertyTypes, enums } = extractNestedTypes(objectDef, shortName, schema);
250
+
251
+ return {
252
+ resource: {
253
+ typeName: entity.typeName,
254
+ description: entity.description ?? objectDef.description,
255
+ properties,
256
+ attributes: [],
257
+ },
258
+ propertyTypes,
259
+ enums,
260
+ };
261
+ }
262
+
263
+ // ── Helpers ────────────────────────────────────────────────────────
264
+
265
+ /**
266
+ * Resolve a source path to a schema definition.
267
+ */
268
+ function resolveSource(schema: CISchema, source: string): CISchemaDefinition | null {
269
+ // Special case: rules array item extraction
270
+ if (source === "#/definitions/rules:item") {
271
+ const rulesDef = schema.definitions?.rules;
272
+ if (!rulesDef) return null;
273
+ // rules is an array — get items
274
+ const items = rulesDef.items;
275
+ if (!items) return null;
276
+ return findObjectVariant(items);
277
+ }
278
+
279
+ if (source.startsWith("#/definitions/")) {
280
+ const defName = source.slice("#/definitions/".length);
281
+ return schema.definitions?.[defName] ?? null;
282
+ }
283
+
284
+ if (source.startsWith("root:")) {
285
+ const propName = source.slice("root:".length);
286
+ const prop = schema.properties?.[propName];
287
+ if (!prop) return null;
288
+ if (prop.$ref) {
289
+ return resolveRef(prop.$ref, schema);
290
+ }
291
+ return prop;
292
+ }
293
+
294
+ if (source.startsWith("job_template:")) {
295
+ const propName = source.slice("job_template:".length);
296
+ const jobDef = schema.definitions?.job_template;
297
+ if (!jobDef?.properties) return null;
298
+ const prop = jobDef.properties[propName];
299
+ if (!prop) return null;
300
+ if (prop.$ref) {
301
+ return resolveRef(prop.$ref, schema);
302
+ }
303
+ return prop;
304
+ }
305
+
306
+ return null;
307
+ }
308
+
309
+ /**
310
+ * Resolve a $ref string to a schema definition.
311
+ */
312
+ function resolveRef(ref: string, schema: CISchema): CISchemaDefinition | null {
313
+ const prefix = "#/definitions/";
314
+ if (!ref.startsWith(prefix)) return null;
315
+ const defName = ref.slice(prefix.length);
316
+ return schema.definitions?.[defName] ?? null;
317
+ }
318
+
319
+ /**
320
+ * Find the object variant from a oneOf/anyOf union, or return the
321
+ * definition itself if it already has properties.
322
+ */
323
+ function findObjectVariant(def: CISchemaDefinition): CISchemaDefinition | null {
324
+ if (def.properties) return def;
325
+
326
+ const variants = def.oneOf ?? def.anyOf;
327
+ if (variants) {
328
+ // Prefer the variant with the most properties
329
+ let best: CISchemaDefinition | null = null;
330
+ let bestCount = 0;
331
+ for (const v of variants) {
332
+ // Resolve $ref in variant
333
+ let resolved: CISchemaDefinition = v;
334
+ if (v.$ref) {
335
+ // We don't have schema here, just check properties
336
+ continue;
337
+ }
338
+ if (resolved.properties) {
339
+ const count = Object.keys(resolved.properties).length;
340
+ if (count > bestCount) {
341
+ best = resolved;
342
+ bestCount = count;
343
+ }
344
+ }
345
+ }
346
+ return best;
347
+ }
348
+
349
+ // If it's a $ref, it would have been resolved before calling this
350
+ return null;
351
+ }
352
+
353
+ /**
354
+ * Parse properties from a schema definition into ParsedProperty[].
355
+ */
356
+ function parseProperties(
357
+ properties: Record<string, CISchemaProperty>,
358
+ requiredSet: Set<string>,
359
+ schema: CISchema,
360
+ ): ParsedProperty[] {
361
+ const result: ParsedProperty[] = [];
362
+
363
+ for (const [name, prop] of Object.entries(properties)) {
364
+ // Skip internal/hidden properties
365
+ if (name === "!reference") continue;
366
+
367
+ const tsType = resolvePropertyType(prop, schema);
368
+ result.push({
369
+ name,
370
+ tsType,
371
+ required: requiredSet.has(name),
372
+ description: prop.description,
373
+ enum: prop.enum,
374
+ constraints: coreExtractConstraints(prop as JsonSchemaProperty),
375
+ });
376
+ }
377
+
378
+ return result;
379
+ }
380
+
381
+ /**
382
+ * Resolve a schema property to its TypeScript type string.
383
+ */
384
+ function resolvePropertyType(prop: CISchemaProperty, schema: CISchema): string {
385
+ if (!prop) return "any";
386
+
387
+ // Handle $ref
388
+ if (prop.$ref) {
389
+ const ref = prop.$ref;
390
+ const prefix = "#/definitions/";
391
+ if (ref.startsWith(prefix)) {
392
+ const defName = ref.slice(prefix.length);
393
+ const def = schema.definitions?.[defName];
394
+ if (def) {
395
+ // Check for known entity types
396
+ const entityType = definitionToTsType(defName);
397
+ if (entityType) return entityType;
398
+
399
+ // Enum
400
+ if (def.enum && def.enum.length > 0 && !def.properties) {
401
+ return def.enum.map((v) => JSON.stringify(v)).join(" | ");
402
+ }
403
+
404
+ // Primitive type
405
+ if (def.type && !def.properties) {
406
+ return jsonTypeToTs(primaryType(def.type));
407
+ }
408
+
409
+ // Object with properties
410
+ if (def.properties) return "Record<string, any>";
411
+ }
412
+ }
413
+ return "any";
414
+ }
415
+
416
+ // Inline enum
417
+ if (prop.enum && prop.enum.length > 0) {
418
+ return prop.enum.map((v) => JSON.stringify(v)).join(" | ");
419
+ }
420
+
421
+ // Handle oneOf/anyOf
422
+ if (prop.oneOf || prop.anyOf) {
423
+ const variants = prop.oneOf ?? prop.anyOf ?? [];
424
+ const types = new Set<string>();
425
+ for (const v of variants) {
426
+ types.add(resolvePropertyType(v, schema));
427
+ }
428
+ // Simplify if all variants resolve to the same type
429
+ const uniqueTypes = [...types].filter((t) => t !== "any");
430
+ if (uniqueTypes.length === 0) return "any";
431
+ if (uniqueTypes.length === 1) return uniqueTypes[0];
432
+ return uniqueTypes.join(" | ");
433
+ }
434
+
435
+ const pt = primaryType(prop.type);
436
+ switch (pt) {
437
+ case "string":
438
+ return "string";
439
+ case "integer":
440
+ case "number":
441
+ return "number";
442
+ case "boolean":
443
+ return "boolean";
444
+ case "array":
445
+ if (prop.items) {
446
+ const itemType = resolvePropertyType(prop.items, schema);
447
+ return `${itemType}[]`;
448
+ }
449
+ return "any[]";
450
+ case "object":
451
+ return "Record<string, any>";
452
+ default:
453
+ return "any";
454
+ }
455
+ }
456
+
457
+ /**
458
+ * Map well-known definition names to their TypeScript entity types.
459
+ */
460
+ function definitionToTsType(defName: string): string | null {
461
+ const map: Record<string, string> = {
462
+ image: "Image",
463
+ services: "Service[]",
464
+ artifacts: "Artifacts",
465
+ cache: "Cache | Cache[]",
466
+ cache_item: "Cache",
467
+ rules: "Rule[]",
468
+ retry: "Retry | number",
469
+ allow_failure: "AllowFailure | boolean",
470
+ parallel: "Parallel | number",
471
+ include_item: "Include",
472
+ before_script: "string | string[]",
473
+ after_script: "string | string[]",
474
+ script: "string | string[]",
475
+ optional_script: "string | string[]",
476
+ globalVariables: "Record<string, any>",
477
+ jobVariables: "Record<string, any>",
478
+ rulesVariables: "Record<string, any>",
479
+ when: '"on_success" | "on_failure" | "always" | "never" | "manual" | "delayed"',
480
+ workflowAutoCancel: "AutoCancel",
481
+ id_tokens: "Record<string, any>",
482
+ secrets: "Record<string, any>",
483
+ timeout: "string",
484
+ start_in: "string",
485
+ };
486
+ return map[defName] ?? null;
487
+ }
488
+
489
+ function jsonTypeToTs(type: string): string {
490
+ switch (type) {
491
+ case "string": return "string";
492
+ case "integer":
493
+ case "number": return "number";
494
+ case "boolean": return "boolean";
495
+ case "array": return "any[]";
496
+ case "object": return "Record<string, any>";
497
+ default: return "any";
498
+ }
499
+ }
500
+
501
+ /**
502
+ * Extract nested property types and enums from a definition.
503
+ * For now, we keep this minimal — the main types are extracted as
504
+ * top-level entities via PROPERTY_ENTITIES.
505
+ */
506
+ function extractNestedTypes(
507
+ _def: CISchemaDefinition,
508
+ _shortName: string,
509
+ _schema: CISchema,
510
+ ): { propertyTypes: ParsedPropertyType[]; enums: ParsedEnum[] } {
511
+ // GitLab CI entities don't have deeply nested property types like CFN.
512
+ // The main nested types (Artifacts, Cache, etc.) are extracted as
513
+ // top-level property entities instead.
514
+ return { propertyTypes: [], enums: [] };
515
+ }
516
+
517
+ /**
518
+ * Extract short name: "GitLab::CI::Job" → "Job"
519
+ */
520
+ export function gitlabShortName(typeName: string): string {
521
+ const parts = typeName.split("::");
522
+ return parts[parts.length - 1];
523
+ }
524
+
525
+ /**
526
+ * Extract service name: "GitLab::CI::Job" → "CI"
527
+ */
528
+ export function gitlabServiceName(typeName: string): string {
529
+ const parts = typeName.split("::");
530
+ return parts.length >= 2 ? parts[1] : "CI";
531
+ }
@@ -0,0 +1,99 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { applyPatches, schemaPatches } from "./patches";
3
+
4
+ describe("applyPatches", () => {
5
+ test("applies patches to matching paths", () => {
6
+ const schema = {
7
+ definitions: {
8
+ job_template: {
9
+ properties: {
10
+ script: { type: "string" },
11
+ },
12
+ },
13
+ },
14
+ };
15
+
16
+ const { applied, skipped } = applyPatches(schema as Record<string, unknown>);
17
+ expect(applied.length).toBeGreaterThan(0);
18
+ expect(skipped).toHaveLength(0);
19
+ });
20
+
21
+ test("skips patches when path does not exist", () => {
22
+ const schema = { definitions: {} };
23
+ const { applied, skipped } = applyPatches(schema as Record<string, unknown>);
24
+ expect(skipped.length).toBeGreaterThan(0);
25
+ });
26
+
27
+ test("adds pages property to job_template", () => {
28
+ const schema = {
29
+ definitions: {
30
+ job_template: {
31
+ properties: {} as Record<string, unknown>,
32
+ },
33
+ },
34
+ };
35
+
36
+ applyPatches(schema as Record<string, unknown>);
37
+ expect(schema.definitions.job_template.properties.pages).toBeDefined();
38
+ });
39
+
40
+ test("adds manual_confirmation property to job_template", () => {
41
+ const schema = {
42
+ definitions: {
43
+ job_template: {
44
+ properties: {} as Record<string, unknown>,
45
+ },
46
+ },
47
+ };
48
+
49
+ applyPatches(schema as Record<string, unknown>);
50
+ expect(schema.definitions.job_template.properties.manual_confirmation).toBeDefined();
51
+ });
52
+
53
+ test("adds inputs property to job_template", () => {
54
+ const schema = {
55
+ definitions: {
56
+ job_template: {
57
+ properties: {} as Record<string, unknown>,
58
+ },
59
+ },
60
+ };
61
+
62
+ applyPatches(schema as Record<string, unknown>);
63
+ expect(schema.definitions.job_template.properties.inputs).toBeDefined();
64
+ });
65
+
66
+ test("does not overwrite existing properties", () => {
67
+ const schema = {
68
+ definitions: {
69
+ job_template: {
70
+ properties: {
71
+ pages: { type: "custom" },
72
+ manual_confirmation: { type: "custom" },
73
+ inputs: { type: "custom" },
74
+ } as Record<string, unknown>,
75
+ },
76
+ },
77
+ };
78
+
79
+ applyPatches(schema as Record<string, unknown>);
80
+ // Should not have overwritten existing properties
81
+ expect((schema.definitions.job_template.properties.pages as Record<string, string>).type).toBe("custom");
82
+ expect((schema.definitions.job_template.properties.manual_confirmation as Record<string, string>).type).toBe("custom");
83
+ expect((schema.definitions.job_template.properties.inputs as Record<string, string>).type).toBe("custom");
84
+ });
85
+ });
86
+
87
+ describe("schemaPatches registry", () => {
88
+ test("has patches defined", () => {
89
+ expect(schemaPatches.length).toBeGreaterThan(0);
90
+ });
91
+
92
+ test("all patches have required fields", () => {
93
+ for (const patch of schemaPatches) {
94
+ expect(patch.description).toBeTruthy();
95
+ expect(patch.path).toBeTruthy();
96
+ expect(typeof patch.apply).toBe("function");
97
+ }
98
+ });
99
+ });