@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,82 @@
1
+ /**
2
+ * WGL016: Secrets in Variables
3
+ *
4
+ * Detects hardcoded passwords, tokens, or secrets in `variables:` blocks.
5
+ * These should use CI/CD masked variables instead.
6
+ */
7
+
8
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
9
+ import { getPrimaryOutput } from "./yaml-helpers";
10
+
11
+ const SECRET_PATTERNS = [
12
+ /password\s*[:=]\s*['"]?[^\s'"]+/i,
13
+ /secret\s*[:=]\s*['"]?[^\s'"]+/i,
14
+ /token\s*[:=]\s*['"]?[^\s'"]+/i,
15
+ /api[_-]?key\s*[:=]\s*['"]?[^\s'"]+/i,
16
+ /private[_-]?key\s*[:=]\s*['"]?[^\s'"]+/i,
17
+ ];
18
+
19
+ /** Variable name patterns that indicate credentials. */
20
+ const SECRET_VAR_NAMES = [
21
+ /password/i,
22
+ /secret/i,
23
+ /token/i,
24
+ /api[_-]?key/i,
25
+ /private[_-]?key/i,
26
+ /credentials?/i,
27
+ ];
28
+
29
+ /** Values that are clearly references (not hardcoded secrets). */
30
+ function isReference(value: string): boolean {
31
+ return value.startsWith("$") || value.startsWith("${");
32
+ }
33
+
34
+ export function checkSecretsInVariables(yaml: string): PostSynthDiagnostic[] {
35
+ const diagnostics: PostSynthDiagnostic[] = [];
36
+
37
+ // Extract variables blocks (global and per-job)
38
+ const varBlocks = yaml.matchAll(/^(\s*)variables:\n((?:\1\s+.+\n?)+)/gm);
39
+
40
+ for (const block of varBlocks) {
41
+ const lines = block[2].split("\n");
42
+ for (const line of lines) {
43
+ const kv = line.match(/^\s+(\w+):\s+(.+)$/);
44
+ if (!kv) continue;
45
+
46
+ const [, varName, rawValue] = kv;
47
+ const value = rawValue.trim().replace(/^['"]|['"]$/g, "");
48
+
49
+ if (isReference(value)) continue;
50
+
51
+ // Check if variable name suggests a secret
52
+ for (const pattern of SECRET_VAR_NAMES) {
53
+ if (pattern.test(varName)) {
54
+ diagnostics.push({
55
+ checkId: "WGL016",
56
+ severity: "error",
57
+ message: `Variable "${varName}" appears to contain a hardcoded secret — use a CI/CD masked variable instead`,
58
+ entity: varName,
59
+ lexicon: "gitlab",
60
+ });
61
+ break;
62
+ }
63
+ }
64
+ }
65
+ }
66
+
67
+ return diagnostics;
68
+ }
69
+
70
+ export const wgl016: PostSynthCheck = {
71
+ id: "WGL016",
72
+ description: "Secrets in variables — hardcoded passwords or tokens in variables blocks",
73
+
74
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
75
+ const diagnostics: PostSynthDiagnostic[] = [];
76
+ for (const [, output] of ctx.outputs) {
77
+ const yaml = getPrimaryOutput(output);
78
+ diagnostics.push(...checkSecretsInVariables(yaml));
79
+ }
80
+ return diagnostics;
81
+ },
82
+ };
@@ -0,0 +1,53 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { wgl017, checkInsecureRegistry } from "./wgl017";
3
+
4
+ describe("WGL017: Insecure Registry", () => {
5
+ test("check metadata", () => {
6
+ expect(wgl017.id).toBe("WGL017");
7
+ expect(wgl017.description).toContain("Insecure");
8
+ });
9
+
10
+ test("flags docker push to HTTP registry", () => {
11
+ const yaml = `build-image:
12
+ script:
13
+ - docker push http://registry.local/myimage:latest
14
+ `;
15
+ const diags = checkInsecureRegistry(yaml);
16
+ expect(diags).toHaveLength(1);
17
+ expect(diags[0].severity).toBe("warning");
18
+ expect(diags[0].message).toContain("build-image");
19
+ expect(diags[0].message).toContain("insecure");
20
+ });
21
+
22
+ test("flags docker pull from HTTP registry", () => {
23
+ const yaml = `test-job:
24
+ script:
25
+ - docker pull http://insecure-registry.com/myimage
26
+ `;
27
+ const diags = checkInsecureRegistry(yaml);
28
+ expect(diags).toHaveLength(1);
29
+ });
30
+
31
+ test("does not flag HTTPS registry", () => {
32
+ const yaml = `build-image:
33
+ script:
34
+ - docker push https://registry.gitlab.com/myimage:latest
35
+ `;
36
+ const diags = checkInsecureRegistry(yaml);
37
+ expect(diags).toHaveLength(0);
38
+ });
39
+
40
+ test("does not flag registry without protocol", () => {
41
+ const yaml = `build-image:
42
+ script:
43
+ - docker push $CI_REGISTRY_IMAGE:latest
44
+ `;
45
+ const diags = checkInsecureRegistry(yaml);
46
+ expect(diags).toHaveLength(0);
47
+ });
48
+
49
+ test("no diagnostics on empty yaml", () => {
50
+ const diags = checkInsecureRegistry("");
51
+ expect(diags).toHaveLength(0);
52
+ });
53
+ });
@@ -0,0 +1,54 @@
1
+ /**
2
+ * WGL017: Insecure Registry
3
+ *
4
+ * Detects Docker push/pull to non-HTTPS registries in job scripts.
5
+ * Using HTTP for container registries is a security risk.
6
+ */
7
+
8
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
9
+ import { getPrimaryOutput, extractJobs } from "./yaml-helpers";
10
+
11
+ const INSECURE_REGISTRY_PATTERN = /docker\s+(push|pull|tag|login)\s+.*http:\/\/[^\s]+/;
12
+
13
+ export function checkInsecureRegistry(yaml: string): PostSynthDiagnostic[] {
14
+ const diagnostics: PostSynthDiagnostic[] = [];
15
+
16
+ const sections = yaml.split("\n\n");
17
+ for (const section of sections) {
18
+ const lines = section.split("\n");
19
+ if (lines.length === 0) continue;
20
+
21
+ const topMatch = lines[0].match(/^(\.?[a-z][a-z0-9_.-]*):/);
22
+ if (!topMatch) continue;
23
+ const jobName = topMatch[1];
24
+
25
+ for (const line of lines) {
26
+ if (INSECURE_REGISTRY_PATTERN.test(line)) {
27
+ diagnostics.push({
28
+ checkId: "WGL017",
29
+ severity: "warning",
30
+ message: `Job "${jobName}" uses an insecure (HTTP) container registry — use HTTPS instead`,
31
+ entity: jobName,
32
+ lexicon: "gitlab",
33
+ });
34
+ break;
35
+ }
36
+ }
37
+ }
38
+
39
+ return diagnostics;
40
+ }
41
+
42
+ export const wgl017: PostSynthCheck = {
43
+ id: "WGL017",
44
+ description: "Insecure registry — Docker push/pull to non-HTTPS registry",
45
+
46
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
47
+ const diagnostics: PostSynthDiagnostic[] = [];
48
+ for (const [, output] of ctx.outputs) {
49
+ const yaml = getPrimaryOutput(output);
50
+ diagnostics.push(...checkInsecureRegistry(yaml));
51
+ }
52
+ return diagnostics;
53
+ },
54
+ };
@@ -0,0 +1,69 @@
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 { wgl018 } from "./wgl018";
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("WGL018: Missing Timeout", () => {
33
+ test("check metadata", () => {
34
+ expect(wgl018.id).toBe("WGL018");
35
+ expect(wgl018.description).toContain("timeout");
36
+ });
37
+
38
+ test("flags job without timeout", () => {
39
+ const entities = new Map<string, Declarable>([
40
+ ["buildJob", new MockJob({ script: ["npm build"] })],
41
+ ]);
42
+ const diags = wgl018.check(makeCtx(entities));
43
+ expect(diags).toHaveLength(1);
44
+ expect(diags[0].severity).toBe("warning");
45
+ expect(diags[0].message).toContain("buildJob");
46
+ });
47
+
48
+ test("does not flag job with timeout", () => {
49
+ const entities = new Map<string, Declarable>([
50
+ ["buildJob", new MockJob({ script: ["npm build"], timeout: "10 minutes" })],
51
+ ]);
52
+ const diags = wgl018.check(makeCtx(entities));
53
+ expect(diags).toHaveLength(0);
54
+ });
55
+
56
+ test("flags multiple jobs without timeout", () => {
57
+ const entities = new Map<string, Declarable>([
58
+ ["job1", new MockJob({ script: ["test"] })],
59
+ ["job2", new MockJob({ script: ["build"] })],
60
+ ]);
61
+ const diags = wgl018.check(makeCtx(entities));
62
+ expect(diags).toHaveLength(2);
63
+ });
64
+
65
+ test("no diagnostics on empty entities", () => {
66
+ const diags = wgl018.check(makeCtx(new Map()));
67
+ expect(diags).toHaveLength(0);
68
+ });
69
+ });
@@ -0,0 +1,39 @@
1
+ /**
2
+ * WGL018: Missing Timeout
3
+ *
4
+ * Warns about jobs without an explicit `timeout:` setting.
5
+ * The default (1 hour) may be too long for most jobs.
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 wgl018: PostSynthCheck = {
12
+ id: "WGL018",
13
+ description: "Missing timeout — jobs without explicit timeout may run too long",
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
+ if (!props.timeout) {
27
+ diagnostics.push({
28
+ checkId: "WGL018",
29
+ severity: "warning",
30
+ message: `Job "${entityName}" has no explicit timeout — default is 1 hour which may be too long`,
31
+ entity: entityName,
32
+ lexicon: "gitlab",
33
+ });
34
+ }
35
+ }
36
+
37
+ return diagnostics;
38
+ },
39
+ };
@@ -0,0 +1,76 @@
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 { wgl019 } from "./wgl019";
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("WGL019: Missing Retry on Deploy Jobs", () => {
33
+ test("check metadata", () => {
34
+ expect(wgl019.id).toBe("WGL019");
35
+ expect(wgl019.description).toContain("retry");
36
+ });
37
+
38
+ test("flags deploy job without retry", () => {
39
+ const entities = new Map<string, Declarable>([
40
+ ["deployApp", new MockJob({ script: ["deploy.sh"], stage: "deploy" })],
41
+ ]);
42
+ const diags = wgl019.check(makeCtx(entities));
43
+ expect(diags).toHaveLength(1);
44
+ expect(diags[0].severity).toBe("info");
45
+ expect(diags[0].message).toContain("deployApp");
46
+ });
47
+
48
+ test("does not flag deploy job with retry", () => {
49
+ const entities = new Map<string, Declarable>([
50
+ ["deployApp", new MockJob({ script: ["deploy.sh"], stage: "deploy", retry: { max: 2 } })],
51
+ ]);
52
+ const diags = wgl019.check(makeCtx(entities));
53
+ expect(diags).toHaveLength(0);
54
+ });
55
+
56
+ test("does not flag non-deploy job without retry", () => {
57
+ const entities = new Map<string, Declarable>([
58
+ ["testJob", new MockJob({ script: ["npm test"], stage: "test" })],
59
+ ]);
60
+ const diags = wgl019.check(makeCtx(entities));
61
+ expect(diags).toHaveLength(0);
62
+ });
63
+
64
+ test("recognizes staging as a deploy stage", () => {
65
+ const entities = new Map<string, Declarable>([
66
+ ["stagingDeploy", new MockJob({ script: ["deploy.sh"], stage: "staging" })],
67
+ ]);
68
+ const diags = wgl019.check(makeCtx(entities));
69
+ expect(diags).toHaveLength(1);
70
+ });
71
+
72
+ test("no diagnostics on empty entities", () => {
73
+ const diags = wgl019.check(makeCtx(new Map()));
74
+ expect(diags).toHaveLength(0);
75
+ });
76
+ });
@@ -0,0 +1,44 @@
1
+ /**
2
+ * WGL019: Missing Retry on Deploy Jobs
3
+ *
4
+ * Deploy-stage jobs should have a `retry:` strategy to handle transient
5
+ * infrastructure failures. This is informational, not a hard requirement.
6
+ */
7
+
8
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
9
+ import { isPropertyDeclarable } from "@intentius/chant/declarable";
10
+
11
+ const DEPLOY_STAGES = new Set(["deploy", "deployment", "release", "production", "staging"]);
12
+
13
+ export const wgl019: PostSynthCheck = {
14
+ id: "WGL019",
15
+ description: "Missing retry — deploy jobs without retry strategy",
16
+
17
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
18
+ const diagnostics: PostSynthDiagnostic[] = [];
19
+
20
+ for (const [entityName, entity] of ctx.entities) {
21
+ if (isPropertyDeclarable(entity)) continue;
22
+ const entityType = (entity as Record<string, unknown>).entityType as string;
23
+ if (entityType !== "GitLab::CI::Job") continue;
24
+
25
+ const props = (entity as Record<string, unknown>).props as Record<string, unknown> | undefined;
26
+ if (!props) continue;
27
+
28
+ const stage = props.stage as string | undefined;
29
+ if (!stage || !DEPLOY_STAGES.has(stage.toLowerCase())) continue;
30
+
31
+ if (!props.retry) {
32
+ diagnostics.push({
33
+ checkId: "WGL019",
34
+ severity: "info",
35
+ message: `Deploy job "${entityName}" (stage: ${stage}) has no retry strategy — consider adding retry for transient failures`,
36
+ entity: entityName,
37
+ lexicon: "gitlab",
38
+ });
39
+ }
40
+ }
41
+
42
+ return diagnostics;
43
+ },
44
+ };
@@ -0,0 +1,54 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { wgl020, checkDuplicateJobNames } from "./wgl020";
3
+
4
+ describe("WGL020: Duplicate Job Names", () => {
5
+ test("check metadata", () => {
6
+ expect(wgl020.id).toBe("WGL020");
7
+ expect(wgl020.description).toContain("Duplicate");
8
+ });
9
+
10
+ test("flags duplicate job names", () => {
11
+ const yaml = `build-app:
12
+ script:
13
+ - npm build
14
+
15
+ build-app:
16
+ script:
17
+ - npm run build
18
+ `;
19
+ const diags = checkDuplicateJobNames(yaml);
20
+ expect(diags).toHaveLength(1);
21
+ expect(diags[0].severity).toBe("error");
22
+ expect(diags[0].message).toContain("build-app");
23
+ expect(diags[0].message).toContain("2 times");
24
+ });
25
+
26
+ test("does not flag unique job names", () => {
27
+ const yaml = `build-app:
28
+ script:
29
+ - npm build
30
+
31
+ test-app:
32
+ script:
33
+ - npm test
34
+ `;
35
+ const diags = checkDuplicateJobNames(yaml);
36
+ expect(diags).toHaveLength(0);
37
+ });
38
+
39
+ test("ignores reserved keys", () => {
40
+ const yaml = `stages:
41
+ - build
42
+
43
+ variables:
44
+ FOO: bar
45
+ `;
46
+ const diags = checkDuplicateJobNames(yaml);
47
+ expect(diags).toHaveLength(0);
48
+ });
49
+
50
+ test("no diagnostics on empty yaml", () => {
51
+ const diags = checkDuplicateJobNames("");
52
+ expect(diags).toHaveLength(0);
53
+ });
54
+ });
@@ -0,0 +1,56 @@
1
+ /**
2
+ * WGL020: Duplicate Job Names
3
+ *
4
+ * Detects multiple jobs that resolve to the same kebab-case name in
5
+ * the serialized YAML. GitLab silently merges duplicate keys, which
6
+ * causes unexpected behavior.
7
+ */
8
+
9
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
10
+ import { getPrimaryOutput, extractJobs } from "./yaml-helpers";
11
+
12
+ export function checkDuplicateJobNames(yaml: string): PostSynthDiagnostic[] {
13
+ const diagnostics: PostSynthDiagnostic[] = [];
14
+
15
+ // Count occurrences of each top-level key (raw line parsing, not extractJobs,
16
+ // to detect actual YAML key duplication)
17
+ const keyCounts = new Map<string, number>();
18
+ const lines = yaml.split("\n");
19
+
20
+ for (const line of lines) {
21
+ const topMatch = line.match(/^(\.?[a-z][a-z0-9_.-]*):/);
22
+ if (topMatch) {
23
+ const name = topMatch[1];
24
+ if (["stages", "default", "workflow", "variables", "include"].includes(name)) continue;
25
+ keyCounts.set(name, (keyCounts.get(name) ?? 0) + 1);
26
+ }
27
+ }
28
+
29
+ for (const [name, count] of keyCounts) {
30
+ if (count > 1) {
31
+ diagnostics.push({
32
+ checkId: "WGL020",
33
+ severity: "error",
34
+ message: `Duplicate job name "${name}" appears ${count} times — GitLab will silently merge these`,
35
+ entity: name,
36
+ lexicon: "gitlab",
37
+ });
38
+ }
39
+ }
40
+
41
+ return diagnostics;
42
+ }
43
+
44
+ export const wgl020: PostSynthCheck = {
45
+ id: "WGL020",
46
+ description: "Duplicate job names — multiple jobs resolving to same name",
47
+
48
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
49
+ const diagnostics: PostSynthDiagnostic[] = [];
50
+ for (const [, output] of ctx.outputs) {
51
+ const yaml = getPrimaryOutput(output);
52
+ diagnostics.push(...checkDuplicateJobNames(yaml));
53
+ }
54
+ return diagnostics;
55
+ },
56
+ };
@@ -0,0 +1,62 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { wgl021, checkUnusedVariables } from "./wgl021";
3
+
4
+ describe("WGL021: Unused Variables", () => {
5
+ test("check metadata", () => {
6
+ expect(wgl021.id).toBe("WGL021");
7
+ expect(wgl021.description).toContain("Unused");
8
+ });
9
+
10
+ test("flags unused global variable", () => {
11
+ const yaml = `variables:
12
+ UNUSED_VAR: hello
13
+ USED_VAR: world
14
+
15
+ test-job:
16
+ script:
17
+ - echo $USED_VAR
18
+ `;
19
+ const diags = checkUnusedVariables(yaml);
20
+ expect(diags).toHaveLength(1);
21
+ expect(diags[0].severity).toBe("warning");
22
+ expect(diags[0].message).toContain("UNUSED_VAR");
23
+ });
24
+
25
+ test("does not flag used variable", () => {
26
+ const yaml = `variables:
27
+ NODE_ENV: production
28
+
29
+ test-job:
30
+ script:
31
+ - echo $NODE_ENV
32
+ `;
33
+ const diags = checkUnusedVariables(yaml);
34
+ expect(diags).toHaveLength(0);
35
+ });
36
+
37
+ test("detects braced variable references", () => {
38
+ const yaml = `variables:
39
+ APP_NAME: myapp
40
+
41
+ deploy-job:
42
+ script:
43
+ - echo \${APP_NAME}
44
+ `;
45
+ const diags = checkUnusedVariables(yaml);
46
+ expect(diags).toHaveLength(0);
47
+ });
48
+
49
+ test("no diagnostics when no global variables", () => {
50
+ const yaml = `test-job:
51
+ script:
52
+ - npm test
53
+ `;
54
+ const diags = checkUnusedVariables(yaml);
55
+ expect(diags).toHaveLength(0);
56
+ });
57
+
58
+ test("no diagnostics on empty yaml", () => {
59
+ const diags = checkUnusedVariables("");
60
+ expect(diags).toHaveLength(0);
61
+ });
62
+ });
@@ -0,0 +1,62 @@
1
+ /**
2
+ * WGL021: Unused Variables
3
+ *
4
+ * Detects global `variables:` that are not referenced by any job script.
5
+ * Unused variables add noise and may indicate stale configuration.
6
+ */
7
+
8
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
9
+ import { getPrimaryOutput, extractGlobalVariables } from "./yaml-helpers";
10
+
11
+ export function checkUnusedVariables(yaml: string): PostSynthDiagnostic[] {
12
+ const diagnostics: PostSynthDiagnostic[] = [];
13
+
14
+ const globalVars = extractGlobalVariables(yaml);
15
+ if (globalVars.size === 0) return diagnostics;
16
+
17
+ // Get the rest of the YAML (everything after the global variables block)
18
+ // to search for references
19
+ for (const [varName] of globalVars) {
20
+ // Check if $VARNAME or ${VARNAME} appears anywhere in the YAML (outside the variables block)
21
+ const refPattern = new RegExp(`\\$\\{?${varName}\\}?`);
22
+ // Also check for uses in extends, needs, etc. — search all sections
23
+ const sections = yaml.split("\n\n");
24
+ let found = false;
25
+
26
+ for (const section of sections) {
27
+ // Skip the global variables section itself
28
+ if (section.trimStart().startsWith("variables:")) continue;
29
+
30
+ if (refPattern.test(section)) {
31
+ found = true;
32
+ break;
33
+ }
34
+ }
35
+
36
+ if (!found) {
37
+ diagnostics.push({
38
+ checkId: "WGL021",
39
+ severity: "warning",
40
+ message: `Global variable "${varName}" is not referenced in any job script`,
41
+ entity: varName,
42
+ lexicon: "gitlab",
43
+ });
44
+ }
45
+ }
46
+
47
+ return diagnostics;
48
+ }
49
+
50
+ export const wgl021: PostSynthCheck = {
51
+ id: "WGL021",
52
+ description: "Unused variables — global variables not referenced by any job",
53
+
54
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
55
+ const diagnostics: PostSynthDiagnostic[] = [];
56
+ for (const [, output] of ctx.outputs) {
57
+ const yaml = getPrimaryOutput(output);
58
+ diagnostics.push(...checkUnusedVariables(yaml));
59
+ }
60
+ return diagnostics;
61
+ },
62
+ };