@intentius/chant-lexicon-gitlab 0.0.1
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/package.json +27 -0
- package/src/codegen/__snapshots__/snapshot.test.ts.snap +33 -0
- package/src/codegen/docs-cli.ts +3 -0
- package/src/codegen/docs.ts +962 -0
- package/src/codegen/fetch.ts +73 -0
- package/src/codegen/generate-cli.ts +41 -0
- package/src/codegen/generate-lexicon.ts +53 -0
- package/src/codegen/generate-typescript.ts +144 -0
- package/src/codegen/generate.ts +166 -0
- package/src/codegen/naming.ts +52 -0
- package/src/codegen/package.ts +64 -0
- package/src/codegen/parse.test.ts +195 -0
- package/src/codegen/parse.ts +531 -0
- package/src/codegen/patches.test.ts +99 -0
- package/src/codegen/patches.ts +100 -0
- package/src/codegen/rollback.ts +26 -0
- package/src/codegen/snapshot.test.ts +109 -0
- package/src/coverage.test.ts +39 -0
- package/src/coverage.ts +52 -0
- package/src/generated/index.d.ts +248 -0
- package/src/generated/index.ts +23 -0
- package/src/generated/lexicon-gitlab.json +77 -0
- package/src/generated/runtime.ts +4 -0
- package/src/import/generator.test.ts +151 -0
- package/src/import/generator.ts +173 -0
- package/src/import/parser.test.ts +160 -0
- package/src/import/parser.ts +282 -0
- package/src/import/roundtrip.test.ts +89 -0
- package/src/index.ts +25 -0
- package/src/intrinsics.test.ts +42 -0
- package/src/intrinsics.ts +40 -0
- package/src/lint/post-synth/post-synth.test.ts +155 -0
- package/src/lint/post-synth/wgl010.ts +41 -0
- package/src/lint/post-synth/wgl011.ts +54 -0
- package/src/lint/post-synth/yaml-helpers.ts +88 -0
- package/src/lint/rules/artifact-no-expiry.ts +62 -0
- package/src/lint/rules/deprecated-only-except.ts +53 -0
- package/src/lint/rules/index.ts +8 -0
- package/src/lint/rules/missing-script.ts +65 -0
- package/src/lint/rules/missing-stage.ts +62 -0
- package/src/lint/rules/rules.test.ts +146 -0
- package/src/lsp/completions.test.ts +85 -0
- package/src/lsp/completions.ts +18 -0
- package/src/lsp/hover.test.ts +60 -0
- package/src/lsp/hover.ts +36 -0
- package/src/plugin.test.ts +228 -0
- package/src/plugin.ts +380 -0
- package/src/serializer.test.ts +309 -0
- package/src/serializer.ts +226 -0
- package/src/testdata/ci-schema-fixture.json +2184 -0
- package/src/testdata/create-fixture.ts +46 -0
- package/src/testdata/load-fixtures.ts +23 -0
- package/src/validate-cli.ts +19 -0
- package/src/validate.test.ts +43 -0
- package/src/validate.ts +125 -0
- package/src/variables.ts +27 -0
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitLab CI YAML parser for `chant import`.
|
|
3
|
+
*
|
|
4
|
+
* Parses an existing .gitlab-ci.yml file into the core TemplateIR format,
|
|
5
|
+
* mapping GitLab CI constructs to resources and property types.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { TemplateParser, TemplateIR, ResourceIR } from "@intentius/chant/import/parser";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Reserved top-level keys in .gitlab-ci.yml that are NOT job definitions.
|
|
12
|
+
*/
|
|
13
|
+
const RESERVED_KEYS = new Set([
|
|
14
|
+
"stages",
|
|
15
|
+
"variables",
|
|
16
|
+
"default",
|
|
17
|
+
"workflow",
|
|
18
|
+
"include",
|
|
19
|
+
"image",
|
|
20
|
+
"services",
|
|
21
|
+
"before_script",
|
|
22
|
+
"after_script",
|
|
23
|
+
"cache",
|
|
24
|
+
"pages",
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Map snake_case GitLab CI keys to camelCase for Chant properties.
|
|
29
|
+
*/
|
|
30
|
+
function toCamelCase(name: string): string {
|
|
31
|
+
return name.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Recursively convert snake_case keys in an object to camelCase.
|
|
36
|
+
*/
|
|
37
|
+
function camelCaseKeys(value: unknown): unknown {
|
|
38
|
+
if (value === null || value === undefined) return value;
|
|
39
|
+
if (Array.isArray(value)) return value.map(camelCaseKeys);
|
|
40
|
+
if (typeof value === "object") {
|
|
41
|
+
const result: Record<string, unknown> = {};
|
|
42
|
+
for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
|
|
43
|
+
result[toCamelCase(key)] = camelCaseKeys(val);
|
|
44
|
+
}
|
|
45
|
+
return result;
|
|
46
|
+
}
|
|
47
|
+
return value;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Parse a YAML document into a plain object.
|
|
52
|
+
* Uses a simple YAML parser approach — GitLab CI YAML is straightforward
|
|
53
|
+
* enough that we can parse it without a full YAML library by parsing JSON
|
|
54
|
+
* or using Bun's built-in YAML support if available.
|
|
55
|
+
*/
|
|
56
|
+
function parseYAML(content: string): Record<string, unknown> {
|
|
57
|
+
// Try JSON first (some CI files may be JSON)
|
|
58
|
+
try {
|
|
59
|
+
return JSON.parse(content);
|
|
60
|
+
} catch {
|
|
61
|
+
// Fall through to YAML parsing
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Simple YAML parser for GitLab CI files
|
|
65
|
+
// This handles the common cases: scalars, arrays, objects, block scalars
|
|
66
|
+
const lines = content.split("\n");
|
|
67
|
+
return parseYAMLLines(lines, 0, 0).value as Record<string, unknown>;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface ParseResult {
|
|
71
|
+
value: unknown;
|
|
72
|
+
endIndex: number;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function parseYAMLLines(lines: string[], startIndex: number, baseIndent: number): ParseResult {
|
|
76
|
+
const result: Record<string, unknown> = {};
|
|
77
|
+
let i = startIndex;
|
|
78
|
+
|
|
79
|
+
while (i < lines.length) {
|
|
80
|
+
const line = lines[i];
|
|
81
|
+
// Skip empty lines and comments
|
|
82
|
+
if (line.trim() === "" || line.trim().startsWith("#")) {
|
|
83
|
+
i++;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const indent = line.search(/\S/);
|
|
88
|
+
if (indent < baseIndent) break; // Dedented — done with this block
|
|
89
|
+
if (indent > baseIndent && startIndex > 0) break; // Unexpected indent
|
|
90
|
+
|
|
91
|
+
const keyMatch = line.match(/^(\s*)([^\s:][^:]*?):\s*(.*)$/);
|
|
92
|
+
if (keyMatch) {
|
|
93
|
+
const key = keyMatch[2].trim();
|
|
94
|
+
const inlineValue = keyMatch[3].trim();
|
|
95
|
+
|
|
96
|
+
if (inlineValue === "" || inlineValue.startsWith("#")) {
|
|
97
|
+
// Check next line for array or nested object
|
|
98
|
+
if (i + 1 < lines.length) {
|
|
99
|
+
const nextLine = lines[i + 1];
|
|
100
|
+
const nextIndent = nextLine.search(/\S/);
|
|
101
|
+
if (nextIndent > indent && nextLine.trimStart().startsWith("- ")) {
|
|
102
|
+
// Array
|
|
103
|
+
const arr = parseYAMLArray(lines, i + 1, nextIndent);
|
|
104
|
+
result[key] = arr.value;
|
|
105
|
+
i = arr.endIndex;
|
|
106
|
+
continue;
|
|
107
|
+
} else if (nextIndent > indent) {
|
|
108
|
+
// Nested object
|
|
109
|
+
const nested = parseYAMLLines(lines, i + 1, nextIndent);
|
|
110
|
+
result[key] = nested.value;
|
|
111
|
+
i = nested.endIndex;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
result[key] = null;
|
|
116
|
+
i++;
|
|
117
|
+
} else if (inlineValue.startsWith("[")) {
|
|
118
|
+
// Inline array
|
|
119
|
+
try {
|
|
120
|
+
result[key] = JSON.parse(inlineValue);
|
|
121
|
+
} catch {
|
|
122
|
+
result[key] = inlineValue;
|
|
123
|
+
}
|
|
124
|
+
i++;
|
|
125
|
+
} else if (inlineValue.startsWith("{")) {
|
|
126
|
+
// Inline object
|
|
127
|
+
try {
|
|
128
|
+
result[key] = JSON.parse(inlineValue);
|
|
129
|
+
} catch {
|
|
130
|
+
result[key] = inlineValue;
|
|
131
|
+
}
|
|
132
|
+
i++;
|
|
133
|
+
} else {
|
|
134
|
+
result[key] = parseScalar(inlineValue);
|
|
135
|
+
i++;
|
|
136
|
+
}
|
|
137
|
+
} else if (line.trimStart().startsWith("- ")) {
|
|
138
|
+
// We hit an array at the top level — shouldn't happen normally
|
|
139
|
+
break;
|
|
140
|
+
} else {
|
|
141
|
+
i++;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return { value: result, endIndex: i };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function parseYAMLArray(lines: string[], startIndex: number, baseIndent: number): ParseResult {
|
|
149
|
+
const result: unknown[] = [];
|
|
150
|
+
let i = startIndex;
|
|
151
|
+
|
|
152
|
+
while (i < lines.length) {
|
|
153
|
+
const line = lines[i];
|
|
154
|
+
if (line.trim() === "" || line.trim().startsWith("#")) {
|
|
155
|
+
i++;
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const indent = line.search(/\S/);
|
|
160
|
+
if (indent < baseIndent) break;
|
|
161
|
+
|
|
162
|
+
const itemMatch = line.match(/^(\s*)- (.*)$/);
|
|
163
|
+
if (itemMatch && indent === baseIndent) {
|
|
164
|
+
const itemValue = itemMatch[2].trim();
|
|
165
|
+
// Check if it's a key-value pair (object item in array)
|
|
166
|
+
const kvMatch = itemValue.match(/^([^\s:][^:]*?):\s*(.*)$/);
|
|
167
|
+
if (kvMatch) {
|
|
168
|
+
const obj: Record<string, unknown> = {};
|
|
169
|
+
obj[kvMatch[1].trim()] = parseScalar(kvMatch[2].trim());
|
|
170
|
+
// Check for more keys at indent+2
|
|
171
|
+
const nextIndent = indent + 2;
|
|
172
|
+
let j = i + 1;
|
|
173
|
+
while (j < lines.length) {
|
|
174
|
+
const nextLine = lines[j];
|
|
175
|
+
if (nextLine.trim() === "" || nextLine.trim().startsWith("#")) {
|
|
176
|
+
j++;
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
const ni = nextLine.search(/\S/);
|
|
180
|
+
if (ni !== nextIndent) break;
|
|
181
|
+
const nextKV = nextLine.match(/^(\s*)([^\s:][^:]*?):\s*(.*)$/);
|
|
182
|
+
if (nextKV) {
|
|
183
|
+
obj[nextKV[2].trim()] = parseScalar(nextKV[3].trim());
|
|
184
|
+
j++;
|
|
185
|
+
} else {
|
|
186
|
+
break;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
result.push(obj);
|
|
190
|
+
i = j;
|
|
191
|
+
} else {
|
|
192
|
+
result.push(parseScalar(itemValue));
|
|
193
|
+
i++;
|
|
194
|
+
}
|
|
195
|
+
} else {
|
|
196
|
+
break;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return { value: result, endIndex: i };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function parseScalar(value: string): unknown {
|
|
204
|
+
if (value === "" || value === "~" || value === "null") return null;
|
|
205
|
+
if (value === "true" || value === "yes") return true;
|
|
206
|
+
if (value === "false" || value === "no") return false;
|
|
207
|
+
// Strip quotes
|
|
208
|
+
if ((value.startsWith("'") && value.endsWith("'")) || (value.startsWith('"') && value.endsWith('"'))) {
|
|
209
|
+
return value.slice(1, -1);
|
|
210
|
+
}
|
|
211
|
+
// Number
|
|
212
|
+
const num = Number(value);
|
|
213
|
+
if (!isNaN(num) && value !== "") return num;
|
|
214
|
+
return value;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* GitLab CI YAML parser implementation.
|
|
219
|
+
*/
|
|
220
|
+
export class GitLabParser implements TemplateParser {
|
|
221
|
+
parse(content: string): TemplateIR {
|
|
222
|
+
const doc = parseYAML(content);
|
|
223
|
+
const resources: ResourceIR[] = [];
|
|
224
|
+
|
|
225
|
+
// Extract default
|
|
226
|
+
if (doc.default && typeof doc.default === "object") {
|
|
227
|
+
resources.push({
|
|
228
|
+
logicalId: "defaults",
|
|
229
|
+
type: "GitLab::CI::Default",
|
|
230
|
+
properties: camelCaseKeys(doc.default) as Record<string, unknown>,
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Extract workflow
|
|
235
|
+
if (doc.workflow && typeof doc.workflow === "object") {
|
|
236
|
+
resources.push({
|
|
237
|
+
logicalId: "workflow",
|
|
238
|
+
type: "GitLab::CI::Workflow",
|
|
239
|
+
properties: camelCaseKeys(doc.workflow) as Record<string, unknown>,
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Extract jobs — any top-level key not in RESERVED_KEYS
|
|
244
|
+
for (const [key, value] of Object.entries(doc)) {
|
|
245
|
+
if (RESERVED_KEYS.has(key)) continue;
|
|
246
|
+
if (typeof value !== "object" || value === null) continue;
|
|
247
|
+
|
|
248
|
+
// Check if it looks like a job (has script, stage, trigger, extends, etc.)
|
|
249
|
+
const obj = value as Record<string, unknown>;
|
|
250
|
+
if (
|
|
251
|
+
obj.script !== undefined ||
|
|
252
|
+
obj.stage !== undefined ||
|
|
253
|
+
obj.trigger !== undefined ||
|
|
254
|
+
obj.extends !== undefined ||
|
|
255
|
+
obj.rules !== undefined ||
|
|
256
|
+
obj.needs !== undefined
|
|
257
|
+
) {
|
|
258
|
+
resources.push({
|
|
259
|
+
logicalId: toCamelCase(key.replace(/-/g, "_")),
|
|
260
|
+
type: "GitLab::CI::Job",
|
|
261
|
+
properties: camelCaseKeys(obj) as Record<string, unknown>,
|
|
262
|
+
metadata: {
|
|
263
|
+
originalName: key,
|
|
264
|
+
stage: typeof obj.stage === "string" ? obj.stage : undefined,
|
|
265
|
+
},
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Record include references as metadata
|
|
271
|
+
const metadata: Record<string, unknown> = {};
|
|
272
|
+
if (doc.stages) metadata.stages = doc.stages;
|
|
273
|
+
if (doc.include) metadata.include = doc.include;
|
|
274
|
+
if (doc.variables) metadata.variables = doc.variables;
|
|
275
|
+
|
|
276
|
+
return {
|
|
277
|
+
resources,
|
|
278
|
+
parameters: [], // GitLab CI doesn't have parameters like CFN
|
|
279
|
+
metadata: Object.keys(metadata).length > 0 ? metadata : undefined,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { GitLabParser } from "./parser";
|
|
3
|
+
import { GitLabGenerator } from "./generator";
|
|
4
|
+
|
|
5
|
+
const parser = new GitLabParser();
|
|
6
|
+
const generator = new GitLabGenerator();
|
|
7
|
+
|
|
8
|
+
describe("roundtrip: parse → generate", () => {
|
|
9
|
+
test("simple pipeline roundtrip", () => {
|
|
10
|
+
const yaml = `
|
|
11
|
+
stages:
|
|
12
|
+
- test
|
|
13
|
+
|
|
14
|
+
test-job:
|
|
15
|
+
stage: test
|
|
16
|
+
script:
|
|
17
|
+
- npm test
|
|
18
|
+
`;
|
|
19
|
+
const ir = parser.parse(yaml);
|
|
20
|
+
const files = generator.generate(ir);
|
|
21
|
+
|
|
22
|
+
expect(files).toHaveLength(1);
|
|
23
|
+
const content = files[0].content;
|
|
24
|
+
|
|
25
|
+
// Should produce valid-looking TypeScript
|
|
26
|
+
expect(content).toContain("import");
|
|
27
|
+
expect(content).toContain("export const");
|
|
28
|
+
expect(content).toContain("new Job(");
|
|
29
|
+
expect(content).toContain('"test"');
|
|
30
|
+
expect(content).toContain("npm test");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("multi-job pipeline roundtrip", () => {
|
|
34
|
+
const yaml = `
|
|
35
|
+
stages:
|
|
36
|
+
- build
|
|
37
|
+
- test
|
|
38
|
+
- deploy
|
|
39
|
+
|
|
40
|
+
build-app:
|
|
41
|
+
stage: build
|
|
42
|
+
script:
|
|
43
|
+
- npm ci
|
|
44
|
+
- npm run build
|
|
45
|
+
|
|
46
|
+
run-tests:
|
|
47
|
+
stage: test
|
|
48
|
+
script:
|
|
49
|
+
- npm test
|
|
50
|
+
|
|
51
|
+
deploy-prod:
|
|
52
|
+
stage: deploy
|
|
53
|
+
script:
|
|
54
|
+
- deploy.sh
|
|
55
|
+
`;
|
|
56
|
+
const ir = parser.parse(yaml);
|
|
57
|
+
expect(ir.resources).toHaveLength(3);
|
|
58
|
+
|
|
59
|
+
const files = generator.generate(ir);
|
|
60
|
+
const content = files[0].content;
|
|
61
|
+
|
|
62
|
+
expect(content).toContain("buildApp");
|
|
63
|
+
expect(content).toContain("runTests");
|
|
64
|
+
expect(content).toContain("deployProd");
|
|
65
|
+
expect(content).toContain("Pipeline stages: build, test, deploy");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("pipeline with defaults and workflow roundtrip", () => {
|
|
69
|
+
const yaml = `
|
|
70
|
+
default:
|
|
71
|
+
interruptible: true
|
|
72
|
+
|
|
73
|
+
workflow:
|
|
74
|
+
name: CI
|
|
75
|
+
|
|
76
|
+
test-job:
|
|
77
|
+
stage: test
|
|
78
|
+
script:
|
|
79
|
+
- test
|
|
80
|
+
`;
|
|
81
|
+
const ir = parser.parse(yaml);
|
|
82
|
+
const files = generator.generate(ir);
|
|
83
|
+
const content = files[0].content;
|
|
84
|
+
|
|
85
|
+
expect(content).toContain("new Default(");
|
|
86
|
+
expect(content).toContain("new Workflow(");
|
|
87
|
+
expect(content).toContain("new Job(");
|
|
88
|
+
});
|
|
89
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Serializer
|
|
2
|
+
export { gitlabSerializer } from "./serializer";
|
|
3
|
+
|
|
4
|
+
// Plugin
|
|
5
|
+
export { gitlabPlugin } from "./plugin";
|
|
6
|
+
|
|
7
|
+
// Intrinsics
|
|
8
|
+
export { reference, ReferenceIntrinsic } from "./intrinsics";
|
|
9
|
+
|
|
10
|
+
// CI/CD Variables
|
|
11
|
+
export { CI } from "./variables";
|
|
12
|
+
|
|
13
|
+
// Generated entities — export everything from generated index
|
|
14
|
+
// After running `chant generate`, this re-exports all CI entity classes
|
|
15
|
+
export * from "./generated/index";
|
|
16
|
+
|
|
17
|
+
// Spec utilities (for tooling)
|
|
18
|
+
export { fetchCISchema, fetchSchemas, GITLAB_SCHEMA_VERSION } from "./codegen/fetch";
|
|
19
|
+
export { parseCISchema, gitlabShortName, gitlabServiceName } from "./codegen/parse";
|
|
20
|
+
export type { GitLabParseResult, ParsedResource, ParsedProperty, ParsedPropertyType, ParsedEnum } from "./codegen/parse";
|
|
21
|
+
|
|
22
|
+
// Code generation pipeline
|
|
23
|
+
export { generate, writeGeneratedFiles } from "./codegen/generate";
|
|
24
|
+
export { packageLexicon } from "./codegen/package";
|
|
25
|
+
export type { PackageOptions, PackageResult } from "./codegen/package";
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { reference, ReferenceIntrinsic } from "./intrinsics";
|
|
3
|
+
import { INTRINSIC_MARKER } from "@intentius/chant/intrinsic";
|
|
4
|
+
|
|
5
|
+
describe("reference intrinsic", () => {
|
|
6
|
+
test("creates a ReferenceIntrinsic instance", () => {
|
|
7
|
+
const ref = reference("job_name", "script");
|
|
8
|
+
expect(ref).toBeInstanceOf(ReferenceIntrinsic);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("has INTRINSIC_MARKER set to true", () => {
|
|
12
|
+
const ref = reference("job_name", "script");
|
|
13
|
+
expect(ref[INTRINSIC_MARKER]).toBe(true);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("toJSON returns the path array", () => {
|
|
17
|
+
const ref = reference("job_name", "script");
|
|
18
|
+
expect(ref.toJSON()).toEqual(["job_name", "script"]);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("toJSON returns single-element path", () => {
|
|
22
|
+
const ref = reference(".setup");
|
|
23
|
+
expect(ref.toJSON()).toEqual([".setup"]);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("toJSON returns multi-element path", () => {
|
|
27
|
+
const ref = reference(".base", "before_script", "0");
|
|
28
|
+
expect(ref.toJSON()).toEqual([".base", "before_script", "0"]);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("toYAML returns !reference tag with path", () => {
|
|
32
|
+
const ref = reference("job_name", "script");
|
|
33
|
+
const yaml = ref.toYAML();
|
|
34
|
+
expect(yaml.tag).toBe("!reference");
|
|
35
|
+
expect(yaml.value).toEqual(["job_name", "script"]);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("empty path produces empty array", () => {
|
|
39
|
+
const ref = reference();
|
|
40
|
+
expect(ref.toJSON()).toEqual([]);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitLab CI/CD intrinsic functions.
|
|
3
|
+
*
|
|
4
|
+
* GitLab CI has a single intrinsic: the !reference tag.
|
|
5
|
+
* CI/CD variables ($CI_*) are just strings, not intrinsic functions.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { INTRINSIC_MARKER, type Intrinsic } from "@intentius/chant/intrinsic";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* !reference tag intrinsic.
|
|
12
|
+
* References another job's properties: !reference [job_name, key]
|
|
13
|
+
*/
|
|
14
|
+
export class ReferenceIntrinsic implements Intrinsic {
|
|
15
|
+
readonly [INTRINSIC_MARKER] = true as const;
|
|
16
|
+
private path: string[];
|
|
17
|
+
|
|
18
|
+
constructor(...path: string[]) {
|
|
19
|
+
this.path = path;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
toJSON(): string[] {
|
|
23
|
+
return this.path;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* YAML representation uses the !reference tag.
|
|
28
|
+
*/
|
|
29
|
+
toYAML(): { tag: "!reference"; value: string[] } {
|
|
30
|
+
return { tag: "!reference", value: this.path };
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Create a !reference intrinsic.
|
|
36
|
+
* Usage: reference("job_name", "script") → !reference [job_name, script]
|
|
37
|
+
*/
|
|
38
|
+
export function reference(...path: string[]): ReferenceIntrinsic {
|
|
39
|
+
return new ReferenceIntrinsic(...path);
|
|
40
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
|
|
3
|
+
import { DECLARABLE_MARKER, type Declarable } from "@intentius/chant/declarable";
|
|
4
|
+
import { wgl010 } from "./wgl010";
|
|
5
|
+
import { wgl011 } from "./wgl011";
|
|
6
|
+
|
|
7
|
+
// ── Helpers ─────────────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
class MockJob implements Declarable {
|
|
10
|
+
readonly [DECLARABLE_MARKER] = true as const;
|
|
11
|
+
readonly lexicon = "gitlab";
|
|
12
|
+
readonly entityType = "GitLab::CI::Job";
|
|
13
|
+
readonly kind = "resource" as const;
|
|
14
|
+
readonly props: Record<string, unknown>;
|
|
15
|
+
|
|
16
|
+
constructor(props: Record<string, unknown> = {}) {
|
|
17
|
+
this.props = props;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function makeCtx(
|
|
22
|
+
yaml: string,
|
|
23
|
+
entities: Map<string, Declarable> = new Map(),
|
|
24
|
+
): PostSynthContext {
|
|
25
|
+
return {
|
|
26
|
+
outputs: new Map([["gitlab", yaml]]),
|
|
27
|
+
entities,
|
|
28
|
+
buildResult: {
|
|
29
|
+
outputs: new Map([["gitlab", yaml]]),
|
|
30
|
+
entities,
|
|
31
|
+
warnings: [],
|
|
32
|
+
errors: [],
|
|
33
|
+
sourceFileCount: 1,
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ── WGL010: Undefined stage ─────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
describe("WGL010: undefined stage", () => {
|
|
41
|
+
test("flags job referencing undefined stage", () => {
|
|
42
|
+
const yaml = `stages:
|
|
43
|
+
- build
|
|
44
|
+
- test
|
|
45
|
+
|
|
46
|
+
deploy-app:
|
|
47
|
+
stage: deploy
|
|
48
|
+
script:
|
|
49
|
+
- deploy.sh
|
|
50
|
+
`;
|
|
51
|
+
const diags = wgl010.check(makeCtx(yaml));
|
|
52
|
+
expect(diags).toHaveLength(1);
|
|
53
|
+
expect(diags[0].checkId).toBe("WGL010");
|
|
54
|
+
expect(diags[0].severity).toBe("error");
|
|
55
|
+
expect(diags[0].message).toContain("deploy");
|
|
56
|
+
expect(diags[0].message).toContain("deploy-app");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("does not flag job with valid stage", () => {
|
|
60
|
+
const yaml = `stages:
|
|
61
|
+
- build
|
|
62
|
+
- test
|
|
63
|
+
|
|
64
|
+
test-job:
|
|
65
|
+
stage: test
|
|
66
|
+
script:
|
|
67
|
+
- npm test
|
|
68
|
+
`;
|
|
69
|
+
const diags = wgl010.check(makeCtx(yaml));
|
|
70
|
+
expect(diags).toHaveLength(0);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("does not flag when no stages are defined", () => {
|
|
74
|
+
const yaml = `test-job:
|
|
75
|
+
script:
|
|
76
|
+
- npm test
|
|
77
|
+
`;
|
|
78
|
+
const diags = wgl010.check(makeCtx(yaml));
|
|
79
|
+
expect(diags).toHaveLength(0);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("flags multiple jobs with undefined stages", () => {
|
|
83
|
+
const yaml = `stages:
|
|
84
|
+
- build
|
|
85
|
+
|
|
86
|
+
deploy-job:
|
|
87
|
+
stage: deploy
|
|
88
|
+
script:
|
|
89
|
+
- deploy.sh
|
|
90
|
+
|
|
91
|
+
release-job:
|
|
92
|
+
stage: release
|
|
93
|
+
script:
|
|
94
|
+
- release.sh
|
|
95
|
+
`;
|
|
96
|
+
const diags = wgl010.check(makeCtx(yaml));
|
|
97
|
+
expect(diags).toHaveLength(2);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// ── WGL011: Unreachable job ─────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
describe("WGL011: unreachable job", () => {
|
|
104
|
+
test("flags job where all rules have when: never", () => {
|
|
105
|
+
const entities = new Map<string, Declarable>();
|
|
106
|
+
entities.set("neverJob", new MockJob({
|
|
107
|
+
script: ["test"],
|
|
108
|
+
rules: [
|
|
109
|
+
{ when: "never" },
|
|
110
|
+
{ when: "never" },
|
|
111
|
+
],
|
|
112
|
+
}));
|
|
113
|
+
|
|
114
|
+
const diags = wgl011.check(makeCtx("", entities));
|
|
115
|
+
expect(diags).toHaveLength(1);
|
|
116
|
+
expect(diags[0].checkId).toBe("WGL011");
|
|
117
|
+
expect(diags[0].severity).toBe("warning");
|
|
118
|
+
expect(diags[0].message).toContain("neverJob");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("does not flag job with reachable rules", () => {
|
|
122
|
+
const entities = new Map<string, Declarable>();
|
|
123
|
+
entities.set("okJob", new MockJob({
|
|
124
|
+
script: ["test"],
|
|
125
|
+
rules: [
|
|
126
|
+
{ if: "$CI_COMMIT_BRANCH", when: "always" },
|
|
127
|
+
{ when: "never" },
|
|
128
|
+
],
|
|
129
|
+
}));
|
|
130
|
+
|
|
131
|
+
const diags = wgl011.check(makeCtx("", entities));
|
|
132
|
+
expect(diags).toHaveLength(0);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("does not flag job without rules", () => {
|
|
136
|
+
const entities = new Map<string, Declarable>();
|
|
137
|
+
entities.set("simpleJob", new MockJob({
|
|
138
|
+
script: ["test"],
|
|
139
|
+
}));
|
|
140
|
+
|
|
141
|
+
const diags = wgl011.check(makeCtx("", entities));
|
|
142
|
+
expect(diags).toHaveLength(0);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("does not flag job with empty rules array", () => {
|
|
146
|
+
const entities = new Map<string, Declarable>();
|
|
147
|
+
entities.set("emptyRulesJob", new MockJob({
|
|
148
|
+
script: ["test"],
|
|
149
|
+
rules: [],
|
|
150
|
+
}));
|
|
151
|
+
|
|
152
|
+
const diags = wgl011.check(makeCtx("", entities));
|
|
153
|
+
expect(diags).toHaveLength(0);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WGL010: Undefined stage
|
|
3
|
+
*
|
|
4
|
+
* Detects jobs that reference a stage not declared in the `stages:` list.
|
|
5
|
+
* This will cause a pipeline validation error in GitLab.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
9
|
+
import { getPrimaryOutput, extractStages, extractJobs } from "./yaml-helpers";
|
|
10
|
+
|
|
11
|
+
export const wgl010: PostSynthCheck = {
|
|
12
|
+
id: "WGL010",
|
|
13
|
+
description: "Job references a stage not in the stages list",
|
|
14
|
+
|
|
15
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
16
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
17
|
+
|
|
18
|
+
for (const [, output] of ctx.outputs) {
|
|
19
|
+
const yaml = getPrimaryOutput(output);
|
|
20
|
+
const stages = extractStages(yaml);
|
|
21
|
+
if (stages.length === 0) continue; // No explicit stages — GitLab uses defaults
|
|
22
|
+
|
|
23
|
+
const stageSet = new Set(stages);
|
|
24
|
+
const jobs = extractJobs(yaml);
|
|
25
|
+
|
|
26
|
+
for (const [jobName, job] of jobs) {
|
|
27
|
+
if (job.stage && !stageSet.has(job.stage)) {
|
|
28
|
+
diagnostics.push({
|
|
29
|
+
checkId: "WGL010",
|
|
30
|
+
severity: "error",
|
|
31
|
+
message: `Job "${jobName}" references undefined stage "${job.stage}". Add it to the stages list.`,
|
|
32
|
+
entity: jobName,
|
|
33
|
+
lexicon: "gitlab",
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return diagnostics;
|
|
40
|
+
},
|
|
41
|
+
};
|