@intentius/chant-lexicon-gitlab 0.0.6 → 0.0.9

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 (52) hide show
  1. package/dist/integrity.json +10 -6
  2. package/dist/manifest.json +1 -1
  3. package/dist/meta.json +186 -8
  4. package/dist/rules/wgl012.ts +86 -0
  5. package/dist/rules/wgl013.ts +62 -0
  6. package/dist/rules/wgl014.ts +51 -0
  7. package/dist/rules/wgl015.ts +85 -0
  8. package/dist/rules/yaml-helpers.ts +65 -3
  9. package/dist/skills/chant-gitlab.md +502 -0
  10. package/dist/types/index.d.ts +55 -16
  11. package/package.json +2 -2
  12. package/src/codegen/__snapshots__/snapshot.test.ts.snap +58 -0
  13. package/src/codegen/docs.ts +88 -11
  14. package/src/codegen/generate-lexicon.ts +6 -1
  15. package/src/codegen/generate.ts +45 -50
  16. package/src/codegen/naming.ts +3 -0
  17. package/src/codegen/package.ts +2 -0
  18. package/src/codegen/parse.test.ts +154 -4
  19. package/src/codegen/parse.ts +161 -49
  20. package/src/codegen/snapshot.test.ts +7 -5
  21. package/src/composites/composites.test.ts +452 -0
  22. package/src/composites/docker-build.ts +81 -0
  23. package/src/composites/index.ts +8 -0
  24. package/src/composites/node-pipeline.ts +104 -0
  25. package/src/composites/python-pipeline.ts +75 -0
  26. package/src/composites/review-app.ts +63 -0
  27. package/src/generated/index.d.ts +55 -16
  28. package/src/generated/index.ts +3 -0
  29. package/src/generated/lexicon-gitlab.json +186 -8
  30. package/src/import/generator.ts +3 -2
  31. package/src/import/parser.test.ts +3 -3
  32. package/src/import/parser.ts +12 -26
  33. package/src/index.ts +4 -0
  34. package/src/lint/post-synth/wgl012.test.ts +131 -0
  35. package/src/lint/post-synth/wgl012.ts +86 -0
  36. package/src/lint/post-synth/wgl013.test.ts +164 -0
  37. package/src/lint/post-synth/wgl013.ts +62 -0
  38. package/src/lint/post-synth/wgl014.test.ts +97 -0
  39. package/src/lint/post-synth/wgl014.ts +51 -0
  40. package/src/lint/post-synth/wgl015.test.ts +139 -0
  41. package/src/lint/post-synth/wgl015.ts +85 -0
  42. package/src/lint/post-synth/yaml-helpers.ts +65 -3
  43. package/src/lsp/completions.ts +2 -0
  44. package/src/lsp/hover.ts +2 -0
  45. package/src/plugin.test.ts +44 -19
  46. package/src/plugin.ts +671 -76
  47. package/src/serializer.test.ts +146 -6
  48. package/src/serializer.ts +64 -14
  49. package/src/validate.ts +1 -0
  50. package/src/variables.ts +4 -0
  51. package/dist/skills/gitlab-ci.md +0 -37
  52. package/src/codegen/rollback.ts +0 -26
@@ -2,6 +2,12 @@
2
2
 
3
3
  exports[`generated lexicon-gitlab.json entries match snapshot 1`] = `
4
4
  {
5
+ "constraints": {
6
+ "coverage": {
7
+ "format": "regex",
8
+ "pattern": "^/.+/$",
9
+ },
10
+ },
5
11
  "kind": "resource",
6
12
  "lexicon": "gitlab",
7
13
  "resourceType": "GitLab::CI::Job",
@@ -10,6 +16,30 @@ exports[`generated lexicon-gitlab.json entries match snapshot 1`] = `
10
16
 
11
17
  exports[`generated lexicon-gitlab.json entries match snapshot 2`] = `
12
18
  {
19
+ "constraints": {
20
+ "access": {
21
+ "default": "all",
22
+ "enum": [
23
+ "none",
24
+ "developer",
25
+ "all",
26
+ ],
27
+ },
28
+ "expire_in": {
29
+ "default": "30 days",
30
+ },
31
+ "untracked": {
32
+ "default": false,
33
+ },
34
+ "when": {
35
+ "default": "on_success",
36
+ "enum": [
37
+ "on_success",
38
+ "on_failure",
39
+ "always",
40
+ ],
41
+ },
42
+ },
13
43
  "kind": "property",
14
44
  "lexicon": "gitlab",
15
45
  "resourceType": "GitLab::CI::Artifacts",
@@ -18,6 +48,26 @@ exports[`generated lexicon-gitlab.json entries match snapshot 2`] = `
18
48
 
19
49
  exports[`generated lexicon-gitlab.json entries match snapshot 3`] = `
20
50
  {
51
+ "constraints": {
52
+ "policy": {
53
+ "default": "pull-push",
54
+ "pattern": "pull-push|pull|push|\\$\\w{1,255}",
55
+ },
56
+ "unprotect": {
57
+ "default": false,
58
+ },
59
+ "untracked": {
60
+ "default": false,
61
+ },
62
+ "when": {
63
+ "default": "on_success",
64
+ "enum": [
65
+ "on_success",
66
+ "on_failure",
67
+ "always",
68
+ ],
69
+ },
70
+ },
21
71
  "kind": "property",
22
72
  "lexicon": "gitlab",
23
73
  "resourceType": "GitLab::CI::Cache",
@@ -26,6 +76,14 @@ exports[`generated lexicon-gitlab.json entries match snapshot 3`] = `
26
76
 
27
77
  exports[`generated lexicon-gitlab.json entries match snapshot 4`] = `
28
78
  {
79
+ "constraints": {
80
+ "name": {
81
+ "minLength": 1,
82
+ },
83
+ "pull_policy": {
84
+ "default": "always",
85
+ },
86
+ },
29
87
  "kind": "property",
30
88
  "lexicon": "gitlab",
31
89
  "resourceType": "GitLab::CI::Image",
@@ -58,8 +58,7 @@ The generated file includes:
58
58
  | Chant (TypeScript) | YAML output | Rule |
59
59
  |--------------------|-------------|------|
60
60
  | \`export const buildApp = new Job({...})\` | \`build-app:\` | Export name → kebab-case job key |
61
- | \`expireIn: "1 week"\` | \`expire_in: 1 week\` | camelCase snake_case |
62
- | \`ifCondition: ...\` | \`if: ...\` | Reserved word properties use suffixed names |
61
+ | \`expire_in: "1 week"\` | \`expire_in: 1 week\` | Property names use spec-native snake_case |
63
62
  | \`new Image({ name: "node:20" })\` | \`image: node:20\` | Single-property objects are collapsed |
64
63
 
65
64
  ## Validating locally
@@ -108,7 +107,7 @@ export async function generateDocs(opts?: { verbose?: boolean }): Promise<void>
108
107
  description: "Jobs, stages, artifacts, caching, images, rules, environments, and triggers in the GitLab CI/CD lexicon",
109
108
  content: `Every exported \`Job\` declaration becomes a job entry in the generated \`.gitlab-ci.yml\`. The serializer handles the translation automatically:
110
109
 
111
- - Converts camelCase property names to snake_case (\`expireIn\` \`expire_in\`)
110
+ - Property names use spec-native snake_case (\`expire_in\`, \`allow_failure\`)
112
111
  - Converts export names to kebab-case job keys (\`buildApp\` → \`build-app\`)
113
112
  - Collects stages from all jobs into a \`stages:\` list
114
113
  - Collapses single-property objects (\`new Image({ name: "node:20" })\` → \`image: node:20\`)
@@ -162,7 +161,7 @@ The lexicon provides 3 resource types and 13 property types:
162
161
 
163
162
  Extract reusable objects into a shared config file and import them across your pipeline files:
164
163
 
165
- {{file:docs-snippets/src/pipeline-barrel.ts}}
164
+ {{file:docs-snippets/src/pipeline-shared-config.ts}}
166
165
 
167
166
  ## Jobs
168
167
 
@@ -339,9 +338,9 @@ lint:
339
338
  - npm run lint
340
339
  \`\`\`
341
340
 
342
- ### When to use \`reference()\` vs barrel imports
341
+ ### When to use \`reference()\` vs direct imports
343
342
 
344
- {{file:docs-snippets/src/reference-vs-barrel.ts}}
343
+ {{file:docs-snippets/src/reference-vs-import.ts}}
345
344
  `,
346
345
  },
347
346
  {
@@ -404,6 +403,30 @@ Flags jobs where all \`rules:\` entries have \`when: "never"\`, making the job u
404
403
 
405
404
  {{file:docs-snippets/src/lint-wgl011.ts}}
406
405
 
406
+ ### WGL012 — Deprecated property usage
407
+
408
+ **Severity:** warning
409
+
410
+ Flags properties marked as deprecated in the GitLab CI schema. Deprecation signals are mined from property descriptions (keywords like "deprecated", "legacy", "no longer available"). Using deprecated properties may cause unexpected behavior in future GitLab versions.
411
+
412
+ ### WGL013 — Invalid \`needs:\` target
413
+
414
+ **Severity:** error
415
+
416
+ Flags jobs whose \`needs:\` entries reference a job not defined in the pipeline, or reference themselves. Both cause GitLab pipeline validation failures. When \`include:\` is present, the check is skipped since needed jobs may come from included files.
417
+
418
+ ### WGL014 — Invalid \`extends:\` target
419
+
420
+ **Severity:** error
421
+
422
+ Flags jobs whose \`extends:\` references a template or hidden job not defined in the pipeline. GitLab rejects pipelines with unresolved extends references. When \`include:\` is present, the check is skipped since templates may come from included files.
423
+
424
+ ### WGL015 — Circular \`needs:\` chain
425
+
426
+ **Severity:** error
427
+
428
+ Detects cycles in the \`needs:\` dependency graph. If job A needs B and B needs A (directly or transitively), GitLab rejects the pipeline. Reports the full cycle chain in the diagnostic message.
429
+
407
430
  ## Running lint
408
431
 
409
432
  \`\`\`bash
@@ -532,13 +555,67 @@ deploy:
532
555
 
533
556
  **Patterns demonstrated:**
534
557
 
535
- 1. **Barrel file** — single import point for lexicon types and shared config
536
- 2. **Shared config** — reusable images, caches, artifacts, and rules extracted into \`config.ts\`
537
- 3. **Conditional execution** — merge request and branch rules control when jobs run
538
- 4. **Manual deployment** — deploy requires manual trigger on the default branch
539
- 5. **JUnit reports** — test artifacts include JUnit XML for GitLab MR display
558
+ 1. **Shared config** — reusable images, caches, artifacts, and rules extracted into \`config.ts\`
559
+ 2. **Conditional execution** — merge request and branch rules control when jobs run
560
+ 3. **Manual deployment** — deploy requires manual trigger on the default branch
561
+ 4. **JUnit reports** — test artifacts include JUnit XML for GitLab MR display
540
562
  `,
541
563
  },
564
+ {
565
+ slug: "skills",
566
+ title: "AI Skills",
567
+ description: "AI agent skills bundled with the GitLab CI/CD lexicon",
568
+ content: `The GitLab lexicon ships an AI skill called **chant-gitlab** that teaches AI coding agents (like Claude Code) how to build, validate, and deploy GitLab CI pipelines from a chant project.
569
+
570
+ ## What are skills?
571
+
572
+ Skills are structured markdown documents bundled with a lexicon. When an AI agent works in a chant project, it discovers and loads relevant skills automatically — giving it operational knowledge about the deployment workflow without requiring the user to explain each step.
573
+
574
+ ## Installation
575
+
576
+ When you scaffold a new project with \`chant init --lexicon gitlab\`, the skill is installed to \`.claude/skills/chant-gitlab/SKILL.md\` for automatic discovery by Claude Code.
577
+
578
+ For existing projects, create the file manually:
579
+
580
+ \`\`\`
581
+ .claude/
582
+ skills/
583
+ chant-gitlab/
584
+ SKILL.md # skill content (see below)
585
+ \`\`\`
586
+
587
+ ## Skill: chant-gitlab
588
+
589
+ The \`chant-gitlab\` skill covers the full deployment lifecycle:
590
+
591
+ - **Build** — \`chant build src/ --output .gitlab-ci.yml\`
592
+ - **Validate** — \`chant lint src/\` + GitLab CI Lint API
593
+ - **Deploy** — commit and push the generated YAML
594
+ - **Status** — GitLab UI or pipelines API
595
+ - **Retry** — retry failed jobs via UI or API
596
+ - **Cancel** — cancel running pipelines via API
597
+ - **Troubleshooting** — job logs, lint rule codes (WGL001–WGL004), post-synth checks (WGL010–WGL015)
598
+
599
+ The skill is invocable as a slash command: \`/chant-gitlab\`
600
+
601
+ ## MCP integration
602
+
603
+ The lexicon also provides MCP (Model Context Protocol) tools and resources that AI agents can use programmatically:
604
+
605
+ | MCP tool | Description |
606
+ |----------|-------------|
607
+ | \`build\` | Build the chant project |
608
+ | \`lint\` | Run lint rules |
609
+ | \`explain\` | Summarize project resources |
610
+ | \`scaffold\` | Generate starter files |
611
+ | \`search\` | Search available resource types |
612
+ | \`gitlab:diff\` | Compare current build output against previous |
613
+
614
+ | MCP resource | Description |
615
+ |--------------|-------------|
616
+ | \`resource-catalog\` | JSON list of all supported GitLab CI entity types |
617
+ | \`examples/basic-pipeline\` | Example pipeline with build, test, and deploy jobs |`,
618
+ },
542
619
  ],
543
620
  basePath: "/chant/lexicons/gitlab/",
544
621
  };
@@ -3,6 +3,7 @@
3
3
  * for all GitLab CI entities.
4
4
  */
5
5
 
6
+ import type { PropertyConstraints } from "@intentius/chant/codegen/json-schema";
6
7
  import type { GitLabParseResult } from "./parse";
7
8
  import { gitlabShortName } from "./parse";
8
9
  import type { NamingStrategy } from "./naming";
@@ -16,6 +17,8 @@ export interface LexiconEntry {
16
17
  resourceType: string;
17
18
  kind: "resource" | "property";
18
19
  lexicon: "gitlab";
20
+ deprecatedProperties?: string[];
21
+ constraints?: Record<string, PropertyConstraints>;
19
22
  }
20
23
 
21
24
  /**
@@ -34,12 +37,14 @@ export function generateLexiconJSON(
34
37
 
35
38
  const entries = buildRegistry<LexiconEntry>(registryResources, naming, {
36
39
  shortName: gitlabShortName,
37
- buildEntry: (resource, _tsName, _attrs, _propConstraints) => {
40
+ buildEntry: (resource, _tsName, _attrs, propConstraints) => {
38
41
  const r = results.find((res) => res.resource.typeName === resource.typeName);
39
42
  return {
40
43
  resourceType: resource.typeName,
41
44
  kind: (r?.isProperty ? "property" : "resource") as "resource" | "property",
42
45
  lexicon: "gitlab" as const,
46
+ ...(r?.resource.deprecatedProperties?.length && { deprecatedProperties: r.resource.deprecatedProperties }),
47
+ ...(propConstraints && Object.keys(propConstraints).length > 0 && { constraints: propConstraints }),
43
48
  };
44
49
  },
45
50
  buildPropertyEntry: (resourceType, propertyType) => ({
@@ -30,61 +30,56 @@ export interface GitLabGenerateOptions extends GenerateOptions {
30
30
  schemaVersion?: string;
31
31
  }
32
32
 
33
- // Captured from generate() call so the pipeline config can access it
34
- let activeSchemaVersion: string | undefined;
35
-
36
- const gitlabPipelineConfig: GeneratePipelineConfig<GitLabParseResult> = {
37
- fetchSchemas: async (opts) => {
38
- return fetchSchemas(opts.force, activeSchemaVersion);
39
- },
40
-
41
- parseSchema: (_typeName, data) => {
42
- // The CI schema is a single document — parseCISchema returns multiple results.
43
- // The pipeline calls this once per schema entry. We return the first result
44
- // and use augmentResults to inject the rest.
45
- const results = parseCISchema(data);
46
- if (results.length === 0) return null;
47
- // Return the first result; stash the rest for augmentResults
48
- pendingResults = results.slice(1);
49
- return results[0];
50
- },
51
-
52
- createNaming: (results) => new NamingStrategy(results),
53
-
54
- augmentResults: (results, _opts, log) => {
55
- // Add the remaining results from the single-schema parse
56
- if (pendingResults.length > 0) {
57
- results.push(...pendingResults);
58
- log(`Added ${pendingResults.length} additional CI entities from single schema`);
59
- pendingResults = [];
60
- }
61
- log(`Total: ${results.length} CI entity schemas`);
62
- return { results };
63
- },
33
+ /**
34
+ * Run the full GitLab generation pipeline.
35
+ */
36
+ export async function generate(opts: GitLabGenerateOptions = {}): Promise<GenerateResult> {
37
+ // Pipeline state captured in closure — no module-level mutation
38
+ let pendingResults: GitLabParseResult[] = [];
64
39
 
65
- generateRegistry: (results, naming) => {
66
- return generateLexiconJSON(results, naming as NamingStrategy);
67
- },
40
+ const config: GeneratePipelineConfig<GitLabParseResult> = {
41
+ fetchSchemas: async (fetchOpts) => {
42
+ return fetchSchemas(fetchOpts.force, opts.schemaVersion);
43
+ },
68
44
 
69
- generateTypes: (results, naming) => {
70
- return generateTypeScriptDeclarations(results, naming as NamingStrategy);
71
- },
45
+ parseSchema: (_typeName, data) => {
46
+ // The CI schema is a single document — parseCISchema returns multiple results.
47
+ // The pipeline calls this once per schema entry. We return the first result
48
+ // and use augmentResults to inject the rest.
49
+ const results = parseCISchema(data);
50
+ if (results.length === 0) return null;
51
+ // Return the first result; stash the rest for augmentResults
52
+ pendingResults = results.slice(1);
53
+ return results[0];
54
+ },
72
55
 
73
- generateRuntimeIndex: (results, naming) => {
74
- return generateRuntimeIndex(results, naming as NamingStrategy);
75
- },
76
- };
56
+ createNaming: (results) => new NamingStrategy(results),
77
57
 
78
- // Shared state between parseSchema and augmentResults
79
- let pendingResults: GitLabParseResult[] = [];
58
+ augmentResults: (results, _opts, log) => {
59
+ // Add the remaining results from the single-schema parse
60
+ if (pendingResults.length > 0) {
61
+ results.push(...pendingResults);
62
+ log(`Added ${pendingResults.length} additional CI entities from single schema`);
63
+ pendingResults = [];
64
+ }
65
+ log(`Total: ${results.length} CI entity schemas`);
66
+ return { results };
67
+ },
80
68
 
81
- /**
82
- * Run the full GitLab generation pipeline.
83
- */
84
- export async function generate(opts: GitLabGenerateOptions = {}): Promise<GenerateResult> {
85
- pendingResults = [];
86
- activeSchemaVersion = opts.schemaVersion;
87
- return generatePipeline(gitlabPipelineConfig, opts);
69
+ generateRegistry: (results, naming) => {
70
+ return generateLexiconJSON(results, naming as NamingStrategy);
71
+ },
72
+
73
+ generateTypes: (results, naming) => {
74
+ return generateTypeScriptDeclarations(results, naming as NamingStrategy);
75
+ },
76
+
77
+ generateRuntimeIndex: (results, naming) => {
78
+ return generateRuntimeIndex(results, naming as NamingStrategy);
79
+ },
80
+ };
81
+
82
+ return generatePipeline(config, opts);
88
83
  }
89
84
 
90
85
  /**
@@ -29,6 +29,9 @@ const gitlabNamingConfig: NamingConfig = {
29
29
  "GitLab::CI::Environment": "Environment",
30
30
  "GitLab::CI::Trigger": "Trigger",
31
31
  "GitLab::CI::AutoCancel": "AutoCancel",
32
+ "GitLab::CI::WorkflowRule": "WorkflowRule",
33
+ "GitLab::CI::Need": "Need",
34
+ "GitLab::CI::Inherit": "Inherit",
32
35
  },
33
36
  priorityAliases: {},
34
37
  priorityPropertyAliases: {},
@@ -3,7 +3,9 @@
3
3
  * with GitLab-specific manifest building and skill collection.
4
4
  */
5
5
 
6
+ import { createRequire } from "module";
6
7
  import { readFileSync } from "fs";
8
+ const require = createRequire(import.meta.url);
7
9
  import { join, dirname } from "path";
8
10
  import { fileURLToPath } from "url";
9
11
  import type { IntrinsicDef } from "@intentius/chant/lexicon";
@@ -5,9 +5,9 @@ import { loadSchemaFixture } from "../testdata/load-fixtures";
5
5
  const fixture = loadSchemaFixture();
6
6
 
7
7
  describe("parseCISchema", () => {
8
- test("returns 16 entities", () => {
8
+ test("returns 19 entities", () => {
9
9
  const results = parseCISchema(fixture);
10
- expect(results).toHaveLength(16);
10
+ expect(results).toHaveLength(19);
11
11
  });
12
12
 
13
13
  test("returns 3 resource entities", () => {
@@ -20,10 +20,10 @@ describe("parseCISchema", () => {
20
20
  expect(names).toContain("GitLab::CI::Workflow");
21
21
  });
22
22
 
23
- test("returns 13 property entities", () => {
23
+ test("returns 16 property entities", () => {
24
24
  const results = parseCISchema(fixture);
25
25
  const properties = results.filter((r) => r.isProperty);
26
- expect(properties).toHaveLength(13);
26
+ expect(properties).toHaveLength(16);
27
27
  const names = properties.map((r) => r.resource.typeName);
28
28
  expect(names).toContain("GitLab::CI::Artifacts");
29
29
  expect(names).toContain("GitLab::CI::Cache");
@@ -38,6 +38,9 @@ describe("parseCISchema", () => {
38
38
  expect(names).toContain("GitLab::CI::Environment");
39
39
  expect(names).toContain("GitLab::CI::Trigger");
40
40
  expect(names).toContain("GitLab::CI::AutoCancel");
41
+ expect(names).toContain("GitLab::CI::WorkflowRule");
42
+ expect(names).toContain("GitLab::CI::Need");
43
+ expect(names).toContain("GitLab::CI::Inherit");
41
44
  });
42
45
 
43
46
  test("property entities have isProperty set to true", () => {
@@ -125,6 +128,108 @@ describe("Cache entity", () => {
125
128
  });
126
129
  });
127
130
 
131
+ describe("spec conformance — type mappings", () => {
132
+ const results = parseCISchema(fixture);
133
+ const findEntity = (name: string) => results.find((r) => r.resource.typeName === `GitLab::CI::${name}`);
134
+ const findProp = (entityName: string, propName: string) =>
135
+ findEntity(entityName)?.resource.properties.find((p) => p.name === propName);
136
+
137
+ test("Job.environment → Environment | string", () => {
138
+ expect(findProp("Job", "environment")?.tsType).toBe("Environment | string");
139
+ });
140
+
141
+ test("Job.trigger → Trigger | string", () => {
142
+ expect(findProp("Job", "trigger")?.tsType).toBe("Trigger | string");
143
+ });
144
+
145
+ test("Job.release → Release", () => {
146
+ expect(findProp("Job", "release")?.tsType).toBe("Release");
147
+ });
148
+
149
+ test("Job.needs → Need[]", () => {
150
+ expect(findProp("Job", "needs")?.tsType).toBe("Need[]");
151
+ });
152
+
153
+ test("Job.inherit → Inherit", () => {
154
+ expect(findProp("Job", "inherit")?.tsType).toBe("Inherit");
155
+ });
156
+
157
+ test("Workflow.rules → WorkflowRule[]", () => {
158
+ expect(findProp("Workflow", "rules")?.tsType).toBe("WorkflowRule[]");
159
+ });
160
+
161
+ test("AllowFailure.exit_codes → number | number[]", () => {
162
+ expect(findProp("AllowFailure", "exit_codes")?.tsType).toBe("number | number[]");
163
+ });
164
+
165
+ test("Image.pull_policy has no duplicate enum values", () => {
166
+ const pp = findProp("Image", "pull_policy");
167
+ expect(pp).toBeDefined();
168
+ // Should be clean union with parens on array form
169
+ expect(pp!.tsType).toContain("always");
170
+ expect(pp!.tsType).toContain("never");
171
+ expect(pp!.tsType).toContain("if-not-present");
172
+ // Array variant should use parens
173
+ expect(pp!.tsType).toContain(")[]");
174
+ });
175
+
176
+ test("Include has local, remote, template, component fields", () => {
177
+ const include = findEntity("Include");
178
+ expect(include).toBeDefined();
179
+ const propNames = include!.resource.properties.map((p) => p.name);
180
+ expect(propNames).toContain("project");
181
+ expect(propNames).toContain("file");
182
+ expect(propNames).toContain("local");
183
+ expect(propNames).toContain("remote");
184
+ expect(propNames).toContain("template");
185
+ expect(propNames).toContain("component");
186
+ });
187
+
188
+ test("Trigger has include field for child pipelines", () => {
189
+ const trigger = findEntity("Trigger");
190
+ expect(trigger).toBeDefined();
191
+ const propNames = trigger!.resource.properties.map((p) => p.name);
192
+ expect(propNames).toContain("project");
193
+ expect(propNames).toContain("include");
194
+ expect(propNames).toContain("strategy");
195
+ expect(propNames).toContain("forward");
196
+ });
197
+
198
+ test("Need has merged properties from all object variants", () => {
199
+ const need = findEntity("Need");
200
+ expect(need).toBeDefined();
201
+ const propNames = need!.resource.properties.map((p) => p.name);
202
+ expect(propNames).toContain("job");
203
+ expect(propNames).toContain("artifacts");
204
+ expect(propNames).toContain("project");
205
+ expect(propNames).toContain("ref");
206
+ expect(propNames).toContain("pipeline");
207
+ expect(propNames).toContain("optional");
208
+ // job should be required (in all object variants)
209
+ const jobProp = need!.resource.properties.find((p) => p.name === "job");
210
+ expect(jobProp?.required).toBe(true);
211
+ });
212
+
213
+ test("WorkflowRule has restricted when enum", () => {
214
+ const wr = findEntity("WorkflowRule");
215
+ expect(wr).toBeDefined();
216
+ const propNames = wr!.resource.properties.map((p) => p.name);
217
+ expect(propNames).toContain("if");
218
+ expect(propNames).toContain("when");
219
+ expect(propNames).toContain("auto_cancel");
220
+ const whenProp = wr!.resource.properties.find((p) => p.name === "when");
221
+ expect(whenProp?.tsType).toBe('"always" | "never"');
222
+ });
223
+
224
+ test("Inherit has default and variables properties", () => {
225
+ const inherit = findEntity("Inherit");
226
+ expect(inherit).toBeDefined();
227
+ const propNames = inherit!.resource.properties.map((p) => p.name);
228
+ expect(propNames).toContain("default");
229
+ expect(propNames).toContain("variables");
230
+ });
231
+ });
232
+
128
233
  describe("property types and enums", () => {
129
234
  test("entities have empty propertyTypes (nested types extracted as top-level)", () => {
130
235
  const results = parseCISchema(fixture);
@@ -141,6 +246,51 @@ describe("property types and enums", () => {
141
246
  });
142
247
  });
143
248
 
249
+ describe("deprecatedProperties", () => {
250
+ test("all entities have a deprecatedProperties array", () => {
251
+ const results = parseCISchema(fixture);
252
+ for (const r of results) {
253
+ expect(Array.isArray(r.resource.deprecatedProperties)).toBe(true);
254
+ }
255
+ });
256
+
257
+ test("mines deprecation from property description", () => {
258
+ const schema = JSON.stringify({
259
+ definitions: {
260
+ job_template: {
261
+ properties: {
262
+ script: { type: "string" },
263
+ oldProp: { type: "string", description: "Deprecated in 12.0: use newProp instead." },
264
+ newProp: { type: "string", description: "The replacement property" },
265
+ },
266
+ },
267
+ },
268
+ });
269
+ const results = parseCISchema(schema);
270
+ const job = results.find((r) => r.resource.typeName === "GitLab::CI::Job");
271
+ expect(job).toBeDefined();
272
+ expect(job!.resource.deprecatedProperties).toContain("oldProp");
273
+ expect(job!.resource.deprecatedProperties).not.toContain("newProp");
274
+ expect(job!.resource.deprecatedProperties).not.toContain("script");
275
+ });
276
+
277
+ test("empty deprecatedProperties when no deprecation signals", () => {
278
+ const schema = JSON.stringify({
279
+ definitions: {
280
+ job_template: {
281
+ properties: {
282
+ script: { type: "string", description: "Commands to run" },
283
+ stage: { type: "string", description: "Pipeline stage" },
284
+ },
285
+ },
286
+ },
287
+ });
288
+ const results = parseCISchema(schema);
289
+ const job = results.find((r) => r.resource.typeName === "GitLab::CI::Job");
290
+ expect(job!.resource.deprecatedProperties).toEqual([]);
291
+ });
292
+ });
293
+
144
294
  describe("edge cases", () => {
145
295
  test("empty schema returns empty results", () => {
146
296
  const emptySchema = JSON.stringify({ definitions: {} });