@intentius/chant-lexicon-gitlab 0.0.15 → 0.0.18

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 (67) hide show
  1. package/dist/integrity.json +18 -4
  2. package/dist/manifest.json +1 -1
  3. package/dist/rules/wgl016.ts +82 -0
  4. package/dist/rules/wgl017.ts +54 -0
  5. package/dist/rules/wgl018.ts +39 -0
  6. package/dist/rules/wgl019.ts +44 -0
  7. package/dist/rules/wgl020.ts +56 -0
  8. package/dist/rules/wgl021.ts +62 -0
  9. package/dist/rules/wgl022.ts +44 -0
  10. package/dist/rules/wgl023.ts +51 -0
  11. package/dist/rules/wgl024.ts +46 -0
  12. package/dist/rules/wgl025.ts +49 -0
  13. package/dist/rules/wgl026.ts +67 -0
  14. package/dist/rules/wgl027.ts +54 -0
  15. package/dist/rules/wgl028.ts +67 -0
  16. package/dist/rules/yaml-helpers.ts +82 -0
  17. package/dist/skills/chant-gitlab.md +1 -1
  18. package/dist/skills/gitlab-ci-patterns.md +309 -0
  19. package/package.json +3 -3
  20. package/src/codegen/fetch.test.ts +30 -0
  21. package/src/codegen/generate.test.ts +65 -0
  22. package/src/codegen/idempotency.test.ts +28 -0
  23. package/src/codegen/naming.test.ts +93 -0
  24. package/src/codegen/snapshot.test.ts +28 -19
  25. package/src/composites/composites.test.ts +160 -0
  26. package/src/coverage.test.ts +15 -7
  27. package/src/import/roundtrip.test.ts +132 -0
  28. package/src/lint/post-synth/wgl016.test.ts +72 -0
  29. package/src/lint/post-synth/wgl016.ts +82 -0
  30. package/src/lint/post-synth/wgl017.test.ts +53 -0
  31. package/src/lint/post-synth/wgl017.ts +54 -0
  32. package/src/lint/post-synth/wgl018.test.ts +69 -0
  33. package/src/lint/post-synth/wgl018.ts +39 -0
  34. package/src/lint/post-synth/wgl019.test.ts +76 -0
  35. package/src/lint/post-synth/wgl019.ts +44 -0
  36. package/src/lint/post-synth/wgl020.test.ts +54 -0
  37. package/src/lint/post-synth/wgl020.ts +56 -0
  38. package/src/lint/post-synth/wgl021.test.ts +62 -0
  39. package/src/lint/post-synth/wgl021.ts +62 -0
  40. package/src/lint/post-synth/wgl022.test.ts +86 -0
  41. package/src/lint/post-synth/wgl022.ts +44 -0
  42. package/src/lint/post-synth/wgl023.test.ts +88 -0
  43. package/src/lint/post-synth/wgl023.ts +51 -0
  44. package/src/lint/post-synth/wgl024.test.ts +77 -0
  45. package/src/lint/post-synth/wgl024.ts +46 -0
  46. package/src/lint/post-synth/wgl025.test.ts +85 -0
  47. package/src/lint/post-synth/wgl025.ts +49 -0
  48. package/src/lint/post-synth/wgl026.test.ts +87 -0
  49. package/src/lint/post-synth/wgl026.ts +67 -0
  50. package/src/lint/post-synth/wgl027.test.ts +84 -0
  51. package/src/lint/post-synth/wgl027.ts +54 -0
  52. package/src/lint/post-synth/wgl028.test.ts +95 -0
  53. package/src/lint/post-synth/wgl028.ts +67 -0
  54. package/src/lint/post-synth/yaml-helpers.ts +82 -0
  55. package/src/lsp/completions.test.ts +16 -6
  56. package/src/lsp/hover.test.ts +18 -7
  57. package/src/plugin.test.ts +15 -2
  58. package/src/plugin.ts +66 -3
  59. package/src/skills/gitlab-ci-patterns.md +309 -0
  60. package/src/testdata/pipelines/deploy-envs.gitlab-ci.yml +60 -0
  61. package/src/testdata/pipelines/docker-build.gitlab-ci.yml +41 -0
  62. package/src/testdata/pipelines/includes-templates.gitlab-ci.yml +52 -0
  63. package/src/testdata/pipelines/monorepo.gitlab-ci.yml +51 -0
  64. package/src/testdata/pipelines/multi-stage.gitlab-ci.yml +56 -0
  65. package/src/testdata/pipelines/simple.gitlab-ci.yml +9 -0
  66. package/src/validate.test.ts +12 -6
  67. package/src/variables.test.ts +58 -0
@@ -0,0 +1,84 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { DECLARABLE_MARKER, type Declarable } from "@intentius/chant/declarable";
3
+ import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
4
+ import { wgl027 } from "./wgl027";
5
+
6
+ class MockJob implements Declarable {
7
+ readonly [DECLARABLE_MARKER] = true as const;
8
+ readonly lexicon = "gitlab";
9
+ readonly entityType = "GitLab::CI::Job";
10
+ readonly kind = "resource" as const;
11
+ readonly props: Record<string, unknown>;
12
+
13
+ constructor(props: Record<string, unknown> = {}) {
14
+ this.props = props;
15
+ }
16
+ }
17
+
18
+ function makeCtx(entities: Map<string, Declarable>): PostSynthContext {
19
+ return {
20
+ outputs: new Map(),
21
+ entities,
22
+ buildResult: {
23
+ outputs: new Map(),
24
+ entities,
25
+ warnings: [],
26
+ errors: [],
27
+ sourceFileCount: 1,
28
+ },
29
+ };
30
+ }
31
+
32
+ describe("WGL027: Empty Script", () => {
33
+ test("check metadata", () => {
34
+ expect(wgl027.id).toBe("WGL027");
35
+ expect(wgl027.description).toContain("Empty script");
36
+ });
37
+
38
+ test("flags empty script array", () => {
39
+ const entities = new Map<string, Declarable>([
40
+ ["emptyJob", new MockJob({ script: [] })],
41
+ ]);
42
+ const diags = wgl027.check(makeCtx(entities));
43
+ expect(diags).toHaveLength(1);
44
+ expect(diags[0].severity).toBe("error");
45
+ expect(diags[0].message).toContain("emptyJob");
46
+ });
47
+
48
+ test("flags script with only empty strings", () => {
49
+ const entities = new Map<string, Declarable>([
50
+ ["blankJob", new MockJob({ script: ["", " "] })],
51
+ ]);
52
+ const diags = wgl027.check(makeCtx(entities));
53
+ expect(diags).toHaveLength(1);
54
+ });
55
+
56
+ test("flags empty string script", () => {
57
+ const entities = new Map<string, Declarable>([
58
+ ["strJob", new MockJob({ script: "" })],
59
+ ]);
60
+ const diags = wgl027.check(makeCtx(entities));
61
+ expect(diags).toHaveLength(1);
62
+ });
63
+
64
+ test("does not flag valid script", () => {
65
+ const entities = new Map<string, Declarable>([
66
+ ["validJob", new MockJob({ script: ["npm test"] })],
67
+ ]);
68
+ const diags = wgl027.check(makeCtx(entities));
69
+ expect(diags).toHaveLength(0);
70
+ });
71
+
72
+ test("does not flag job without script", () => {
73
+ const entities = new Map<string, Declarable>([
74
+ ["triggerJob", new MockJob({ trigger: "other-project" })],
75
+ ]);
76
+ const diags = wgl027.check(makeCtx(entities));
77
+ expect(diags).toHaveLength(0);
78
+ });
79
+
80
+ test("no diagnostics on empty entities", () => {
81
+ const diags = wgl027.check(makeCtx(new Map()));
82
+ expect(diags).toHaveLength(0);
83
+ });
84
+ });
@@ -0,0 +1,54 @@
1
+ /**
2
+ * WGL027: Empty Script
3
+ *
4
+ * Detects jobs with `script: []` or scripts containing only empty strings.
5
+ * GitLab rejects jobs with empty scripts at pipeline validation time.
6
+ */
7
+
8
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
9
+ import { isPropertyDeclarable } from "@intentius/chant/declarable";
10
+
11
+ export const wgl027: PostSynthCheck = {
12
+ id: "WGL027",
13
+ description: "Empty script — jobs with empty or blank script entries",
14
+
15
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
16
+ const diagnostics: PostSynthDiagnostic[] = [];
17
+
18
+ for (const [entityName, entity] of ctx.entities) {
19
+ if (isPropertyDeclarable(entity)) continue;
20
+ const entityType = (entity as Record<string, unknown>).entityType as string;
21
+ if (entityType !== "GitLab::CI::Job") continue;
22
+
23
+ const props = (entity as Record<string, unknown>).props as Record<string, unknown> | undefined;
24
+ if (!props) continue;
25
+
26
+ const script = props.script;
27
+ if (script === undefined || script === null) continue;
28
+
29
+ let isEmpty = false;
30
+
31
+ if (Array.isArray(script)) {
32
+ if (script.length === 0) {
33
+ isEmpty = true;
34
+ } else if (script.every((s) => typeof s === "string" && s.trim() === "")) {
35
+ isEmpty = true;
36
+ }
37
+ } else if (typeof script === "string" && script.trim() === "") {
38
+ isEmpty = true;
39
+ }
40
+
41
+ if (isEmpty) {
42
+ diagnostics.push({
43
+ checkId: "WGL027",
44
+ severity: "error",
45
+ message: `Job "${entityName}" has an empty script — GitLab will reject this pipeline`,
46
+ entity: entityName,
47
+ lexicon: "gitlab",
48
+ });
49
+ }
50
+ }
51
+
52
+ return diagnostics;
53
+ },
54
+ };
@@ -0,0 +1,95 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { wgl028, checkRedundantNeeds } from "./wgl028";
3
+
4
+ describe("WGL028: Redundant Needs", () => {
5
+ test("check metadata", () => {
6
+ expect(wgl028.id).toBe("WGL028");
7
+ expect(wgl028.description).toContain("Redundant");
8
+ });
9
+
10
+ test("flags needs pointing to earlier stage job", () => {
11
+ const yaml = `stages:
12
+ - build
13
+ - test
14
+ - deploy
15
+
16
+ build-app:
17
+ stage: build
18
+ script:
19
+ - npm build
20
+
21
+ deploy-app:
22
+ stage: deploy
23
+ needs:
24
+ - build-app
25
+ script:
26
+ - deploy.sh
27
+ `;
28
+ const diags = checkRedundantNeeds(yaml);
29
+ expect(diags).toHaveLength(1);
30
+ expect(diags[0].severity).toBe("info");
31
+ expect(diags[0].message).toContain("deploy-app");
32
+ expect(diags[0].message).toContain("build-app");
33
+ expect(diags[0].message).toContain("earlier stage");
34
+ });
35
+
36
+ test("does not flag needs within same stage", () => {
37
+ const yaml = `stages:
38
+ - build
39
+ - test
40
+
41
+ test-a:
42
+ stage: test
43
+ script:
44
+ - test-a
45
+
46
+ test-b:
47
+ stage: test
48
+ needs:
49
+ - test-a
50
+ script:
51
+ - test-b
52
+ `;
53
+ const diags = checkRedundantNeeds(yaml);
54
+ expect(diags).toHaveLength(0);
55
+ });
56
+
57
+ test("does not flag when no stages defined", () => {
58
+ const yaml = `build-app:
59
+ script:
60
+ - npm build
61
+
62
+ deploy-app:
63
+ needs:
64
+ - build-app
65
+ script:
66
+ - deploy.sh
67
+ `;
68
+ const diags = checkRedundantNeeds(yaml);
69
+ expect(diags).toHaveLength(0);
70
+ });
71
+
72
+ test("does not flag when no needs defined", () => {
73
+ const yaml = `stages:
74
+ - build
75
+ - test
76
+
77
+ build-app:
78
+ stage: build
79
+ script:
80
+ - npm build
81
+
82
+ test-app:
83
+ stage: test
84
+ script:
85
+ - npm test
86
+ `;
87
+ const diags = checkRedundantNeeds(yaml);
88
+ expect(diags).toHaveLength(0);
89
+ });
90
+
91
+ test("no diagnostics on empty yaml", () => {
92
+ const diags = checkRedundantNeeds("");
93
+ expect(diags).toHaveLength(0);
94
+ });
95
+ });
@@ -0,0 +1,67 @@
1
+ /**
2
+ * WGL028: Redundant Needs
3
+ *
4
+ * Detects `needs:` entries that list jobs already implied by stage ordering.
5
+ * While not incorrect, redundant needs add noise and make the pipeline
6
+ * harder to maintain. This is informational only.
7
+ */
8
+
9
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
10
+ import { getPrimaryOutput, extractStages, extractJobs } from "./yaml-helpers";
11
+
12
+ export function checkRedundantNeeds(yaml: string): PostSynthDiagnostic[] {
13
+ const diagnostics: PostSynthDiagnostic[] = [];
14
+
15
+ const stages = extractStages(yaml);
16
+ if (stages.length === 0) return diagnostics;
17
+
18
+ const stageIndex = new Map<string, number>();
19
+ for (let i = 0; i < stages.length; i++) {
20
+ stageIndex.set(stages[i], i);
21
+ }
22
+
23
+ const jobs = extractJobs(yaml);
24
+
25
+ for (const [jobName, job] of jobs) {
26
+ if (!job.needs || !job.stage) continue;
27
+
28
+ const jobStageIdx = stageIndex.get(job.stage);
29
+ if (jobStageIdx === undefined) continue;
30
+
31
+ for (const need of job.needs) {
32
+ const neededJob = jobs.get(need);
33
+ if (!neededJob?.stage) continue;
34
+
35
+ const needStageIdx = stageIndex.get(neededJob.stage);
36
+ if (needStageIdx === undefined) continue;
37
+
38
+ // If the needed job is in an earlier stage, it's already implied
39
+ // by GitLab's default stage-based ordering
40
+ if (needStageIdx < jobStageIdx) {
41
+ diagnostics.push({
42
+ checkId: "WGL028",
43
+ severity: "info",
44
+ message: `Job "${jobName}" lists "${need}" in needs: but it's already in an earlier stage (${neededJob.stage} → ${job.stage})`,
45
+ entity: jobName,
46
+ lexicon: "gitlab",
47
+ });
48
+ }
49
+ }
50
+ }
51
+
52
+ return diagnostics;
53
+ }
54
+
55
+ export const wgl028: PostSynthCheck = {
56
+ id: "WGL028",
57
+ description: "Redundant needs — needs listing jobs already implied by stage ordering",
58
+
59
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
60
+ const diagnostics: PostSynthDiagnostic[] = [];
61
+ for (const [, output] of ctx.outputs) {
62
+ const yaml = getPrimaryOutput(output);
63
+ diagnostics.push(...checkRedundantNeeds(yaml));
64
+ }
65
+ return diagnostics;
66
+ },
67
+ };
@@ -141,3 +141,85 @@ export function extractJobs(yaml: string): Map<string, ParsedJob> {
141
141
  export function hasInclude(yaml: string): boolean {
142
142
  return /^include:/m.test(yaml);
143
143
  }
144
+
145
+ /**
146
+ * Extract global variables from serialized YAML.
147
+ */
148
+ export function extractGlobalVariables(yaml: string): Map<string, string> {
149
+ const vars = new Map<string, string>();
150
+ const match = yaml.match(/^variables:\n((?:\s+.+\n?)+)/m);
151
+ if (!match) return vars;
152
+
153
+ for (const line of match[1].split("\n")) {
154
+ const kv = line.match(/^\s+(\w+):\s+(.+)$/);
155
+ if (kv) {
156
+ vars.set(kv[1], kv[2].trim().replace(/^['"]|['"]$/g, ""));
157
+ }
158
+ }
159
+ return vars;
160
+ }
161
+
162
+ /**
163
+ * Extract the full section text for a given job name.
164
+ */
165
+ export function extractJobSection(yaml: string, jobName: string): string | null {
166
+ const sections = yaml.split("\n\n");
167
+ for (const section of sections) {
168
+ const lines = section.split("\n");
169
+ if (lines.length > 0 && lines[0].startsWith(`${jobName}:`)) {
170
+ return section;
171
+ }
172
+ }
173
+ return null;
174
+ }
175
+
176
+ /**
177
+ * Extract rules from a job section.
178
+ */
179
+ export function extractJobRules(section: string): ParsedRule[] {
180
+ const rules: ParsedRule[] = [];
181
+ const lines = section.split("\n");
182
+
183
+ let inRules = false;
184
+ let currentRule: ParsedRule = {};
185
+
186
+ for (const line of lines) {
187
+ if (line.match(/^\s+rules:$/)) {
188
+ inRules = true;
189
+ continue;
190
+ }
191
+
192
+ if (inRules) {
193
+ const ruleStart = line.match(/^\s+- (if|when|changes):\s*(.*)$/);
194
+ if (ruleStart) {
195
+ if (Object.keys(currentRule).length > 0) {
196
+ rules.push(currentRule);
197
+ }
198
+ currentRule = {};
199
+ if (ruleStart[1] === "if") currentRule.if = ruleStart[2].trim();
200
+ if (ruleStart[1] === "when") currentRule.when = ruleStart[2].trim();
201
+ continue;
202
+ }
203
+
204
+ const whenMatch = line.match(/^\s+when:\s+(.+)$/);
205
+ if (whenMatch) {
206
+ currentRule.when = whenMatch[1].trim();
207
+ continue;
208
+ }
209
+
210
+ // End of rules block
211
+ if (!line.match(/^\s+\s/) || line.match(/^\s+[a-z_]+:/) && !line.match(/^\s+when:/)) {
212
+ if (Object.keys(currentRule).length > 0) {
213
+ rules.push(currentRule);
214
+ }
215
+ inRules = false;
216
+ }
217
+ }
218
+ }
219
+
220
+ if (inRules && Object.keys(currentRule).length > 0) {
221
+ rules.push(currentRule);
222
+ }
223
+
224
+ return rules;
225
+ }
@@ -1,7 +1,12 @@
1
1
  import { describe, test, expect } from "bun:test";
2
- import { gitlabCompletions } from "./completions";
2
+ import { existsSync } from "fs";
3
+ import { join, dirname } from "path";
4
+ import { fileURLToPath } from "url";
3
5
  import type { CompletionContext } from "@intentius/chant/lsp/types";
4
6
 
7
+ const generatedDir = join(dirname(dirname(fileURLToPath(import.meta.url))), "generated");
8
+ const hasGenerated = existsSync(join(generatedDir, "lexicon-gitlab.json"));
9
+
5
10
  function makeCtx(overrides: Partial<CompletionContext>): CompletionContext {
6
11
  return {
7
12
  uri: "file:///test.ts",
@@ -14,7 +19,8 @@ function makeCtx(overrides: Partial<CompletionContext>): CompletionContext {
14
19
  }
15
20
 
16
21
  describe("gitlabCompletions", () => {
17
- test("returns resource completions for 'new ' prefix", () => {
22
+ test.skipIf(!hasGenerated)("returns resource completions for 'new ' prefix", async () => {
23
+ const { gitlabCompletions } = await import("./completions");
18
24
  const ctx = makeCtx({
19
25
  linePrefix: "const j = new Job",
20
26
  wordAtCursor: "Job",
@@ -29,7 +35,8 @@ describe("gitlabCompletions", () => {
29
35
  expect(job!.kind).toBe("resource");
30
36
  });
31
37
 
32
- test("returns all resource completions when no filter", () => {
38
+ test.skipIf(!hasGenerated)("returns all resource completions when no filter", async () => {
39
+ const { gitlabCompletions } = await import("./completions");
33
40
  const ctx = makeCtx({
34
41
  linePrefix: "const x = new ",
35
42
  wordAtCursor: "",
@@ -44,7 +51,8 @@ describe("gitlabCompletions", () => {
44
51
  expect(labels).toContain("Workflow");
45
52
  });
46
53
 
47
- test("filters completions by prefix", () => {
54
+ test.skipIf(!hasGenerated)("filters completions by prefix", async () => {
55
+ const { gitlabCompletions } = await import("./completions");
48
56
  const ctx = makeCtx({
49
57
  linePrefix: "const x = new D",
50
58
  wordAtCursor: "D",
@@ -58,7 +66,8 @@ describe("gitlabCompletions", () => {
58
66
  expect(labels).not.toContain("Job");
59
67
  });
60
68
 
61
- test("returns empty for non-constructor context", () => {
69
+ test.skipIf(!hasGenerated)("returns empty for non-constructor context", async () => {
70
+ const { gitlabCompletions } = await import("./completions");
62
71
  const ctx = makeCtx({
63
72
  linePrefix: "const x = foo(",
64
73
  wordAtCursor: "",
@@ -70,7 +79,8 @@ describe("gitlabCompletions", () => {
70
79
  expect(items).toHaveLength(0);
71
80
  });
72
81
 
73
- test("completion items have detail with resource type", () => {
82
+ test.skipIf(!hasGenerated)("completion items have detail with resource type", async () => {
83
+ const { gitlabCompletions } = await import("./completions");
74
84
  const ctx = makeCtx({
75
85
  linePrefix: "const j = new Job",
76
86
  wordAtCursor: "Job",
@@ -1,7 +1,12 @@
1
1
  import { describe, test, expect } from "bun:test";
2
- import { gitlabHover } from "./hover";
2
+ import { existsSync } from "fs";
3
+ import { join, dirname } from "path";
4
+ import { fileURLToPath } from "url";
3
5
  import type { HoverContext } from "@intentius/chant/lsp/types";
4
6
 
7
+ const generatedDir = join(dirname(dirname(fileURLToPath(import.meta.url))), "generated");
8
+ const hasGenerated = existsSync(join(generatedDir, "lexicon-gitlab.json"));
9
+
5
10
  function makeCtx(overrides: Partial<HoverContext>): HoverContext {
6
11
  return {
7
12
  uri: "file:///test.ts",
@@ -14,7 +19,8 @@ function makeCtx(overrides: Partial<HoverContext>): HoverContext {
14
19
  }
15
20
 
16
21
  describe("gitlabHover", () => {
17
- test("returns hover for Job class", () => {
22
+ test.skipIf(!hasGenerated)("returns hover for Job class", async () => {
23
+ const { gitlabHover } = await import("./hover");
18
24
  const ctx = makeCtx({ word: "Job" });
19
25
  const hover = gitlabHover(ctx);
20
26
  expect(hover).toBeDefined();
@@ -23,7 +29,8 @@ describe("gitlabHover", () => {
23
29
  expect(hover!.contents).toContain("Resource entity");
24
30
  });
25
31
 
26
- test("returns hover for property entity", () => {
32
+ test.skipIf(!hasGenerated)("returns hover for property entity", async () => {
33
+ const { gitlabHover } = await import("./hover");
27
34
  const ctx = makeCtx({ word: "Cache" });
28
35
  const hover = gitlabHover(ctx);
29
36
  expect(hover).toBeDefined();
@@ -32,27 +39,31 @@ describe("gitlabHover", () => {
32
39
  expect(hover!.contents).toContain("Property entity");
33
40
  });
34
41
 
35
- test("returns hover for Default", () => {
42
+ test.skipIf(!hasGenerated)("returns hover for Default", async () => {
43
+ const { gitlabHover } = await import("./hover");
36
44
  const ctx = makeCtx({ word: "Default" });
37
45
  const hover = gitlabHover(ctx);
38
46
  expect(hover).toBeDefined();
39
47
  expect(hover!.contents).toContain("GitLab::CI::Default");
40
48
  });
41
49
 
42
- test("returns hover for Artifacts", () => {
50
+ test.skipIf(!hasGenerated)("returns hover for Artifacts", async () => {
51
+ const { gitlabHover } = await import("./hover");
43
52
  const ctx = makeCtx({ word: "Artifacts" });
44
53
  const hover = gitlabHover(ctx);
45
54
  expect(hover).toBeDefined();
46
55
  expect(hover!.contents).toContain("GitLab::CI::Artifacts");
47
56
  });
48
57
 
49
- test("returns undefined for unknown word", () => {
58
+ test.skipIf(!hasGenerated)("returns undefined for unknown word", async () => {
59
+ const { gitlabHover } = await import("./hover");
50
60
  const ctx = makeCtx({ word: "UnknownEntity" });
51
61
  const hover = gitlabHover(ctx);
52
62
  expect(hover).toBeUndefined();
53
63
  });
54
64
 
55
- test("returns undefined for empty word", () => {
65
+ test.skipIf(!hasGenerated)("returns undefined for empty word", async () => {
66
+ const { gitlabHover } = await import("./hover");
56
67
  const ctx = makeCtx({ word: "" });
57
68
  const hover = gitlabHover(ctx);
58
69
  expect(hover).toBeUndefined();
@@ -83,7 +83,7 @@ describe("gitlabPlugin", () => {
83
83
 
84
84
  test("returns post-synth checks", () => {
85
85
  const checks = gitlabPlugin.postSynthChecks!();
86
- expect(checks).toHaveLength(6);
86
+ expect(checks).toHaveLength(19);
87
87
  const ids = checks.map((c) => c.id);
88
88
  expect(ids).toContain("WGL010");
89
89
  expect(ids).toContain("WGL011");
@@ -91,6 +91,19 @@ describe("gitlabPlugin", () => {
91
91
  expect(ids).toContain("WGL013");
92
92
  expect(ids).toContain("WGL014");
93
93
  expect(ids).toContain("WGL015");
94
+ expect(ids).toContain("WGL016");
95
+ expect(ids).toContain("WGL017");
96
+ expect(ids).toContain("WGL018");
97
+ expect(ids).toContain("WGL019");
98
+ expect(ids).toContain("WGL020");
99
+ expect(ids).toContain("WGL021");
100
+ expect(ids).toContain("WGL022");
101
+ expect(ids).toContain("WGL023");
102
+ expect(ids).toContain("WGL024");
103
+ expect(ids).toContain("WGL025");
104
+ expect(ids).toContain("WGL026");
105
+ expect(ids).toContain("WGL027");
106
+ expect(ids).toContain("WGL028");
94
107
  });
95
108
 
96
109
  // -----------------------------------------------------------------------
@@ -182,7 +195,7 @@ describe("gitlabPlugin", () => {
182
195
 
183
196
  test("returns skills", () => {
184
197
  const skills = gitlabPlugin.skills!();
185
- expect(skills).toHaveLength(1);
198
+ expect(skills.length).toBeGreaterThanOrEqual(2);
186
199
  expect(skills[0].name).toBe("chant-gitlab");
187
200
  expect(skills[0].description).toBeDefined();
188
201
  expect(skills[0].content).toContain("skill: chant-gitlab");
package/src/plugin.ts CHANGED
@@ -31,7 +31,24 @@ export const gitlabPlugin: LexiconPlugin = {
31
31
  const { wgl013 } = require("./lint/post-synth/wgl013");
32
32
  const { wgl014 } = require("./lint/post-synth/wgl014");
33
33
  const { wgl015 } = require("./lint/post-synth/wgl015");
34
- return [wgl010, wgl011, wgl012, wgl013, wgl014, wgl015];
34
+ const { wgl016 } = require("./lint/post-synth/wgl016");
35
+ const { wgl017 } = require("./lint/post-synth/wgl017");
36
+ const { wgl018 } = require("./lint/post-synth/wgl018");
37
+ const { wgl019 } = require("./lint/post-synth/wgl019");
38
+ const { wgl020 } = require("./lint/post-synth/wgl020");
39
+ const { wgl021 } = require("./lint/post-synth/wgl021");
40
+ const { wgl022 } = require("./lint/post-synth/wgl022");
41
+ const { wgl023 } = require("./lint/post-synth/wgl023");
42
+ const { wgl024 } = require("./lint/post-synth/wgl024");
43
+ const { wgl025 } = require("./lint/post-synth/wgl025");
44
+ const { wgl026 } = require("./lint/post-synth/wgl026");
45
+ const { wgl027 } = require("./lint/post-synth/wgl027");
46
+ const { wgl028 } = require("./lint/post-synth/wgl028");
47
+ return [
48
+ wgl010, wgl011, wgl012, wgl013, wgl014, wgl015,
49
+ wgl016, wgl017, wgl018, wgl019, wgl020, wgl021,
50
+ wgl022, wgl023, wgl024, wgl025, wgl026, wgl027, wgl028,
51
+ ];
35
52
  },
36
53
 
37
54
  intrinsics(): IntrinsicDef[] {
@@ -342,7 +359,7 @@ export const deploy = new Job({
342
359
  },
343
360
 
344
361
  skills(): SkillDefinition[] {
345
- return [
362
+ const skills: SkillDefinition[] = [
346
363
  {
347
364
  name: "chant-gitlab",
348
365
  description: "GitLab CI/CD pipeline lifecycle — build, validate, deploy, monitor, rollback, and troubleshoot",
@@ -495,7 +512,7 @@ Add \`"dry_run": true, "include_merged_yaml": true\` for full expansion with inc
495
512
  | Step | Catches | When to run |
496
513
  |------|---------|-------------|
497
514
  | \`chant lint\` | Deprecated only/except (WGL001), missing script (WGL002), missing stage (WGL003), artifacts without expiry (WGL004) | Every edit |
498
- | \`chant build\` | Post-synth checks: undefined stages (WGL010), unreachable jobs (WGL011), deprecated properties (WGL012), invalid needs targets (WGL013), invalid extends targets (WGL014), circular needs chains (WGL015) | Before push |
515
+ | \`chant build\` | Post-synth checks: undefined stages (WGL010), unreachable jobs (WGL011), deprecated properties (WGL012), invalid needs/extends targets (WGL013-014), circular needs (WGL015), secrets in variables (WGL016), insecure registry (WGL017), missing timeout/retry (WGL018-019), duplicate jobs (WGL020), unused variables (WGL021), missing artifacts expiry (WGL022), overly broad rules (WGL023), manual without allow_failure (WGL024), missing cache key (WGL025), DinD without TLS (WGL026), empty script (WGL027), redundant needs (WGL028) | Before push |
499
516
  | CI Lint API | GitLab-specific validation: include resolution, variable expansion, YAML schema errors | Before merge to default branch |
500
517
 
501
518
  Always run all three before deploying. Lint catches things the API cannot (and vice versa).
@@ -944,5 +961,51 @@ curl --header "PRIVATE-TOKEN: $GITLAB_TOKEN" \\
944
961
  ],
945
962
  },
946
963
  ];
964
+
965
+ // Load file-based skills from src/skills/
966
+ const { readFileSync } = require("fs");
967
+ const { join, dirname } = require("path");
968
+ const { fileURLToPath } = require("url");
969
+ const dir = dirname(fileURLToPath(import.meta.url));
970
+
971
+ const skillFiles = [
972
+ {
973
+ file: "gitlab-ci-patterns.md",
974
+ name: "gitlab-ci-patterns",
975
+ description: "GitLab CI/CD pipeline stages, caching, artifacts, includes, and advanced patterns",
976
+ triggers: [
977
+ { type: "context" as const, value: "gitlab pipeline" },
978
+ { type: "context" as const, value: "gitlab cache" },
979
+ { type: "context" as const, value: "gitlab artifacts" },
980
+ { type: "context" as const, value: "gitlab include" },
981
+ { type: "context" as const, value: "gitlab stages" },
982
+ { type: "context" as const, value: "review app" },
983
+ ],
984
+ parameters: [],
985
+ examples: [
986
+ {
987
+ title: "Pipeline with caching",
988
+ input: "Set up a Node.js pipeline with proper caching",
989
+ output: "import { Job, Cache } from \"@intentius/chant-lexicon-gitlab\";\n\nconst cache = new Cache({ key: { files: [\"package-lock.json\"] }, paths: [\"node_modules/\"] });",
990
+ },
991
+ ],
992
+ },
993
+ ];
994
+
995
+ for (const skill of skillFiles) {
996
+ try {
997
+ const content = readFileSync(join(dir, "skills", skill.file), "utf-8");
998
+ skills.push({
999
+ name: skill.name,
1000
+ description: skill.description,
1001
+ content,
1002
+ triggers: skill.triggers,
1003
+ parameters: skill.parameters,
1004
+ examples: skill.examples,
1005
+ });
1006
+ } catch { /* skip missing skills */ }
1007
+ }
1008
+
1009
+ return skills;
947
1010
  },
948
1011
  };