@intentius/chant-lexicon-gitlab 0.0.16 → 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.
- package/dist/integrity.json +17 -4
- package/dist/manifest.json +1 -1
- package/dist/rules/wgl016.ts +82 -0
- package/dist/rules/wgl017.ts +54 -0
- package/dist/rules/wgl018.ts +39 -0
- package/dist/rules/wgl019.ts +44 -0
- package/dist/rules/wgl020.ts +56 -0
- package/dist/rules/wgl021.ts +62 -0
- package/dist/rules/wgl022.ts +44 -0
- package/dist/rules/wgl023.ts +51 -0
- package/dist/rules/wgl024.ts +46 -0
- package/dist/rules/wgl025.ts +49 -0
- package/dist/rules/wgl026.ts +67 -0
- package/dist/rules/wgl027.ts +54 -0
- package/dist/rules/wgl028.ts +67 -0
- package/dist/rules/yaml-helpers.ts +82 -0
- package/dist/skills/chant-gitlab.md +1 -1
- package/package.json +2 -2
- package/src/codegen/fetch.test.ts +30 -0
- package/src/codegen/generate.test.ts +65 -0
- package/src/codegen/idempotency.test.ts +28 -0
- package/src/codegen/naming.test.ts +93 -0
- package/src/codegen/snapshot.test.ts +28 -19
- package/src/composites/composites.test.ts +160 -0
- package/src/coverage.test.ts +15 -7
- package/src/import/roundtrip.test.ts +132 -0
- package/src/lint/post-synth/wgl016.test.ts +72 -0
- package/src/lint/post-synth/wgl016.ts +82 -0
- package/src/lint/post-synth/wgl017.test.ts +53 -0
- package/src/lint/post-synth/wgl017.ts +54 -0
- package/src/lint/post-synth/wgl018.test.ts +69 -0
- package/src/lint/post-synth/wgl018.ts +39 -0
- package/src/lint/post-synth/wgl019.test.ts +76 -0
- package/src/lint/post-synth/wgl019.ts +44 -0
- package/src/lint/post-synth/wgl020.test.ts +54 -0
- package/src/lint/post-synth/wgl020.ts +56 -0
- package/src/lint/post-synth/wgl021.test.ts +62 -0
- package/src/lint/post-synth/wgl021.ts +62 -0
- package/src/lint/post-synth/wgl022.test.ts +86 -0
- package/src/lint/post-synth/wgl022.ts +44 -0
- package/src/lint/post-synth/wgl023.test.ts +88 -0
- package/src/lint/post-synth/wgl023.ts +51 -0
- package/src/lint/post-synth/wgl024.test.ts +77 -0
- package/src/lint/post-synth/wgl024.ts +46 -0
- package/src/lint/post-synth/wgl025.test.ts +85 -0
- package/src/lint/post-synth/wgl025.ts +49 -0
- package/src/lint/post-synth/wgl026.test.ts +87 -0
- package/src/lint/post-synth/wgl026.ts +67 -0
- package/src/lint/post-synth/wgl027.test.ts +84 -0
- package/src/lint/post-synth/wgl027.ts +54 -0
- package/src/lint/post-synth/wgl028.test.ts +95 -0
- package/src/lint/post-synth/wgl028.ts +67 -0
- package/src/lint/post-synth/yaml-helpers.ts +82 -0
- package/src/lsp/completions.test.ts +16 -6
- package/src/lsp/hover.test.ts +18 -7
- package/src/plugin.test.ts +14 -1
- package/src/plugin.ts +19 -2
- package/src/testdata/pipelines/deploy-envs.gitlab-ci.yml +60 -0
- package/src/testdata/pipelines/docker-build.gitlab-ci.yml +41 -0
- package/src/testdata/pipelines/includes-templates.gitlab-ci.yml +52 -0
- package/src/testdata/pipelines/monorepo.gitlab-ci.yml +51 -0
- package/src/testdata/pipelines/multi-stage.gitlab-ci.yml +56 -0
- package/src/testdata/pipelines/simple.gitlab-ci.yml +9 -0
- package/src/validate.test.ts +12 -6
- package/src/variables.test.ts +58 -0
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,86 @@
|
|
|
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 { wgl022 } from "./wgl022";
|
|
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("WGL022: Missing Artifacts Expiry", () => {
|
|
33
|
+
test("check metadata", () => {
|
|
34
|
+
expect(wgl022.id).toBe("WGL022");
|
|
35
|
+
expect(wgl022.description).toContain("artifacts");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("flags artifacts without expire_in", () => {
|
|
39
|
+
const entities = new Map<string, Declarable>([
|
|
40
|
+
["buildJob", new MockJob({
|
|
41
|
+
script: ["npm build"],
|
|
42
|
+
artifacts: { paths: ["dist/"] },
|
|
43
|
+
})],
|
|
44
|
+
]);
|
|
45
|
+
const diags = wgl022.check(makeCtx(entities));
|
|
46
|
+
expect(diags).toHaveLength(1);
|
|
47
|
+
expect(diags[0].severity).toBe("warning");
|
|
48
|
+
expect(diags[0].message).toContain("buildJob");
|
|
49
|
+
expect(diags[0].message).toContain("expire_in");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("does not flag artifacts with expire_in", () => {
|
|
53
|
+
const entities = new Map<string, Declarable>([
|
|
54
|
+
["buildJob", new MockJob({
|
|
55
|
+
script: ["npm build"],
|
|
56
|
+
artifacts: { paths: ["dist/"], expire_in: "1 week" },
|
|
57
|
+
})],
|
|
58
|
+
]);
|
|
59
|
+
const diags = wgl022.check(makeCtx(entities));
|
|
60
|
+
expect(diags).toHaveLength(0);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("does not flag job without artifacts", () => {
|
|
64
|
+
const entities = new Map<string, Declarable>([
|
|
65
|
+
["testJob", new MockJob({ script: ["npm test"] })],
|
|
66
|
+
]);
|
|
67
|
+
const diags = wgl022.check(makeCtx(entities));
|
|
68
|
+
expect(diags).toHaveLength(0);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("handles artifacts as declarable with props", () => {
|
|
72
|
+
const entities = new Map<string, Declarable>([
|
|
73
|
+
["buildJob", new MockJob({
|
|
74
|
+
script: ["npm build"],
|
|
75
|
+
artifacts: { props: { paths: ["dist/"], expire_in: "30 days" } },
|
|
76
|
+
})],
|
|
77
|
+
]);
|
|
78
|
+
const diags = wgl022.check(makeCtx(entities));
|
|
79
|
+
expect(diags).toHaveLength(0);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("no diagnostics on empty entities", () => {
|
|
83
|
+
const diags = wgl022.check(makeCtx(new Map()));
|
|
84
|
+
expect(diags).toHaveLength(0);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WGL022: Missing Artifacts Expiry
|
|
3
|
+
*
|
|
4
|
+
* Warns about `artifacts:` without `expire_in:`, which causes disk bloat
|
|
5
|
+
* on the GitLab instance. Default retention is "never expire" in some
|
|
6
|
+
* GitLab configurations.
|
|
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 wgl022: PostSynthCheck = {
|
|
13
|
+
id: "WGL022",
|
|
14
|
+
description: "Missing artifacts expiry — artifacts without expire_in cause disk bloat",
|
|
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?.artifacts) continue;
|
|
26
|
+
|
|
27
|
+
const artifacts = props.artifacts as Record<string, unknown>;
|
|
28
|
+
// artifacts might be a Declarable with its own props
|
|
29
|
+
const artProps = (artifacts.props as Record<string, unknown> | undefined) ?? artifacts;
|
|
30
|
+
|
|
31
|
+
if (!artProps.expire_in && !artProps.expireIn) {
|
|
32
|
+
diagnostics.push({
|
|
33
|
+
checkId: "WGL022",
|
|
34
|
+
severity: "warning",
|
|
35
|
+
message: `Job "${entityName}" has artifacts without expire_in — set an expiry to avoid disk bloat`,
|
|
36
|
+
entity: entityName,
|
|
37
|
+
lexicon: "gitlab",
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return diagnostics;
|
|
43
|
+
},
|
|
44
|
+
};
|
|
@@ -0,0 +1,88 @@
|
|
|
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 { wgl023 } from "./wgl023";
|
|
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("WGL023: Overly Broad Rules", () => {
|
|
33
|
+
test("check metadata", () => {
|
|
34
|
+
expect(wgl023.id).toBe("WGL023");
|
|
35
|
+
expect(wgl023.description).toContain("Overly broad");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("flags single rule with only when: always", () => {
|
|
39
|
+
const entities = new Map<string, Declarable>([
|
|
40
|
+
["alwaysJob", new MockJob({
|
|
41
|
+
script: ["test"],
|
|
42
|
+
rules: [{ when: "always" }],
|
|
43
|
+
})],
|
|
44
|
+
]);
|
|
45
|
+
const diags = wgl023.check(makeCtx(entities));
|
|
46
|
+
expect(diags).toHaveLength(1);
|
|
47
|
+
expect(diags[0].severity).toBe("info");
|
|
48
|
+
expect(diags[0].message).toContain("alwaysJob");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("does not flag rule with when: always and if condition", () => {
|
|
52
|
+
const entities = new Map<string, Declarable>([
|
|
53
|
+
["conditionalJob", new MockJob({
|
|
54
|
+
script: ["test"],
|
|
55
|
+
rules: [{ if: "$CI_COMMIT_BRANCH == 'main'", when: "always" }],
|
|
56
|
+
})],
|
|
57
|
+
]);
|
|
58
|
+
const diags = wgl023.check(makeCtx(entities));
|
|
59
|
+
expect(diags).toHaveLength(0);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("does not flag multiple rules", () => {
|
|
63
|
+
const entities = new Map<string, Declarable>([
|
|
64
|
+
["multiRuleJob", new MockJob({
|
|
65
|
+
script: ["test"],
|
|
66
|
+
rules: [
|
|
67
|
+
{ when: "always" },
|
|
68
|
+
{ when: "never" },
|
|
69
|
+
],
|
|
70
|
+
})],
|
|
71
|
+
]);
|
|
72
|
+
const diags = wgl023.check(makeCtx(entities));
|
|
73
|
+
expect(diags).toHaveLength(0);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("does not flag job without rules", () => {
|
|
77
|
+
const entities = new Map<string, Declarable>([
|
|
78
|
+
["simpleJob", new MockJob({ script: ["test"] })],
|
|
79
|
+
]);
|
|
80
|
+
const diags = wgl023.check(makeCtx(entities));
|
|
81
|
+
expect(diags).toHaveLength(0);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("no diagnostics on empty entities", () => {
|
|
85
|
+
const diags = wgl023.check(makeCtx(new Map()));
|
|
86
|
+
expect(diags).toHaveLength(0);
|
|
87
|
+
});
|
|
88
|
+
});
|