@intentius/chant-lexicon-gitlab 0.0.16 → 0.0.22

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 (65) hide show
  1. package/dist/integrity.json +17 -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 +2 -2
  18. package/package.json +20 -2
  19. package/src/codegen/fetch.test.ts +30 -0
  20. package/src/codegen/generate.test.ts +65 -0
  21. package/src/codegen/idempotency.test.ts +28 -0
  22. package/src/codegen/naming.test.ts +93 -0
  23. package/src/codegen/snapshot.test.ts +28 -19
  24. package/src/composites/composites.test.ts +160 -0
  25. package/src/coverage.test.ts +15 -7
  26. package/src/import/roundtrip.test.ts +132 -0
  27. package/src/lint/post-synth/wgl016.test.ts +72 -0
  28. package/src/lint/post-synth/wgl016.ts +82 -0
  29. package/src/lint/post-synth/wgl017.test.ts +53 -0
  30. package/src/lint/post-synth/wgl017.ts +54 -0
  31. package/src/lint/post-synth/wgl018.test.ts +69 -0
  32. package/src/lint/post-synth/wgl018.ts +39 -0
  33. package/src/lint/post-synth/wgl019.test.ts +76 -0
  34. package/src/lint/post-synth/wgl019.ts +44 -0
  35. package/src/lint/post-synth/wgl020.test.ts +54 -0
  36. package/src/lint/post-synth/wgl020.ts +56 -0
  37. package/src/lint/post-synth/wgl021.test.ts +62 -0
  38. package/src/lint/post-synth/wgl021.ts +62 -0
  39. package/src/lint/post-synth/wgl022.test.ts +86 -0
  40. package/src/lint/post-synth/wgl022.ts +44 -0
  41. package/src/lint/post-synth/wgl023.test.ts +88 -0
  42. package/src/lint/post-synth/wgl023.ts +51 -0
  43. package/src/lint/post-synth/wgl024.test.ts +77 -0
  44. package/src/lint/post-synth/wgl024.ts +46 -0
  45. package/src/lint/post-synth/wgl025.test.ts +85 -0
  46. package/src/lint/post-synth/wgl025.ts +49 -0
  47. package/src/lint/post-synth/wgl026.test.ts +87 -0
  48. package/src/lint/post-synth/wgl026.ts +67 -0
  49. package/src/lint/post-synth/wgl027.test.ts +84 -0
  50. package/src/lint/post-synth/wgl027.ts +54 -0
  51. package/src/lint/post-synth/wgl028.test.ts +95 -0
  52. package/src/lint/post-synth/wgl028.ts +67 -0
  53. package/src/lint/post-synth/yaml-helpers.ts +82 -0
  54. package/src/lsp/completions.test.ts +16 -6
  55. package/src/lsp/hover.test.ts +18 -7
  56. package/src/plugin.test.ts +14 -1
  57. package/src/plugin.ts +20 -3
  58. package/src/testdata/pipelines/deploy-envs.gitlab-ci.yml +60 -0
  59. package/src/testdata/pipelines/docker-build.gitlab-ci.yml +41 -0
  60. package/src/testdata/pipelines/includes-templates.gitlab-ci.yml +52 -0
  61. package/src/testdata/pipelines/monorepo.gitlab-ci.yml +51 -0
  62. package/src/testdata/pipelines/multi-stage.gitlab-ci.yml +56 -0
  63. package/src/testdata/pipelines/simple.gitlab-ci.yml +9 -0
  64. package/src/validate.test.ts +12 -6
  65. package/src/variables.test.ts +58 -0
@@ -0,0 +1,51 @@
1
+ /**
2
+ * WGL023: Overly Broad Rules
3
+ *
4
+ * Flags jobs with a single rule that has only `when: always` and no
5
+ * conditions (no `if:`, `changes:`, etc.). This effectively disables
6
+ * all pipeline filtering for the job, which is usually unintended.
7
+ */
8
+
9
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
10
+ import { isPropertyDeclarable } from "@intentius/chant/declarable";
11
+
12
+ export const wgl023: PostSynthCheck = {
13
+ id: "WGL023",
14
+ description: "Overly broad rules — job with only when: always rule (no conditions)",
15
+
16
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
17
+ const diagnostics: PostSynthDiagnostic[] = [];
18
+
19
+ for (const [entityName, entity] of ctx.entities) {
20
+ if (isPropertyDeclarable(entity)) continue;
21
+ const entityType = (entity as Record<string, unknown>).entityType as string;
22
+ if (entityType !== "GitLab::CI::Job") continue;
23
+
24
+ const props = (entity as Record<string, unknown>).props as Record<string, unknown> | undefined;
25
+ if (!props?.rules || !Array.isArray(props.rules)) continue;
26
+
27
+ const rules = props.rules as Array<Record<string, unknown>>;
28
+ if (rules.length !== 1) continue;
29
+
30
+ const rule = rules[0];
31
+ const ruleProps = (rule.props as Record<string, unknown> | undefined) ?? rule;
32
+
33
+ const when = ruleProps.when;
34
+ const hasIf = !!ruleProps.if;
35
+ const hasChanges = !!ruleProps.changes;
36
+ const hasExists = !!ruleProps.exists;
37
+
38
+ if (when === "always" && !hasIf && !hasChanges && !hasExists) {
39
+ diagnostics.push({
40
+ checkId: "WGL023",
41
+ severity: "info",
42
+ message: `Job "${entityName}" has a single rule with only "when: always" — this disables all pipeline filtering. Consider adding conditions or removing rules entirely.`,
43
+ entity: entityName,
44
+ lexicon: "gitlab",
45
+ });
46
+ }
47
+ }
48
+
49
+ return diagnostics;
50
+ },
51
+ };
@@ -0,0 +1,77 @@
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 { wgl024 } from "./wgl024";
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("WGL024: Manual Without allow_failure", () => {
33
+ test("check metadata", () => {
34
+ expect(wgl024.id).toBe("WGL024");
35
+ expect(wgl024.description).toContain("Manual");
36
+ });
37
+
38
+ test("flags manual job without allow_failure", () => {
39
+ const entities = new Map<string, Declarable>([
40
+ ["manualDeploy", new MockJob({ script: ["deploy.sh"], when: "manual" })],
41
+ ]);
42
+ const diags = wgl024.check(makeCtx(entities));
43
+ expect(diags).toHaveLength(1);
44
+ expect(diags[0].severity).toBe("warning");
45
+ expect(diags[0].message).toContain("manualDeploy");
46
+ expect(diags[0].message).toContain("block");
47
+ });
48
+
49
+ test("does not flag manual job with allow_failure: true", () => {
50
+ const entities = new Map<string, Declarable>([
51
+ ["manualDeploy", new MockJob({ script: ["deploy.sh"], when: "manual", allow_failure: true })],
52
+ ]);
53
+ const diags = wgl024.check(makeCtx(entities));
54
+ expect(diags).toHaveLength(0);
55
+ });
56
+
57
+ test("does not flag non-manual job", () => {
58
+ const entities = new Map<string, Declarable>([
59
+ ["autoJob", new MockJob({ script: ["test"] })],
60
+ ]);
61
+ const diags = wgl024.check(makeCtx(entities));
62
+ expect(diags).toHaveLength(0);
63
+ });
64
+
65
+ test("flags manual job with allow_failure: false", () => {
66
+ const entities = new Map<string, Declarable>([
67
+ ["manualJob", new MockJob({ script: ["test"], when: "manual", allow_failure: false })],
68
+ ]);
69
+ const diags = wgl024.check(makeCtx(entities));
70
+ expect(diags).toHaveLength(1);
71
+ });
72
+
73
+ test("no diagnostics on empty entities", () => {
74
+ const diags = wgl024.check(makeCtx(new Map()));
75
+ expect(diags).toHaveLength(0);
76
+ });
77
+ });
@@ -0,0 +1,46 @@
1
+ /**
2
+ * WGL024: Manual Without allow_failure
3
+ *
4
+ * Warns about jobs with `when: manual` that don't set `allow_failure: true`.
5
+ * Without it, the manual job blocks the pipeline from progressing past
6
+ * its stage until someone manually triggers it.
7
+ */
8
+
9
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
10
+ import { isPropertyDeclarable } from "@intentius/chant/declarable";
11
+
12
+ export const wgl024: PostSynthCheck = {
13
+ id: "WGL024",
14
+ description: "Manual without allow_failure — manual jobs block pipeline without allow_failure: true",
15
+
16
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
17
+ const diagnostics: PostSynthDiagnostic[] = [];
18
+
19
+ for (const [entityName, entity] of ctx.entities) {
20
+ if (isPropertyDeclarable(entity)) continue;
21
+ const entityType = (entity as Record<string, unknown>).entityType as string;
22
+ if (entityType !== "GitLab::CI::Job") continue;
23
+
24
+ const props = (entity as Record<string, unknown>).props as Record<string, unknown> | undefined;
25
+ if (!props) continue;
26
+
27
+ // Check top-level when: manual
28
+ const isManual = props.when === "manual";
29
+ if (!isManual) continue;
30
+
31
+ // Check allow_failure
32
+ const allowFailure = props.allow_failure ?? props.allowFailure;
33
+ if (allowFailure !== true) {
34
+ diagnostics.push({
35
+ checkId: "WGL024",
36
+ severity: "warning",
37
+ message: `Job "${entityName}" has when: manual but no allow_failure: true — this will block the pipeline`,
38
+ entity: entityName,
39
+ lexicon: "gitlab",
40
+ });
41
+ }
42
+ }
43
+
44
+ return diagnostics;
45
+ },
46
+ };
@@ -0,0 +1,85 @@
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 { wgl025 } from "./wgl025";
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("WGL025: Missing Cache Key", () => {
33
+ test("check metadata", () => {
34
+ expect(wgl025.id).toBe("WGL025");
35
+ expect(wgl025.description).toContain("cache");
36
+ });
37
+
38
+ test("flags cache without key", () => {
39
+ const entities = new Map<string, Declarable>([
40
+ ["buildJob", new MockJob({
41
+ script: ["npm build"],
42
+ cache: { paths: ["node_modules/"] },
43
+ })],
44
+ ]);
45
+ const diags = wgl025.check(makeCtx(entities));
46
+ expect(diags).toHaveLength(1);
47
+ expect(diags[0].severity).toBe("warning");
48
+ expect(diags[0].message).toContain("buildJob");
49
+ });
50
+
51
+ test("does not flag cache with key", () => {
52
+ const entities = new Map<string, Declarable>([
53
+ ["buildJob", new MockJob({
54
+ script: ["npm build"],
55
+ cache: { key: "$CI_COMMIT_REF_SLUG", paths: ["node_modules/"] },
56
+ })],
57
+ ]);
58
+ const diags = wgl025.check(makeCtx(entities));
59
+ expect(diags).toHaveLength(0);
60
+ });
61
+
62
+ test("does not flag job without cache", () => {
63
+ const entities = new Map<string, Declarable>([
64
+ ["testJob", new MockJob({ script: ["npm test"] })],
65
+ ]);
66
+ const diags = wgl025.check(makeCtx(entities));
67
+ expect(diags).toHaveLength(0);
68
+ });
69
+
70
+ test("handles cache as declarable with props", () => {
71
+ const entities = new Map<string, Declarable>([
72
+ ["buildJob", new MockJob({
73
+ script: ["npm build"],
74
+ cache: { props: { key: "my-cache", paths: ["node_modules/"] } },
75
+ })],
76
+ ]);
77
+ const diags = wgl025.check(makeCtx(entities));
78
+ expect(diags).toHaveLength(0);
79
+ });
80
+
81
+ test("no diagnostics on empty entities", () => {
82
+ const diags = wgl025.check(makeCtx(new Map()));
83
+ expect(diags).toHaveLength(0);
84
+ });
85
+ });
@@ -0,0 +1,49 @@
1
+ /**
2
+ * WGL025: Missing Cache Key
3
+ *
4
+ * Warns about `cache:` without `key:`. Without an explicit key, GitLab
5
+ * uses `default` as the key, which causes cache collisions between
6
+ * unrelated jobs sharing the same runner.
7
+ */
8
+
9
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
10
+ import { isPropertyDeclarable } from "@intentius/chant/declarable";
11
+
12
+ export const wgl025: PostSynthCheck = {
13
+ id: "WGL025",
14
+ description: "Missing cache key — cache without key causes collisions",
15
+
16
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
17
+ const diagnostics: PostSynthDiagnostic[] = [];
18
+
19
+ for (const [entityName, entity] of ctx.entities) {
20
+ if (isPropertyDeclarable(entity)) continue;
21
+ const entityType = (entity as Record<string, unknown>).entityType as string;
22
+ if (entityType !== "GitLab::CI::Job") continue;
23
+
24
+ const props = (entity as Record<string, unknown>).props as Record<string, unknown> | undefined;
25
+ if (!props?.cache) continue;
26
+
27
+ // Cache can be a single object or an array
28
+ const caches = Array.isArray(props.cache) ? props.cache : [props.cache];
29
+
30
+ for (const cache of caches) {
31
+ const cacheObj = cache as Record<string, unknown>;
32
+ const cacheProps = (cacheObj.props as Record<string, unknown> | undefined) ?? cacheObj;
33
+
34
+ if (!cacheProps.key) {
35
+ diagnostics.push({
36
+ checkId: "WGL025",
37
+ severity: "warning",
38
+ message: `Job "${entityName}" has cache without a key — this causes cache collisions between jobs`,
39
+ entity: entityName,
40
+ lexicon: "gitlab",
41
+ });
42
+ break; // One diagnostic per job is enough
43
+ }
44
+ }
45
+ }
46
+
47
+ return diagnostics;
48
+ },
49
+ };
@@ -0,0 +1,87 @@
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 { wgl026 } from "./wgl026";
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("WGL026: Privileged Services Without TLS", () => {
33
+ test("check metadata", () => {
34
+ expect(wgl026.id).toBe("WGL026");
35
+ expect(wgl026.description).toContain("TLS");
36
+ });
37
+
38
+ test("flags DinD service without TLS cert dir", () => {
39
+ const entities = new Map<string, Declarable>([
40
+ ["buildImage", new MockJob({
41
+ script: ["docker build ."],
42
+ services: [{ name: "docker:dind" }],
43
+ })],
44
+ ]);
45
+ const diags = wgl026.check(makeCtx(entities));
46
+ expect(diags).toHaveLength(1);
47
+ expect(diags[0].severity).toBe("warning");
48
+ expect(diags[0].message).toContain("buildImage");
49
+ expect(diags[0].message).toContain("TLS");
50
+ });
51
+
52
+ test("does not flag DinD service with TLS cert dir in job variables", () => {
53
+ const entities = new Map<string, Declarable>([
54
+ ["buildImage", new MockJob({
55
+ script: ["docker build ."],
56
+ services: [{ name: "docker:dind" }],
57
+ variables: { DOCKER_TLS_CERTDIR: "/certs" },
58
+ })],
59
+ ]);
60
+ const diags = wgl026.check(makeCtx(entities));
61
+ expect(diags).toHaveLength(0);
62
+ });
63
+
64
+ test("does not flag non-DinD service", () => {
65
+ const entities = new Map<string, Declarable>([
66
+ ["testJob", new MockJob({
67
+ script: ["npm test"],
68
+ services: [{ name: "postgres:15" }],
69
+ })],
70
+ ]);
71
+ const diags = wgl026.check(makeCtx(entities));
72
+ expect(diags).toHaveLength(0);
73
+ });
74
+
75
+ test("does not flag job without services", () => {
76
+ const entities = new Map<string, Declarable>([
77
+ ["simpleJob", new MockJob({ script: ["test"] })],
78
+ ]);
79
+ const diags = wgl026.check(makeCtx(entities));
80
+ expect(diags).toHaveLength(0);
81
+ });
82
+
83
+ test("no diagnostics on empty entities", () => {
84
+ const diags = wgl026.check(makeCtx(new Map()));
85
+ expect(diags).toHaveLength(0);
86
+ });
87
+ });
@@ -0,0 +1,67 @@
1
+ /**
2
+ * WGL026: Privileged Services Without TLS
3
+ *
4
+ * Warns about Docker-in-Docker (DinD) services that don't set
5
+ * DOCKER_TLS_CERTDIR. Running DinD without TLS exposes the Docker
6
+ * daemon on an unencrypted socket.
7
+ */
8
+
9
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
10
+ import { isPropertyDeclarable } from "@intentius/chant/declarable";
11
+
12
+ const DIND_IMAGES = ["docker:dind", "docker:stable-dind"];
13
+
14
+ function isDindImage(image: string): boolean {
15
+ return DIND_IMAGES.some((dind) => image.includes(dind));
16
+ }
17
+
18
+ export const wgl026: PostSynthCheck = {
19
+ id: "WGL026",
20
+ description: "Privileged services without TLS — DinD services without DOCKER_TLS_CERTDIR",
21
+
22
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
23
+ const diagnostics: PostSynthDiagnostic[] = [];
24
+
25
+ for (const [entityName, entity] of ctx.entities) {
26
+ if (isPropertyDeclarable(entity)) continue;
27
+ const entityType = (entity as Record<string, unknown>).entityType as string;
28
+ if (entityType !== "GitLab::CI::Job") continue;
29
+
30
+ const props = (entity as Record<string, unknown>).props as Record<string, unknown> | undefined;
31
+ if (!props?.services || !Array.isArray(props.services)) continue;
32
+
33
+ for (const service of props.services) {
34
+ let imageName: string | undefined;
35
+ let serviceVars: Record<string, unknown> | undefined;
36
+
37
+ if (typeof service === "string") {
38
+ imageName = service;
39
+ } else if (typeof service === "object" && service !== null) {
40
+ const svc = service as Record<string, unknown>;
41
+ const svcProps = (svc.props as Record<string, unknown> | undefined) ?? svc;
42
+ imageName = svcProps.name as string | undefined;
43
+ serviceVars = svcProps.variables as Record<string, unknown> | undefined;
44
+ }
45
+
46
+ if (!imageName || !isDindImage(imageName)) continue;
47
+
48
+ // Check if DOCKER_TLS_CERTDIR is set in service variables or job variables
49
+ const jobVars = props.variables as Record<string, unknown> | undefined;
50
+ const hasTLS = serviceVars?.DOCKER_TLS_CERTDIR !== undefined ||
51
+ jobVars?.DOCKER_TLS_CERTDIR !== undefined;
52
+
53
+ if (!hasTLS) {
54
+ diagnostics.push({
55
+ checkId: "WGL026",
56
+ severity: "warning",
57
+ message: `Job "${entityName}" uses DinD service without DOCKER_TLS_CERTDIR — the Docker daemon will be unencrypted`,
58
+ entity: entityName,
59
+ lexicon: "gitlab",
60
+ });
61
+ }
62
+ }
63
+ }
64
+
65
+ return diagnostics;
66
+ },
67
+ };
@@ -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
+ };