@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,93 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { gitlabShortName, type GitLabParseResult } from "./parse";
3
+ import { NamingStrategy } from "./naming";
4
+
5
+ /**
6
+ * Chant's naming philosophy for GitLab: simple, flat names.
7
+ * Every priorityName should be the short name from the GitLab::CI:: namespace.
8
+ * No "CI" prefix should leak into class names.
9
+ */
10
+
11
+ /** Build a minimal GitLabParseResult for a given typeName. */
12
+ function stubResult(typeName: string, isProperty?: boolean): GitLabParseResult {
13
+ return {
14
+ resource: {
15
+ typeName,
16
+ properties: [],
17
+ attributes: [],
18
+ deprecatedProperties: [],
19
+ },
20
+ propertyTypes: [],
21
+ enums: [],
22
+ isProperty,
23
+ };
24
+ }
25
+
26
+ describe("naming spec fidelity", () => {
27
+ const typeNames = [
28
+ "GitLab::CI::Job",
29
+ "GitLab::CI::Default",
30
+ "GitLab::CI::Workflow",
31
+ "GitLab::CI::Artifacts",
32
+ "GitLab::CI::Cache",
33
+ "GitLab::CI::Image",
34
+ "GitLab::CI::Rule",
35
+ "GitLab::CI::Retry",
36
+ "GitLab::CI::AllowFailure",
37
+ "GitLab::CI::Parallel",
38
+ "GitLab::CI::Include",
39
+ "GitLab::CI::Release",
40
+ "GitLab::CI::Environment",
41
+ "GitLab::CI::Trigger",
42
+ "GitLab::CI::AutoCancel",
43
+ "GitLab::CI::WorkflowRule",
44
+ "GitLab::CI::Need",
45
+ "GitLab::CI::Inherit",
46
+ ];
47
+
48
+ const results = typeNames.map((t) => stubResult(t));
49
+ const strategy = new NamingStrategy(results);
50
+
51
+ test("all priority names resolve to short name", () => {
52
+ for (const typeName of typeNames) {
53
+ const resolved = strategy.resolve(typeName);
54
+ const shortName = gitlabShortName(typeName);
55
+ expect(resolved).toBe(shortName);
56
+ }
57
+ });
58
+
59
+ test("no priority name has CI prefix", () => {
60
+ for (const typeName of typeNames) {
61
+ const resolved = strategy.resolve(typeName)!;
62
+ // "CI" as a prefix (e.g. "CIJob") would be wrong; "Cache" starting with "C" is fine
63
+ expect(resolved.startsWith("CI")).toBe(false);
64
+ }
65
+ });
66
+
67
+ test("no priority name has GitLab prefix", () => {
68
+ for (const typeName of typeNames) {
69
+ const resolved = strategy.resolve(typeName)!;
70
+ expect(resolved.startsWith("GitLab")).toBe(false);
71
+ }
72
+ });
73
+
74
+ test("all 18 priority names are mapped", () => {
75
+ expect(typeNames).toHaveLength(18);
76
+ for (const typeName of typeNames) {
77
+ expect(strategy.resolve(typeName)).toBeDefined();
78
+ }
79
+ });
80
+ });
81
+
82
+ describe("property type naming", () => {
83
+ test("resolves property entities correctly", () => {
84
+ const results = [
85
+ stubResult("GitLab::CI::Artifacts", true),
86
+ stubResult("GitLab::CI::Cache", true),
87
+ ];
88
+ const strategy = new NamingStrategy(results);
89
+
90
+ expect(strategy.resolve("GitLab::CI::Artifacts")).toBe("Artifacts");
91
+ expect(strategy.resolve("GitLab::CI::Cache")).toBe("Cache");
92
+ });
93
+ });
@@ -1,19 +1,21 @@
1
1
  import { describe, test, expect } from "bun:test";
2
- import { readFileSync } from "fs";
2
+ import { existsSync, readFileSync } from "fs";
3
3
  import { join, dirname } from "path";
4
4
  import { fileURLToPath } from "url";
5
5
 
6
6
  const generatedDir = join(dirname(dirname(fileURLToPath(import.meta.url))), "generated");
7
+ const hasGenerated = existsSync(join(generatedDir, "lexicon-gitlab.json"));
7
8
 
8
9
  describe("generated lexicon-gitlab.json", () => {
9
- const content = readFileSync(join(generatedDir, "lexicon-gitlab.json"), "utf-8");
10
- const registry = JSON.parse(content);
11
-
12
- test("is valid JSON with expected entries", () => {
10
+ test.skipIf(!hasGenerated)("is valid JSON with expected entries", () => {
11
+ const content = readFileSync(join(generatedDir, "lexicon-gitlab.json"), "utf-8");
12
+ const registry = JSON.parse(content);
13
13
  expect(Object.keys(registry)).toHaveLength(19);
14
14
  });
15
15
 
16
- test("contains all resource entities", () => {
16
+ test.skipIf(!hasGenerated)("contains all resource entities", () => {
17
+ const content = readFileSync(join(generatedDir, "lexicon-gitlab.json"), "utf-8");
18
+ const registry = JSON.parse(content);
17
19
  expect(registry.Job).toBeDefined();
18
20
  expect(registry.Job.kind).toBe("resource");
19
21
  expect(registry.Job.resourceType).toBe("GitLab::CI::Job");
@@ -26,7 +28,9 @@ describe("generated lexicon-gitlab.json", () => {
26
28
  expect(registry.Workflow.kind).toBe("resource");
27
29
  });
28
30
 
29
- test("contains all property entities", () => {
31
+ test.skipIf(!hasGenerated)("contains all property entities", () => {
32
+ const content = readFileSync(join(generatedDir, "lexicon-gitlab.json"), "utf-8");
33
+ const registry = JSON.parse(content);
30
34
  const propertyNames = [
31
35
  "AllowFailure", "Artifacts", "AutoCancel", "Cache",
32
36
  "Environment", "Image", "Include", "Inherit", "Parallel",
@@ -40,7 +44,9 @@ describe("generated lexicon-gitlab.json", () => {
40
44
  }
41
45
  });
42
46
 
43
- test("entries match snapshot", () => {
47
+ test.skipIf(!hasGenerated)("entries match snapshot", () => {
48
+ const content = readFileSync(join(generatedDir, "lexicon-gitlab.json"), "utf-8");
49
+ const registry = JSON.parse(content);
44
50
  expect(registry.Job).toMatchSnapshot();
45
51
  expect(registry.Artifacts).toMatchSnapshot();
46
52
  expect(registry.Cache).toMatchSnapshot();
@@ -49,9 +55,8 @@ describe("generated lexicon-gitlab.json", () => {
49
55
  });
50
56
 
51
57
  describe("generated index.d.ts", () => {
52
- const content = readFileSync(join(generatedDir, "index.d.ts"), "utf-8");
53
-
54
- test("contains all class declarations", () => {
58
+ test.skipIf(!hasGenerated)("contains all class declarations", () => {
59
+ const content = readFileSync(join(generatedDir, "index.d.ts"), "utf-8");
55
60
  const expectedClasses = [
56
61
  "Job", "Default", "Workflow",
57
62
  "AllowFailure", "Artifacts", "AutoCancel", "Cache",
@@ -64,14 +69,16 @@ describe("generated index.d.ts", () => {
64
69
  }
65
70
  });
66
71
 
67
- test("contains CI variables declaration", () => {
72
+ test.skipIf(!hasGenerated)("contains CI variables declaration", () => {
73
+ const content = readFileSync(join(generatedDir, "index.d.ts"), "utf-8");
68
74
  expect(content).toContain("export declare const CI");
69
75
  expect(content).toContain("readonly CommitBranch: string");
70
76
  expect(content).toContain("readonly CommitSha: string");
71
77
  expect(content).toContain("readonly PipelineId: string");
72
78
  });
73
79
 
74
- test("Job class has key properties in constructor", () => {
80
+ test.skipIf(!hasGenerated)("Job class has key properties in constructor", () => {
81
+ const content = readFileSync(join(generatedDir, "index.d.ts"), "utf-8");
75
82
  // Extract the Job class declaration
76
83
  const jobMatch = content.match(/export declare class Job \{[\s\S]*?\n\}/);
77
84
  expect(jobMatch).toBeDefined();
@@ -85,27 +92,29 @@ describe("generated index.d.ts", () => {
85
92
  });
86
93
 
87
94
  describe("generated index.ts", () => {
88
- const content = readFileSync(join(generatedDir, "index.ts"), "utf-8");
89
-
90
- test("has correct resource createResource calls", () => {
95
+ test.skipIf(!hasGenerated)("has correct resource createResource calls", () => {
96
+ const content = readFileSync(join(generatedDir, "index.ts"), "utf-8");
91
97
  expect(content).toContain('createResource("GitLab::CI::Default"');
92
98
  expect(content).toContain('createResource("GitLab::CI::Job"');
93
99
  expect(content).toContain('createResource("GitLab::CI::Workflow"');
94
100
  });
95
101
 
96
- test("has correct property createProperty calls", () => {
102
+ test.skipIf(!hasGenerated)("has correct property createProperty calls", () => {
103
+ const content = readFileSync(join(generatedDir, "index.ts"), "utf-8");
97
104
  expect(content).toContain('createProperty("GitLab::CI::Artifacts"');
98
105
  expect(content).toContain('createProperty("GitLab::CI::Cache"');
99
106
  expect(content).toContain('createProperty("GitLab::CI::Image"');
100
107
  expect(content).toContain('createProperty("GitLab::CI::Rule"');
101
108
  });
102
109
 
103
- test("re-exports reference and CI", () => {
110
+ test.skipIf(!hasGenerated)("re-exports reference and CI", () => {
111
+ const content = readFileSync(join(generatedDir, "index.ts"), "utf-8");
104
112
  expect(content).toContain('export { reference } from "../intrinsics"');
105
113
  expect(content).toContain('export { CI } from "../variables"');
106
114
  });
107
115
 
108
- test("imports from runtime", () => {
116
+ test.skipIf(!hasGenerated)("imports from runtime", () => {
117
+ const content = readFileSync(join(generatedDir, "index.ts"), "utf-8");
109
118
  expect(content).toContain('from "./runtime"');
110
119
  });
111
120
  });
@@ -98,6 +98,46 @@ describe("DockerBuild", () => {
98
98
  expect(expanded.has("dockerBuild")).toBe(true);
99
99
  expect(expanded.size).toBe(1);
100
100
  });
101
+
102
+ test("default dockerfile and context", () => {
103
+ const instance = DockerBuild({});
104
+ const props = (instance.build as any).props;
105
+ expect(props.script[0]).toContain("-f Dockerfile");
106
+ expect(props.script[0]).toMatch(/\.\s*$/);
107
+ });
108
+
109
+ test("custom dockerfile path", () => {
110
+ const instance = DockerBuild({ dockerfile: "docker/app.Dockerfile" });
111
+ const props = (instance.build as any).props;
112
+ expect(props.script[0]).toContain("-f docker/app.Dockerfile");
113
+ });
114
+
115
+ test("custom context directory", () => {
116
+ const instance = DockerBuild({ context: "./app" });
117
+ const props = (instance.build as any).props;
118
+ expect(props.script[0]).toContain("./app");
119
+ });
120
+
121
+ test("empty buildArgs produces no --build-arg flags", () => {
122
+ const instance = DockerBuild({ buildArgs: {} });
123
+ const props = (instance.build as any).props;
124
+ expect(props.script[0]).not.toContain("--build-arg");
125
+ });
126
+
127
+ test("multiple buildArgs are all present", () => {
128
+ const instance = DockerBuild({
129
+ buildArgs: { NODE_ENV: "production", VERSION: "1.0" },
130
+ });
131
+ const props = (instance.build as any).props;
132
+ expect(props.script[0]).toContain("--build-arg NODE_ENV=production");
133
+ expect(props.script[0]).toContain("--build-arg VERSION=1.0");
134
+ });
135
+
136
+ test("no rules by default", () => {
137
+ const instance = DockerBuild({});
138
+ const props = (instance.build as any).props;
139
+ expect(props.rules).toBeUndefined();
140
+ });
101
141
  });
102
142
 
103
143
  // ---------------------------------------------------------------------------
@@ -201,6 +241,52 @@ describe("NodePipeline", () => {
201
241
  expect(expanded.has("appBuild")).toBe(true);
202
242
  expect(expanded.has("appTest")).toBe(true);
203
243
  });
244
+
245
+ test("custom artifact paths", () => {
246
+ const instance = NodePipeline({ buildArtifactPaths: ["build/", "out/"] });
247
+ const props = (instance.build as any).props;
248
+ const artProps = (props.artifacts as any).props;
249
+ expect(artProps.paths).toEqual(["build/", "out/"]);
250
+ });
251
+
252
+ test("custom artifact expiry", () => {
253
+ const instance = NodePipeline({ artifactExpiry: "30 minutes" });
254
+ const props = (instance.build as any).props;
255
+ const artProps = (props.artifacts as any).props;
256
+ expect(artProps.expire_in).toBe("30 minutes");
257
+ });
258
+
259
+ test("cache has pull-push policy", () => {
260
+ const instance = NodePipeline({});
261
+ const defaultProps = (instance.defaults as any).props;
262
+ const cacheProps = (defaultProps.cache[0] as any).props;
263
+ expect(cacheProps.policy).toBe("pull-push");
264
+ });
265
+
266
+ test("pnpm install command", () => {
267
+ const instance = NodePipeline({ packageManager: "pnpm" });
268
+ const buildProps = (instance.build as any).props;
269
+ expect(buildProps.script[0]).toBe("pnpm install --frozen-lockfile");
270
+ });
271
+
272
+ test("bun install command", () => {
273
+ const instance = NodePipeline({ packageManager: "bun" });
274
+ const buildProps = (instance.build as any).props;
275
+ expect(buildProps.script[0]).toBe("bun install --frozen-lockfile");
276
+ });
277
+
278
+ test("test job stage is test", () => {
279
+ const instance = NodePipeline({});
280
+ const props = (instance.test as any).props;
281
+ expect(props.stage).toBe("test");
282
+ });
283
+
284
+ test("test artifacts always reported", () => {
285
+ const instance = NodePipeline({});
286
+ const props = (instance.test as any).props;
287
+ const artProps = (props.artifacts as any).props;
288
+ expect(artProps.when).toBe("always");
289
+ });
204
290
  });
205
291
 
206
292
  // ---------------------------------------------------------------------------
@@ -329,6 +415,42 @@ describe("PythonPipeline", () => {
329
415
  expect(expanded.has("pyTest")).toBe(true);
330
416
  expect(expanded.has("pyLint")).toBe(false);
331
417
  });
418
+
419
+ test("custom requirements file", () => {
420
+ const instance = PythonPipeline({ requirementsFile: "requirements-dev.txt" });
421
+ const defaultProps = (instance.defaults as any).props;
422
+ const cacheProps = (defaultProps.cache[0] as any).props;
423
+ expect(cacheProps.key).toEqual({ files: ["requirements-dev.txt"] });
424
+ expect(defaultProps.before_script).toContain("pip install -r requirements-dev.txt");
425
+ });
426
+
427
+ test("poetry mode sets virtualenvs.in-project config", () => {
428
+ const instance = PythonPipeline({ usePoetry: true });
429
+ const defaultProps = (instance.defaults as any).props;
430
+ expect(defaultProps.before_script).toContain("poetry config virtualenvs.in-project true");
431
+ });
432
+
433
+ test("custom lint command", () => {
434
+ const instance = PythonPipeline({ lintCommand: "flake8 src/" });
435
+ const members = instance.members as any;
436
+ const props = (members.lint as any).props;
437
+ expect(props.script).toContain("flake8 src/");
438
+ });
439
+
440
+ test("test and lint jobs share the test stage", () => {
441
+ const instance = PythonPipeline({});
442
+ const testProps = (instance.test as any).props;
443
+ const lintProps = ((instance.members as any).lint as any).props;
444
+ expect(testProps.stage).toBe("test");
445
+ expect(lintProps.stage).toBe("test");
446
+ });
447
+
448
+ test("cache has pull-push policy", () => {
449
+ const instance = PythonPipeline({});
450
+ const defaultProps = (instance.defaults as any).props;
451
+ const cacheProps = (defaultProps.cache[0] as any).props;
452
+ expect(cacheProps.policy).toBe("pull-push");
453
+ });
332
454
  });
333
455
 
334
456
  // ---------------------------------------------------------------------------
@@ -449,4 +571,42 @@ describe("ReviewApp", () => {
449
571
  // which the serializer converts to "staging-stop" in YAML
450
572
  expect(envProps.on_stop).toBe("staging-stop");
451
573
  });
574
+
575
+ test("default auto_stop_in is 1 week", () => {
576
+ const instance = ReviewApp(baseProps);
577
+ const props = (instance.deploy as any).props;
578
+ const envProps = (props.environment as any).props;
579
+ expect(envProps.auto_stop_in).toBe("1 week");
580
+ });
581
+
582
+ test("custom auto_stop_in", () => {
583
+ const instance = ReviewApp({ ...baseProps, autoStopIn: "2 days" });
584
+ const props = (instance.deploy as any).props;
585
+ const envProps = (props.environment as any).props;
586
+ expect(envProps.auto_stop_in).toBe("2 days");
587
+ });
588
+
589
+ test("default stage is deploy for both jobs", () => {
590
+ const instance = ReviewApp(baseProps);
591
+ const deployProps = (instance.deploy as any).props;
592
+ const stopProps = (instance.stop as any).props;
593
+ expect(deployProps.stage).toBe("deploy");
594
+ expect(stopProps.stage).toBe("deploy");
595
+ });
596
+
597
+ test("stop script as array", () => {
598
+ const instance = ReviewApp({
599
+ ...baseProps,
600
+ stopScript: ["helm uninstall app", "kubectl delete ns review"],
601
+ });
602
+ const props = (instance.stop as any).props;
603
+ expect(props.script).toEqual(["helm uninstall app", "kubectl delete ns review"]);
604
+ });
605
+
606
+ test("deploy environment url defaults to CI_ENVIRONMENT_SLUG pattern", () => {
607
+ const instance = ReviewApp(baseProps);
608
+ const props = (instance.deploy as any).props;
609
+ const envProps = (props.environment as any).props;
610
+ expect(envProps.url).toContain("$CI_ENVIRONMENT_SLUG");
611
+ });
452
612
  });
@@ -1,20 +1,24 @@
1
1
  import { describe, test, expect } from "bun:test";
2
- import { computeCoverage, overallPct, formatSummary } from "./coverage";
3
- import { readFileSync } from "fs";
2
+ import { existsSync, readFileSync } from "fs";
4
3
  import { join, dirname } from "path";
5
4
  import { fileURLToPath } from "url";
6
5
 
7
6
  const basePath = dirname(dirname(fileURLToPath(import.meta.url)));
8
- const lexiconJSON = readFileSync(join(basePath, "src", "generated", "lexicon-gitlab.json"), "utf-8");
7
+ const lexiconPath = join(basePath, "src", "generated", "lexicon-gitlab.json");
8
+ const hasGenerated = existsSync(lexiconPath);
9
9
 
10
10
  describe("coverage analysis", () => {
11
- test("computes coverage for GitLab lexicon", () => {
11
+ test.skipIf(!hasGenerated)("computes coverage for GitLab lexicon", async () => {
12
+ const { computeCoverage } = await import("./coverage");
13
+ const lexiconJSON = readFileSync(lexiconPath, "utf-8");
12
14
  const report = computeCoverage(lexiconJSON);
13
15
  expect(report.resourceCount).toBe(3); // Job, Default, Workflow
14
16
  expect(report.resources).toHaveLength(3);
15
17
  });
16
18
 
17
- test("reports resource names correctly", () => {
19
+ test.skipIf(!hasGenerated)("reports resource names correctly", async () => {
20
+ const { computeCoverage } = await import("./coverage");
21
+ const lexiconJSON = readFileSync(lexiconPath, "utf-8");
18
22
  const report = computeCoverage(lexiconJSON);
19
23
  const names = report.resources.map((r) => r.name);
20
24
  expect(names).toContain("Job");
@@ -22,7 +26,9 @@ describe("coverage analysis", () => {
22
26
  expect(names).toContain("Workflow");
23
27
  });
24
28
 
25
- test("overallPct returns a number", () => {
29
+ test.skipIf(!hasGenerated)("overallPct returns a number", async () => {
30
+ const { computeCoverage, overallPct } = await import("./coverage");
31
+ const lexiconJSON = readFileSync(lexiconPath, "utf-8");
26
32
  const report = computeCoverage(lexiconJSON);
27
33
  const pct = overallPct(report);
28
34
  expect(typeof pct).toBe("number");
@@ -30,7 +36,9 @@ describe("coverage analysis", () => {
30
36
  expect(pct).toBeLessThanOrEqual(100);
31
37
  });
32
38
 
33
- test("formatSummary returns readable string", () => {
39
+ test.skipIf(!hasGenerated)("formatSummary returns readable string", async () => {
40
+ const { computeCoverage, formatSummary } = await import("./coverage");
41
+ const lexiconJSON = readFileSync(lexiconPath, "utf-8");
34
42
  const report = computeCoverage(lexiconJSON);
35
43
  const summary = formatSummary(report);
36
44
  expect(summary).toContain("Coverage Report");
@@ -1,11 +1,22 @@
1
1
  import { describe, test, expect } from "bun:test";
2
+ import { readFileSync, readdirSync } from "fs";
3
+ import { join, dirname } from "path";
4
+ import { fileURLToPath } from "url";
2
5
  import { GitLabParser } from "./parser";
3
6
  import { GitLabGenerator } from "./generator";
4
7
 
5
8
  const parser = new GitLabParser();
6
9
  const generator = new GitLabGenerator();
7
10
 
11
+ const pipelinesDir = join(
12
+ dirname(dirname(fileURLToPath(import.meta.url))),
13
+ "testdata",
14
+ "pipelines",
15
+ );
16
+
8
17
  describe("roundtrip: parse → generate", () => {
18
+ // ---- inline tests (existing smoke tests) ----
19
+
9
20
  test("simple pipeline roundtrip", () => {
10
21
  const yaml = `
11
22
  stages:
@@ -86,4 +97,125 @@ test-job:
86
97
  expect(content).toContain("new Workflow(");
87
98
  expect(content).toContain("new Job(");
88
99
  });
100
+
101
+ // ---- fixture-based tests ----
102
+
103
+ test("simple.gitlab-ci.yml fixture roundtrip", () => {
104
+ const yaml = readFileSync(join(pipelinesDir, "simple.gitlab-ci.yml"), "utf-8");
105
+ const ir = parser.parse(yaml);
106
+ const files = generator.generate(ir);
107
+
108
+ expect(files).toHaveLength(1);
109
+ const content = files[0].content;
110
+
111
+ expect(ir.resources).toHaveLength(1);
112
+ expect(content).toContain("new Job(");
113
+ expect(content).toContain("npm ci");
114
+ expect(content).toContain("npm test");
115
+ expect(content).toContain("node:22-alpine");
116
+ });
117
+
118
+ test("multi-stage.gitlab-ci.yml fixture roundtrip", () => {
119
+ const yaml = readFileSync(join(pipelinesDir, "multi-stage.gitlab-ci.yml"), "utf-8");
120
+ const ir = parser.parse(yaml);
121
+ const files = generator.generate(ir);
122
+
123
+ const content = files[0].content;
124
+
125
+ // 4 jobs: build-app, lint, unit-tests, deploy-staging
126
+ expect(ir.resources).toHaveLength(4);
127
+ expect(content).toContain("buildApp");
128
+ expect(content).toContain("lint");
129
+ expect(content).toContain("unitTests");
130
+ expect(content).toContain("deployStaging");
131
+ expect(content).toContain("Pipeline stages: build, test, deploy");
132
+ // Artifacts and cache references
133
+ expect(content).toContain("dist/");
134
+ expect(content).toContain("package-lock.json");
135
+ });
136
+
137
+ test("docker-build.gitlab-ci.yml fixture roundtrip", () => {
138
+ const yaml = readFileSync(join(pipelinesDir, "docker-build.gitlab-ci.yml"), "utf-8");
139
+ const ir = parser.parse(yaml);
140
+ const files = generator.generate(ir);
141
+
142
+ const content = files[0].content;
143
+
144
+ expect(ir.resources).toHaveLength(2);
145
+ expect(content).toContain("buildImage");
146
+ expect(content).toContain("pushLatest");
147
+ expect(content).toContain("docker:27-cli");
148
+ expect(content).toContain("docker:27-dind");
149
+ expect(content).toContain("docker build");
150
+ expect(content).toContain("docker push");
151
+ });
152
+
153
+ test("deploy-envs.gitlab-ci.yml fixture roundtrip", () => {
154
+ const yaml = readFileSync(join(pipelinesDir, "deploy-envs.gitlab-ci.yml"), "utf-8");
155
+ const ir = parser.parse(yaml);
156
+ const files = generator.generate(ir);
157
+
158
+ const content = files[0].content;
159
+
160
+ // 5 jobs: build, deploy-review, stop-review, deploy-staging, deploy-production
161
+ expect(ir.resources).toHaveLength(5);
162
+ expect(content).toContain("deployReview");
163
+ expect(content).toContain("stopReview");
164
+ expect(content).toContain("deployStaging");
165
+ expect(content).toContain("deployProduction");
166
+ // Environment constructs
167
+ expect(content).toContain("review/$CI_COMMIT_REF_SLUG");
168
+ expect(content).toContain("staging");
169
+ expect(content).toContain("production");
170
+ });
171
+
172
+ test("includes-templates.gitlab-ci.yml fixture roundtrip", () => {
173
+ const yaml = readFileSync(join(pipelinesDir, "includes-templates.gitlab-ci.yml"), "utf-8");
174
+ const ir = parser.parse(yaml);
175
+ const files = generator.generate(ir);
176
+
177
+ const content = files[0].content;
178
+
179
+ // default + build + unit-test + integration-test + deploy
180
+ expect(ir.resources.length).toBeGreaterThanOrEqual(3);
181
+ expect(content).toContain("new Default(");
182
+ // Include references should be captured as comments
183
+ expect(content).toContain("Auto-DevOps.gitlab-ci.yml");
184
+ expect(content).toContain("interruptible");
185
+ });
186
+
187
+ test("monorepo.gitlab-ci.yml fixture roundtrip", () => {
188
+ const yaml = readFileSync(join(pipelinesDir, "monorepo.gitlab-ci.yml"), "utf-8");
189
+ const ir = parser.parse(yaml);
190
+ const files = generator.generate(ir);
191
+
192
+ const content = files[0].content;
193
+
194
+ // frontend, backend, e2e-matrix, deploy-all
195
+ expect(ir.resources).toHaveLength(4);
196
+ expect(content).toContain("frontend");
197
+ expect(content).toContain("backend");
198
+ expect(content).toContain("e2eMatrix");
199
+ expect(content).toContain("deployAll");
200
+ // Monorepo-specific constructs
201
+ expect(content).toContain("cypress");
202
+ });
203
+
204
+ // ---- fixture directory sweep ----
205
+
206
+ test("all pipeline fixtures parse and generate without error", () => {
207
+ const fixtures = readdirSync(pipelinesDir).filter((f) => f.endsWith(".yml"));
208
+ expect(fixtures.length).toBeGreaterThanOrEqual(6);
209
+
210
+ for (const fixture of fixtures) {
211
+ const yaml = readFileSync(join(pipelinesDir, fixture), "utf-8");
212
+ const ir = parser.parse(yaml);
213
+ expect(ir.resources.length).toBeGreaterThan(0);
214
+
215
+ const files = generator.generate(ir);
216
+ expect(files.length).toBeGreaterThan(0);
217
+ expect(files[0].content).toContain("import");
218
+ expect(files[0].content).toContain("export const");
219
+ }
220
+ });
89
221
  });
@@ -0,0 +1,72 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { wgl016, checkSecretsInVariables } from "./wgl016";
3
+
4
+ describe("WGL016: Secrets in Variables", () => {
5
+ test("check metadata", () => {
6
+ expect(wgl016.id).toBe("WGL016");
7
+ expect(wgl016.description).toContain("Secrets");
8
+ });
9
+
10
+ test("flags hardcoded password variable", () => {
11
+ const yaml = `variables:
12
+ DB_PASSWORD: my-secret-pass123
13
+ DB_HOST: postgres
14
+ `;
15
+ const diags = checkSecretsInVariables(yaml);
16
+ expect(diags).toHaveLength(1);
17
+ expect(diags[0].checkId).toBe("WGL016");
18
+ expect(diags[0].severity).toBe("error");
19
+ expect(diags[0].message).toContain("DB_PASSWORD");
20
+ });
21
+
22
+ test("flags hardcoded token variable", () => {
23
+ const yaml = `variables:
24
+ API_TOKEN: abc123xyz
25
+ `;
26
+ const diags = checkSecretsInVariables(yaml);
27
+ expect(diags).toHaveLength(1);
28
+ expect(diags[0].message).toContain("API_TOKEN");
29
+ });
30
+
31
+ test("flags hardcoded secret variable", () => {
32
+ const yaml = `variables:
33
+ APP_SECRET: supersecretvalue
34
+ `;
35
+ const diags = checkSecretsInVariables(yaml);
36
+ expect(diags).toHaveLength(1);
37
+ expect(diags[0].message).toContain("APP_SECRET");
38
+ });
39
+
40
+ test("does not flag variable references", () => {
41
+ const yaml = `variables:
42
+ DB_PASSWORD: $CI_DB_PASSWORD
43
+ API_TOKEN: \${SECRET_TOKEN}
44
+ `;
45
+ const diags = checkSecretsInVariables(yaml);
46
+ expect(diags).toHaveLength(0);
47
+ });
48
+
49
+ test("does not flag non-secret variables", () => {
50
+ const yaml = `variables:
51
+ NODE_ENV: production
52
+ DB_HOST: postgres
53
+ STAGE: deploy
54
+ `;
55
+ const diags = checkSecretsInVariables(yaml);
56
+ expect(diags).toHaveLength(0);
57
+ });
58
+
59
+ test("flags multiple secret variables", () => {
60
+ const yaml = `variables:
61
+ DB_PASSWORD: pass123
62
+ API_SECRET: secret456
63
+ `;
64
+ const diags = checkSecretsInVariables(yaml);
65
+ expect(diags).toHaveLength(2);
66
+ });
67
+
68
+ test("no diagnostics on empty yaml", () => {
69
+ const diags = checkSecretsInVariables("");
70
+ expect(diags).toHaveLength(0);
71
+ });
72
+ });