@intentius/chant-lexicon-gitlab 0.0.8 → 0.0.9
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 +10 -6
- package/dist/manifest.json +1 -1
- package/dist/meta.json +186 -8
- package/dist/rules/wgl012.ts +86 -0
- package/dist/rules/wgl013.ts +62 -0
- package/dist/rules/wgl014.ts +51 -0
- package/dist/rules/wgl015.ts +85 -0
- package/dist/rules/yaml-helpers.ts +65 -3
- package/dist/skills/chant-gitlab.md +467 -24
- package/dist/types/index.d.ts +55 -16
- package/package.json +2 -2
- package/src/codegen/__snapshots__/snapshot.test.ts.snap +58 -0
- package/src/codegen/docs.ts +32 -9
- package/src/codegen/generate-lexicon.ts +6 -1
- package/src/codegen/generate.ts +45 -50
- package/src/codegen/naming.ts +3 -0
- package/src/codegen/parse.test.ts +154 -4
- package/src/codegen/parse.ts +161 -49
- package/src/codegen/snapshot.test.ts +7 -5
- package/src/composites/composites.test.ts +452 -0
- package/src/composites/docker-build.ts +81 -0
- package/src/composites/index.ts +8 -0
- package/src/composites/node-pipeline.ts +104 -0
- package/src/composites/python-pipeline.ts +75 -0
- package/src/composites/review-app.ts +63 -0
- package/src/generated/index.d.ts +55 -16
- package/src/generated/index.ts +3 -0
- package/src/generated/lexicon-gitlab.json +186 -8
- package/src/import/generator.ts +3 -2
- package/src/index.ts +4 -0
- package/src/lint/post-synth/wgl012.test.ts +131 -0
- package/src/lint/post-synth/wgl012.ts +86 -0
- package/src/lint/post-synth/wgl013.test.ts +164 -0
- package/src/lint/post-synth/wgl013.ts +62 -0
- package/src/lint/post-synth/wgl014.test.ts +97 -0
- package/src/lint/post-synth/wgl014.ts +51 -0
- package/src/lint/post-synth/wgl015.test.ts +139 -0
- package/src/lint/post-synth/wgl015.ts +85 -0
- package/src/lint/post-synth/yaml-helpers.ts +65 -3
- package/src/plugin.test.ts +39 -13
- package/src/plugin.ts +636 -40
- package/src/serializer.test.ts +140 -0
- package/src/serializer.ts +63 -5
- package/src/validate.ts +1 -0
- package/src/variables.ts +4 -0
|
@@ -0,0 +1,131 @@
|
|
|
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 { wgl012, checkDeprecatedProperties } from "./wgl012";
|
|
5
|
+
|
|
6
|
+
class MockEntity implements Declarable {
|
|
7
|
+
readonly [DECLARABLE_MARKER] = true as const;
|
|
8
|
+
readonly lexicon = "gitlab";
|
|
9
|
+
readonly entityType: string;
|
|
10
|
+
readonly kind = "resource" as const;
|
|
11
|
+
readonly props: Record<string, unknown>;
|
|
12
|
+
|
|
13
|
+
constructor(entityType: string, props: Record<string, unknown> = {}) {
|
|
14
|
+
this.entityType = entityType;
|
|
15
|
+
this.props = props;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function makeCtx(entities: Map<string, Declarable>): PostSynthContext {
|
|
20
|
+
return {
|
|
21
|
+
outputs: new Map(),
|
|
22
|
+
entities,
|
|
23
|
+
buildResult: {
|
|
24
|
+
outputs: new Map(),
|
|
25
|
+
entities,
|
|
26
|
+
warnings: [],
|
|
27
|
+
errors: [],
|
|
28
|
+
sourceFileCount: 1,
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Synthetic deprecated-property map — no disk dependency. */
|
|
34
|
+
function fakeDeprecated(): Map<string, Set<string>> {
|
|
35
|
+
return new Map([
|
|
36
|
+
["GitLab::CI::Job", new Set(["only", "except"])],
|
|
37
|
+
["GitLab::CI::Artifacts", new Set(["license_management"])],
|
|
38
|
+
]);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe("WGL012: Deprecated Property Usage", () => {
|
|
42
|
+
test("check metadata", () => {
|
|
43
|
+
expect(wgl012.id).toBe("WGL012");
|
|
44
|
+
expect(wgl012.description).toContain("Deprecated");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("emits warning for deprecated property", () => {
|
|
48
|
+
const entities = new Map<string, Declarable>([
|
|
49
|
+
["deployJob", new MockEntity("GitLab::CI::Job", {
|
|
50
|
+
only: ["main"],
|
|
51
|
+
script: ["deploy.sh"],
|
|
52
|
+
})],
|
|
53
|
+
]);
|
|
54
|
+
const diags = checkDeprecatedProperties(makeCtx(entities), fakeDeprecated());
|
|
55
|
+
expect(diags).toHaveLength(1);
|
|
56
|
+
expect(diags[0].checkId).toBe("WGL012");
|
|
57
|
+
expect(diags[0].severity).toBe("warning");
|
|
58
|
+
expect(diags[0].message).toContain("only");
|
|
59
|
+
expect(diags[0].message).toContain("deployJob");
|
|
60
|
+
expect(diags[0].message).toContain("deprecated");
|
|
61
|
+
expect(diags[0].entity).toBe("deployJob");
|
|
62
|
+
expect(diags[0].lexicon).toBe("gitlab");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("emits one warning per deprecated property", () => {
|
|
66
|
+
const entities = new Map<string, Declarable>([
|
|
67
|
+
["oldJob", new MockEntity("GitLab::CI::Job", {
|
|
68
|
+
only: ["main"],
|
|
69
|
+
except: ["tags"],
|
|
70
|
+
script: ["test"],
|
|
71
|
+
})],
|
|
72
|
+
]);
|
|
73
|
+
const diags = checkDeprecatedProperties(makeCtx(entities), fakeDeprecated());
|
|
74
|
+
expect(diags).toHaveLength(2);
|
|
75
|
+
expect(diags.some((d) => d.message.includes("only"))).toBe(true);
|
|
76
|
+
expect(diags.some((d) => d.message.includes("except"))).toBe(true);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("no diagnostic for non-deprecated properties", () => {
|
|
80
|
+
const entities = new Map<string, Declarable>([
|
|
81
|
+
["testJob", new MockEntity("GitLab::CI::Job", {
|
|
82
|
+
script: ["npm test"],
|
|
83
|
+
stage: "test",
|
|
84
|
+
})],
|
|
85
|
+
]);
|
|
86
|
+
const diags = checkDeprecatedProperties(makeCtx(entities), fakeDeprecated());
|
|
87
|
+
expect(diags).toHaveLength(0);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("no diagnostic for entity type not in map", () => {
|
|
91
|
+
const entities = new Map<string, Declarable>([
|
|
92
|
+
["myCache", new MockEntity("GitLab::CI::Cache", {
|
|
93
|
+
paths: ["node_modules/"],
|
|
94
|
+
})],
|
|
95
|
+
]);
|
|
96
|
+
const diags = checkDeprecatedProperties(makeCtx(entities), fakeDeprecated());
|
|
97
|
+
expect(diags).toHaveLength(0);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("no diagnostic on empty entities", () => {
|
|
101
|
+
const diags = checkDeprecatedProperties(makeCtx(new Map()), fakeDeprecated());
|
|
102
|
+
expect(diags).toHaveLength(0);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("returns empty when deprecated map is empty", () => {
|
|
106
|
+
const entities = new Map<string, Declarable>([
|
|
107
|
+
["job", new MockEntity("GitLab::CI::Job", { only: ["main"] })],
|
|
108
|
+
]);
|
|
109
|
+
const diags = checkDeprecatedProperties(makeCtx(entities), new Map());
|
|
110
|
+
expect(diags).toHaveLength(0);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("flags deprecated properties across multiple entities", () => {
|
|
114
|
+
const entities = new Map<string, Declarable>([
|
|
115
|
+
["oldJob", new MockEntity("GitLab::CI::Job", { only: ["main"], script: ["test"] })],
|
|
116
|
+
["artifacts", new MockEntity("GitLab::CI::Artifacts", { license_management: "report.json" })],
|
|
117
|
+
]);
|
|
118
|
+
const diags = checkDeprecatedProperties(makeCtx(entities), fakeDeprecated());
|
|
119
|
+
expect(diags).toHaveLength(2);
|
|
120
|
+
expect(diags[0].entity).toBe("oldJob");
|
|
121
|
+
expect(diags[1].entity).toBe("artifacts");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("handles entity with no props", () => {
|
|
125
|
+
const entities = new Map<string, Declarable>([
|
|
126
|
+
["emptyJob", new MockEntity("GitLab::CI::Job")],
|
|
127
|
+
]);
|
|
128
|
+
const diags = checkDeprecatedProperties(makeCtx(entities), fakeDeprecated());
|
|
129
|
+
expect(diags).toHaveLength(0);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WGL012: Deprecated Property Usage
|
|
3
|
+
*
|
|
4
|
+
* Flags properties marked as deprecated in the GitLab CI schema.
|
|
5
|
+
* Sources: description text mining (keywords like "Deprecated", "legacy").
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { readFileSync } from "fs";
|
|
9
|
+
import { join } from "path";
|
|
10
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
11
|
+
import { isPropertyDeclarable } from "@intentius/chant/declarable";
|
|
12
|
+
|
|
13
|
+
interface LexiconEntry {
|
|
14
|
+
kind: string;
|
|
15
|
+
resourceType: string;
|
|
16
|
+
deprecatedProperties?: string[];
|
|
17
|
+
[key: string]: unknown;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Load deprecated properties per entity type from the lexicon JSON.
|
|
22
|
+
*/
|
|
23
|
+
function loadDeprecatedProperties(): Map<string, Set<string>> {
|
|
24
|
+
const map = new Map<string, Set<string>>();
|
|
25
|
+
try {
|
|
26
|
+
const pkgDir = join(__dirname, "..", "..", "..");
|
|
27
|
+
const lexiconPath = join(pkgDir, "src", "generated", "lexicon-gitlab.json");
|
|
28
|
+
const content = readFileSync(lexiconPath, "utf-8");
|
|
29
|
+
const data = JSON.parse(content) as Record<string, LexiconEntry>;
|
|
30
|
+
|
|
31
|
+
for (const [_name, entry] of Object.entries(data)) {
|
|
32
|
+
if (entry.resourceType && entry.deprecatedProperties && entry.deprecatedProperties.length > 0) {
|
|
33
|
+
map.set(entry.resourceType, new Set(entry.deprecatedProperties));
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
} catch {
|
|
37
|
+
// Lexicon not available — skip
|
|
38
|
+
}
|
|
39
|
+
return map;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Core detection logic — exported for direct testing with synthetic data.
|
|
44
|
+
*/
|
|
45
|
+
export function checkDeprecatedProperties(
|
|
46
|
+
ctx: PostSynthContext,
|
|
47
|
+
deprecated: Map<string, Set<string>>,
|
|
48
|
+
): PostSynthDiagnostic[] {
|
|
49
|
+
if (deprecated.size === 0) return [];
|
|
50
|
+
|
|
51
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
52
|
+
|
|
53
|
+
for (const [entityName, entity] of ctx.entities) {
|
|
54
|
+
if (isPropertyDeclarable(entity)) continue;
|
|
55
|
+
|
|
56
|
+
const entityType = (entity as Record<string, unknown>).entityType as string;
|
|
57
|
+
const deprProps = deprecated.get(entityType);
|
|
58
|
+
if (!deprProps) continue;
|
|
59
|
+
|
|
60
|
+
const props = (entity as Record<string, unknown>).props as Record<string, unknown> | undefined;
|
|
61
|
+
if (!props) continue;
|
|
62
|
+
|
|
63
|
+
for (const propName of Object.keys(props)) {
|
|
64
|
+
if (deprProps.has(propName)) {
|
|
65
|
+
diagnostics.push({
|
|
66
|
+
checkId: "WGL012",
|
|
67
|
+
severity: "warning",
|
|
68
|
+
message: `Entity "${entityName}" (${entityType}) uses deprecated property "${propName}" — consider alternatives`,
|
|
69
|
+
entity: entityName,
|
|
70
|
+
lexicon: "gitlab",
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return diagnostics;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export const wgl012: PostSynthCheck = {
|
|
80
|
+
id: "WGL012",
|
|
81
|
+
description: "Deprecated property usage — flags properties marked as deprecated in the GitLab CI schema",
|
|
82
|
+
|
|
83
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
84
|
+
return checkDeprecatedProperties(ctx, loadDeprecatedProperties());
|
|
85
|
+
},
|
|
86
|
+
};
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
|
|
3
|
+
import { wgl013, checkInvalidNeeds } from "./wgl013";
|
|
4
|
+
|
|
5
|
+
function makeCtx(yaml: string): PostSynthContext {
|
|
6
|
+
return {
|
|
7
|
+
outputs: new Map([["gitlab", yaml]]),
|
|
8
|
+
entities: new Map(),
|
|
9
|
+
buildResult: {
|
|
10
|
+
outputs: new Map([["gitlab", yaml]]),
|
|
11
|
+
entities: new Map(),
|
|
12
|
+
warnings: [],
|
|
13
|
+
errors: [],
|
|
14
|
+
sourceFileCount: 1,
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe("WGL013: Invalid needs: Target", () => {
|
|
20
|
+
test("check metadata", () => {
|
|
21
|
+
expect(wgl013.id).toBe("WGL013");
|
|
22
|
+
expect(wgl013.description).toContain("needs:");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("dangling needs target → error", () => {
|
|
26
|
+
const yaml = `stages:
|
|
27
|
+
- build
|
|
28
|
+
- test
|
|
29
|
+
|
|
30
|
+
build:
|
|
31
|
+
stage: build
|
|
32
|
+
script:
|
|
33
|
+
- npm run build
|
|
34
|
+
|
|
35
|
+
test:
|
|
36
|
+
stage: test
|
|
37
|
+
needs:
|
|
38
|
+
- nonexistent
|
|
39
|
+
script:
|
|
40
|
+
- npm test`;
|
|
41
|
+
const diags = checkInvalidNeeds(makeCtx(yaml));
|
|
42
|
+
expect(diags).toHaveLength(1);
|
|
43
|
+
expect(diags[0].checkId).toBe("WGL013");
|
|
44
|
+
expect(diags[0].severity).toBe("error");
|
|
45
|
+
expect(diags[0].message).toContain("nonexistent");
|
|
46
|
+
expect(diags[0].message).toContain("not defined");
|
|
47
|
+
expect(diags[0].entity).toBe("test");
|
|
48
|
+
expect(diags[0].lexicon).toBe("gitlab");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("self-referencing needs → error", () => {
|
|
52
|
+
const yaml = `stages:
|
|
53
|
+
- build
|
|
54
|
+
|
|
55
|
+
build:
|
|
56
|
+
stage: build
|
|
57
|
+
needs:
|
|
58
|
+
- build
|
|
59
|
+
script:
|
|
60
|
+
- npm run build`;
|
|
61
|
+
const diags = checkInvalidNeeds(makeCtx(yaml));
|
|
62
|
+
expect(diags).toHaveLength(1);
|
|
63
|
+
expect(diags[0].checkId).toBe("WGL013");
|
|
64
|
+
expect(diags[0].severity).toBe("error");
|
|
65
|
+
expect(diags[0].message).toContain("itself");
|
|
66
|
+
expect(diags[0].entity).toBe("build");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("valid needs → no diagnostic", () => {
|
|
70
|
+
const yaml = `stages:
|
|
71
|
+
- build
|
|
72
|
+
- test
|
|
73
|
+
|
|
74
|
+
build:
|
|
75
|
+
stage: build
|
|
76
|
+
script:
|
|
77
|
+
- npm run build
|
|
78
|
+
|
|
79
|
+
test:
|
|
80
|
+
stage: test
|
|
81
|
+
needs:
|
|
82
|
+
- build
|
|
83
|
+
script:
|
|
84
|
+
- npm test`;
|
|
85
|
+
const diags = checkInvalidNeeds(makeCtx(yaml));
|
|
86
|
+
expect(diags).toHaveLength(0);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("no needs → no diagnostic", () => {
|
|
90
|
+
const yaml = `stages:
|
|
91
|
+
- build
|
|
92
|
+
|
|
93
|
+
build:
|
|
94
|
+
stage: build
|
|
95
|
+
script:
|
|
96
|
+
- npm run build`;
|
|
97
|
+
const diags = checkInvalidNeeds(makeCtx(yaml));
|
|
98
|
+
expect(diags).toHaveLength(0);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("multiple jobs, only one with bad needs → 1 diagnostic", () => {
|
|
102
|
+
const yaml = `stages:
|
|
103
|
+
- build
|
|
104
|
+
- test
|
|
105
|
+
- deploy
|
|
106
|
+
|
|
107
|
+
build:
|
|
108
|
+
stage: build
|
|
109
|
+
script:
|
|
110
|
+
- npm run build
|
|
111
|
+
|
|
112
|
+
test:
|
|
113
|
+
stage: test
|
|
114
|
+
needs:
|
|
115
|
+
- build
|
|
116
|
+
script:
|
|
117
|
+
- npm test
|
|
118
|
+
|
|
119
|
+
deploy:
|
|
120
|
+
stage: deploy
|
|
121
|
+
needs:
|
|
122
|
+
- nonexistent
|
|
123
|
+
script:
|
|
124
|
+
- ./deploy.sh`;
|
|
125
|
+
const diags = checkInvalidNeeds(makeCtx(yaml));
|
|
126
|
+
expect(diags).toHaveLength(1);
|
|
127
|
+
expect(diags[0].entity).toBe("deploy");
|
|
128
|
+
expect(diags[0].message).toContain("nonexistent");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("skips check when include: is present", () => {
|
|
132
|
+
const yaml = `include:
|
|
133
|
+
- local: .gitlab/ci/templates.yml
|
|
134
|
+
|
|
135
|
+
stages:
|
|
136
|
+
- test
|
|
137
|
+
|
|
138
|
+
test:
|
|
139
|
+
stage: test
|
|
140
|
+
needs:
|
|
141
|
+
- from-included-file
|
|
142
|
+
script:
|
|
143
|
+
- npm test`;
|
|
144
|
+
const diags = checkInvalidNeeds(makeCtx(yaml));
|
|
145
|
+
expect(diags).toHaveLength(0);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("multiple invalid needs → multiple diagnostics", () => {
|
|
149
|
+
const yaml = `stages:
|
|
150
|
+
- build
|
|
151
|
+
|
|
152
|
+
build:
|
|
153
|
+
stage: build
|
|
154
|
+
needs:
|
|
155
|
+
- ghost1
|
|
156
|
+
- ghost2
|
|
157
|
+
script:
|
|
158
|
+
- npm run build`;
|
|
159
|
+
const diags = checkInvalidNeeds(makeCtx(yaml));
|
|
160
|
+
expect(diags).toHaveLength(2);
|
|
161
|
+
expect(diags[0].message).toContain("ghost1");
|
|
162
|
+
expect(diags[1].message).toContain("ghost2");
|
|
163
|
+
});
|
|
164
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WGL013: Invalid `needs:` Target
|
|
3
|
+
*
|
|
4
|
+
* Detects two cases in the serialized YAML:
|
|
5
|
+
* - Dangling reference: `needs:` names a job not defined in the pipeline
|
|
6
|
+
* - Self-reference: job lists itself in `needs:`
|
|
7
|
+
*
|
|
8
|
+
* Both cause GitLab pipeline validation failures.
|
|
9
|
+
*
|
|
10
|
+
* Caveat: when `include:` is present, referenced jobs may come from
|
|
11
|
+
* included files, so the check is skipped.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
15
|
+
import { getPrimaryOutput, extractJobs, hasInclude } from "./yaml-helpers";
|
|
16
|
+
|
|
17
|
+
export function checkInvalidNeeds(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
18
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
19
|
+
|
|
20
|
+
for (const [, output] of ctx.outputs) {
|
|
21
|
+
const yaml = getPrimaryOutput(output);
|
|
22
|
+
if (hasInclude(yaml)) continue;
|
|
23
|
+
|
|
24
|
+
const jobs = extractJobs(yaml);
|
|
25
|
+
const jobNames = new Set(jobs.keys());
|
|
26
|
+
|
|
27
|
+
for (const [jobName, job] of jobs) {
|
|
28
|
+
if (!job.needs) continue;
|
|
29
|
+
|
|
30
|
+
for (const need of job.needs) {
|
|
31
|
+
if (need === jobName) {
|
|
32
|
+
diagnostics.push({
|
|
33
|
+
checkId: "WGL013",
|
|
34
|
+
severity: "error",
|
|
35
|
+
message: `Job "${jobName}" lists itself in needs: — self-references are invalid`,
|
|
36
|
+
entity: jobName,
|
|
37
|
+
lexicon: "gitlab",
|
|
38
|
+
});
|
|
39
|
+
} else if (!jobNames.has(need)) {
|
|
40
|
+
diagnostics.push({
|
|
41
|
+
checkId: "WGL013",
|
|
42
|
+
severity: "error",
|
|
43
|
+
message: `Job "${jobName}" needs "${need}" which is not defined in the pipeline`,
|
|
44
|
+
entity: jobName,
|
|
45
|
+
lexicon: "gitlab",
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return diagnostics;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const wgl013: PostSynthCheck = {
|
|
56
|
+
id: "WGL013",
|
|
57
|
+
description: "Invalid needs: target — dangling reference or self-reference",
|
|
58
|
+
|
|
59
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
60
|
+
return checkInvalidNeeds(ctx);
|
|
61
|
+
},
|
|
62
|
+
};
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
|
|
3
|
+
import { wgl014, checkInvalidExtends } from "./wgl014";
|
|
4
|
+
|
|
5
|
+
function makeCtx(yaml: string): PostSynthContext {
|
|
6
|
+
return {
|
|
7
|
+
outputs: new Map([["gitlab", yaml]]),
|
|
8
|
+
entities: new Map(),
|
|
9
|
+
buildResult: {
|
|
10
|
+
outputs: new Map([["gitlab", yaml]]),
|
|
11
|
+
entities: new Map(),
|
|
12
|
+
warnings: [],
|
|
13
|
+
errors: [],
|
|
14
|
+
sourceFileCount: 1,
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe("WGL014: Invalid extends: Target", () => {
|
|
20
|
+
test("check metadata", () => {
|
|
21
|
+
expect(wgl014.id).toBe("WGL014");
|
|
22
|
+
expect(wgl014.description).toContain("extends:");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("extends nonexistent template → error", () => {
|
|
26
|
+
const yaml = `stages:
|
|
27
|
+
- deploy
|
|
28
|
+
|
|
29
|
+
deploy:
|
|
30
|
+
stage: deploy
|
|
31
|
+
extends: .nonexistent-template
|
|
32
|
+
script:
|
|
33
|
+
- ./deploy.sh`;
|
|
34
|
+
const diags = checkInvalidExtends(makeCtx(yaml));
|
|
35
|
+
expect(diags).toHaveLength(1);
|
|
36
|
+
expect(diags[0].checkId).toBe("WGL014");
|
|
37
|
+
expect(diags[0].severity).toBe("error");
|
|
38
|
+
expect(diags[0].message).toContain(".nonexistent-template");
|
|
39
|
+
expect(diags[0].message).toContain("not defined");
|
|
40
|
+
expect(diags[0].entity).toBe("deploy");
|
|
41
|
+
expect(diags[0].lexicon).toBe("gitlab");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("extends existing hidden job → no diagnostic", () => {
|
|
45
|
+
const yaml = `.deploy-template:
|
|
46
|
+
image: alpine
|
|
47
|
+
script:
|
|
48
|
+
- echo "template"
|
|
49
|
+
|
|
50
|
+
deploy:
|
|
51
|
+
stage: deploy
|
|
52
|
+
extends: .deploy-template
|
|
53
|
+
script:
|
|
54
|
+
- ./deploy.sh`;
|
|
55
|
+
const diags = checkInvalidExtends(makeCtx(yaml));
|
|
56
|
+
expect(diags).toHaveLength(0);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("no extends → no diagnostic", () => {
|
|
60
|
+
const yaml = `stages:
|
|
61
|
+
- build
|
|
62
|
+
|
|
63
|
+
build:
|
|
64
|
+
stage: build
|
|
65
|
+
script:
|
|
66
|
+
- npm run build`;
|
|
67
|
+
const diags = checkInvalidExtends(makeCtx(yaml));
|
|
68
|
+
expect(diags).toHaveLength(0);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("skips check when include: is present", () => {
|
|
72
|
+
const yaml = `include:
|
|
73
|
+
- local: .gitlab/ci/templates.yml
|
|
74
|
+
|
|
75
|
+
deploy:
|
|
76
|
+
stage: deploy
|
|
77
|
+
extends: .deploy-template
|
|
78
|
+
script:
|
|
79
|
+
- ./deploy.sh`;
|
|
80
|
+
const diags = checkInvalidExtends(makeCtx(yaml));
|
|
81
|
+
expect(diags).toHaveLength(0);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("multiple extends targets, one invalid → 1 diagnostic", () => {
|
|
85
|
+
const yaml = `.base:
|
|
86
|
+
image: alpine
|
|
87
|
+
|
|
88
|
+
deploy:
|
|
89
|
+
stage: deploy
|
|
90
|
+
extends: [.base, .missing]
|
|
91
|
+
script:
|
|
92
|
+
- ./deploy.sh`;
|
|
93
|
+
const diags = checkInvalidExtends(makeCtx(yaml));
|
|
94
|
+
expect(diags).toHaveLength(1);
|
|
95
|
+
expect(diags[0].message).toContain(".missing");
|
|
96
|
+
});
|
|
97
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WGL014: Invalid `extends:` Target
|
|
3
|
+
*
|
|
4
|
+
* Detects jobs that `extends:` a template not defined in the pipeline YAML.
|
|
5
|
+
* GitLab rejects pipelines with unresolved extends references.
|
|
6
|
+
*
|
|
7
|
+
* Caveat: when `include:` is present, templates may come from
|
|
8
|
+
* included files, so the check is skipped.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
12
|
+
import { getPrimaryOutput, extractJobs, hasInclude } from "./yaml-helpers";
|
|
13
|
+
|
|
14
|
+
export function checkInvalidExtends(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
15
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
16
|
+
|
|
17
|
+
for (const [, output] of ctx.outputs) {
|
|
18
|
+
const yaml = getPrimaryOutput(output);
|
|
19
|
+
if (hasInclude(yaml)) continue;
|
|
20
|
+
|
|
21
|
+
const jobs = extractJobs(yaml);
|
|
22
|
+
const jobNames = new Set(jobs.keys());
|
|
23
|
+
|
|
24
|
+
for (const [jobName, job] of jobs) {
|
|
25
|
+
if (!job.extends) continue;
|
|
26
|
+
|
|
27
|
+
for (const target of job.extends) {
|
|
28
|
+
if (!jobNames.has(target)) {
|
|
29
|
+
diagnostics.push({
|
|
30
|
+
checkId: "WGL014",
|
|
31
|
+
severity: "error",
|
|
32
|
+
message: `Job "${jobName}" extends "${target}" which is not defined in the pipeline`,
|
|
33
|
+
entity: jobName,
|
|
34
|
+
lexicon: "gitlab",
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return diagnostics;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const wgl014: PostSynthCheck = {
|
|
45
|
+
id: "WGL014",
|
|
46
|
+
description: "Invalid extends: target — references a template not in the pipeline",
|
|
47
|
+
|
|
48
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
49
|
+
return checkInvalidExtends(ctx);
|
|
50
|
+
},
|
|
51
|
+
};
|