@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
package/src/plugin.ts
ADDED
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitLab CI/CD lexicon plugin.
|
|
3
|
+
*
|
|
4
|
+
* Provides serializer, template detection, and code generation
|
|
5
|
+
* for GitLab CI/CD pipelines.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { LexiconPlugin, IntrinsicDef, SkillDefinition } from "@intentius/chant/lexicon";
|
|
9
|
+
import type { LintRule } from "@intentius/chant/lint/rule";
|
|
10
|
+
import type { PostSynthCheck } from "@intentius/chant/lint/post-synth";
|
|
11
|
+
import { gitlabSerializer } from "./serializer";
|
|
12
|
+
|
|
13
|
+
export const gitlabPlugin: LexiconPlugin = {
|
|
14
|
+
name: "gitlab",
|
|
15
|
+
serializer: gitlabSerializer,
|
|
16
|
+
|
|
17
|
+
lintRules(): LintRule[] {
|
|
18
|
+
const { deprecatedOnlyExceptRule } = require("./lint/rules/deprecated-only-except");
|
|
19
|
+
const { missingScriptRule } = require("./lint/rules/missing-script");
|
|
20
|
+
const { missingStageRule } = require("./lint/rules/missing-stage");
|
|
21
|
+
const { artifactNoExpiryRule } = require("./lint/rules/artifact-no-expiry");
|
|
22
|
+
return [deprecatedOnlyExceptRule, missingScriptRule, missingStageRule, artifactNoExpiryRule];
|
|
23
|
+
},
|
|
24
|
+
|
|
25
|
+
postSynthChecks(): PostSynthCheck[] {
|
|
26
|
+
const { wgl010 } = require("./lint/post-synth/wgl010");
|
|
27
|
+
const { wgl011 } = require("./lint/post-synth/wgl011");
|
|
28
|
+
return [wgl010, wgl011];
|
|
29
|
+
},
|
|
30
|
+
|
|
31
|
+
intrinsics(): IntrinsicDef[] {
|
|
32
|
+
return [
|
|
33
|
+
{
|
|
34
|
+
name: "reference",
|
|
35
|
+
description: "!reference tag — reference another job's properties",
|
|
36
|
+
outputKey: "!reference",
|
|
37
|
+
isTag: true,
|
|
38
|
+
},
|
|
39
|
+
];
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
initTemplates(): Record<string, string> {
|
|
43
|
+
return {
|
|
44
|
+
"_.ts": `export * from "./config";\n`,
|
|
45
|
+
"config.ts": `/**
|
|
46
|
+
* Shared pipeline configuration
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
import * as gl from "@intentius/chant-lexicon-gitlab";
|
|
50
|
+
|
|
51
|
+
// Default image for all jobs
|
|
52
|
+
export const defaultImage = new gl.Image({
|
|
53
|
+
name: "node:20-alpine",
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// Standard cache configuration
|
|
57
|
+
export const npmCache = new gl.Cache({
|
|
58
|
+
key: "$CI_COMMIT_REF_SLUG",
|
|
59
|
+
paths: ["node_modules/"],
|
|
60
|
+
policy: "pull-push",
|
|
61
|
+
});
|
|
62
|
+
`,
|
|
63
|
+
"test.ts": `/**
|
|
64
|
+
* Test job
|
|
65
|
+
*/
|
|
66
|
+
|
|
67
|
+
import * as gl from "@intentius/chant-lexicon-gitlab";
|
|
68
|
+
import * as _ from "./_";
|
|
69
|
+
|
|
70
|
+
export const test = new gl.Job({
|
|
71
|
+
stage: "test",
|
|
72
|
+
image: _.defaultImage,
|
|
73
|
+
cache: _.npmCache,
|
|
74
|
+
script: ["npm ci", "npm test"],
|
|
75
|
+
artifacts: new gl.Artifacts({
|
|
76
|
+
reports: { junit: "coverage/junit.xml" },
|
|
77
|
+
paths: ["coverage/"],
|
|
78
|
+
expireIn: "1 week",
|
|
79
|
+
}),
|
|
80
|
+
});
|
|
81
|
+
`,
|
|
82
|
+
};
|
|
83
|
+
},
|
|
84
|
+
|
|
85
|
+
detectTemplate(data: unknown): boolean {
|
|
86
|
+
if (typeof data !== "object" || data === null) return false;
|
|
87
|
+
const obj = data as Record<string, unknown>;
|
|
88
|
+
|
|
89
|
+
// GitLab CI files typically have stages, or job-like top-level keys
|
|
90
|
+
if (Array.isArray(obj.stages)) return true;
|
|
91
|
+
if (obj.image !== undefined && obj.script !== undefined) return true;
|
|
92
|
+
|
|
93
|
+
// Check for job-like entries (objects with "stage" or "script" properties)
|
|
94
|
+
for (const value of Object.values(obj)) {
|
|
95
|
+
if (typeof value === "object" && value !== null) {
|
|
96
|
+
const entry = value as Record<string, unknown>;
|
|
97
|
+
if (entry.stage !== undefined || entry.script !== undefined) {
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return false;
|
|
104
|
+
},
|
|
105
|
+
|
|
106
|
+
completionProvider(ctx: import("@intentius/chant/lsp/types").CompletionContext) {
|
|
107
|
+
const { gitlabCompletions } = require("./lsp/completions");
|
|
108
|
+
return gitlabCompletions(ctx);
|
|
109
|
+
},
|
|
110
|
+
|
|
111
|
+
hoverProvider(ctx: import("@intentius/chant/lsp/types").HoverContext) {
|
|
112
|
+
const { gitlabHover } = require("./lsp/hover");
|
|
113
|
+
return gitlabHover(ctx);
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
templateParser() {
|
|
117
|
+
const { GitLabParser } = require("./import/parser");
|
|
118
|
+
return new GitLabParser();
|
|
119
|
+
},
|
|
120
|
+
|
|
121
|
+
templateGenerator() {
|
|
122
|
+
const { GitLabGenerator } = require("./import/generator");
|
|
123
|
+
return new GitLabGenerator();
|
|
124
|
+
},
|
|
125
|
+
|
|
126
|
+
async generate(options?: { verbose?: boolean }): Promise<void> {
|
|
127
|
+
const { generate, writeGeneratedFiles } = await import("./codegen/generate");
|
|
128
|
+
const { dirname } = await import("path");
|
|
129
|
+
const { fileURLToPath } = await import("url");
|
|
130
|
+
|
|
131
|
+
const result = await generate({ verbose: options?.verbose ?? true });
|
|
132
|
+
const pkgDir = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
133
|
+
writeGeneratedFiles(result, pkgDir);
|
|
134
|
+
|
|
135
|
+
console.error(
|
|
136
|
+
`Generated ${result.resources} entities, ${result.properties} property types, ${result.enums} enums`,
|
|
137
|
+
);
|
|
138
|
+
if (result.warnings.length > 0) {
|
|
139
|
+
console.error(`${result.warnings.length} warnings`);
|
|
140
|
+
}
|
|
141
|
+
},
|
|
142
|
+
|
|
143
|
+
async validate(options?: { verbose?: boolean }): Promise<void> {
|
|
144
|
+
const { validate } = await import("./validate");
|
|
145
|
+
const result = await validate();
|
|
146
|
+
|
|
147
|
+
for (const check of result.checks) {
|
|
148
|
+
const status = check.ok ? "OK" : "FAIL";
|
|
149
|
+
const msg = check.error ? ` — ${check.error}` : "";
|
|
150
|
+
console.error(` [${status}] ${check.name}${msg}`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (!result.success) {
|
|
154
|
+
throw new Error("Validation failed");
|
|
155
|
+
}
|
|
156
|
+
console.error("All validation checks passed.");
|
|
157
|
+
},
|
|
158
|
+
|
|
159
|
+
async coverage(options?: { verbose?: boolean; minOverall?: number }): Promise<void> {
|
|
160
|
+
const { analyzeGitLabCoverage } = await import("./coverage");
|
|
161
|
+
await analyzeGitLabCoverage({
|
|
162
|
+
verbose: options?.verbose,
|
|
163
|
+
minOverall: options?.minOverall,
|
|
164
|
+
});
|
|
165
|
+
},
|
|
166
|
+
|
|
167
|
+
async package(options?: { verbose?: boolean; force?: boolean }): Promise<void> {
|
|
168
|
+
const { packageLexicon } = await import("./codegen/package");
|
|
169
|
+
const { writeFileSync, mkdirSync } = await import("fs");
|
|
170
|
+
const { join, dirname } = await import("path");
|
|
171
|
+
const { fileURLToPath } = await import("url");
|
|
172
|
+
|
|
173
|
+
const { spec, stats } = await packageLexicon({ verbose: options?.verbose, force: options?.force });
|
|
174
|
+
|
|
175
|
+
const pkgDir = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
176
|
+
const distDir = join(pkgDir, "dist");
|
|
177
|
+
mkdirSync(join(distDir, "types"), { recursive: true });
|
|
178
|
+
mkdirSync(join(distDir, "rules"), { recursive: true });
|
|
179
|
+
mkdirSync(join(distDir, "skills"), { recursive: true });
|
|
180
|
+
|
|
181
|
+
writeFileSync(join(distDir, "manifest.json"), JSON.stringify(spec.manifest, null, 2));
|
|
182
|
+
writeFileSync(join(distDir, "meta.json"), spec.registry);
|
|
183
|
+
writeFileSync(join(distDir, "types", "index.d.ts"), spec.typesDTS);
|
|
184
|
+
|
|
185
|
+
for (const [name, content] of spec.rules) {
|
|
186
|
+
writeFileSync(join(distDir, "rules", name), content);
|
|
187
|
+
}
|
|
188
|
+
for (const [name, content] of spec.skills) {
|
|
189
|
+
writeFileSync(join(distDir, "skills", name), content);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (spec.integrity) {
|
|
193
|
+
writeFileSync(join(distDir, "integrity.json"), JSON.stringify(spec.integrity, null, 2));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
console.error(`Packaged ${stats.resources} entities, ${stats.ruleCount} rules, ${stats.skillCount} skills`);
|
|
197
|
+
},
|
|
198
|
+
|
|
199
|
+
async rollback(options?: { restore?: string; verbose?: boolean }): Promise<void> {
|
|
200
|
+
const { listSnapshots, restoreSnapshot } = await import("./codegen/rollback");
|
|
201
|
+
const { join, dirname } = await import("path");
|
|
202
|
+
const { fileURLToPath } = await import("url");
|
|
203
|
+
|
|
204
|
+
const pkgDir = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
205
|
+
const snapshotsDir = join(pkgDir, ".snapshots");
|
|
206
|
+
|
|
207
|
+
if (options?.restore) {
|
|
208
|
+
const generatedDir = join(pkgDir, "src", "generated");
|
|
209
|
+
restoreSnapshot(String(options.restore), generatedDir);
|
|
210
|
+
console.error(`Restored snapshot: ${options.restore}`);
|
|
211
|
+
} else {
|
|
212
|
+
const snapshots = listSnapshots(snapshotsDir);
|
|
213
|
+
if (snapshots.length === 0) {
|
|
214
|
+
console.error("No snapshots available.");
|
|
215
|
+
} else {
|
|
216
|
+
console.error(`Available snapshots (${snapshots.length}):`);
|
|
217
|
+
for (const s of snapshots) {
|
|
218
|
+
console.error(` ${s.timestamp} ${s.resourceCount} resources ${s.path}`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
},
|
|
223
|
+
|
|
224
|
+
mcpTools() {
|
|
225
|
+
return [
|
|
226
|
+
{
|
|
227
|
+
name: "diff",
|
|
228
|
+
description: "Compare current build output against previous output for GitLab CI",
|
|
229
|
+
inputSchema: {
|
|
230
|
+
type: "object" as const,
|
|
231
|
+
properties: {
|
|
232
|
+
path: {
|
|
233
|
+
type: "string",
|
|
234
|
+
description: "Path to the infrastructure project directory",
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
},
|
|
238
|
+
async handler(params: Record<string, unknown>): Promise<unknown> {
|
|
239
|
+
const { diffCommand } = await import("@intentius/chant/cli/commands/diff");
|
|
240
|
+
const result = await diffCommand({
|
|
241
|
+
path: (params.path as string) ?? ".",
|
|
242
|
+
serializers: [gitlabSerializer],
|
|
243
|
+
});
|
|
244
|
+
return result;
|
|
245
|
+
},
|
|
246
|
+
},
|
|
247
|
+
];
|
|
248
|
+
},
|
|
249
|
+
|
|
250
|
+
mcpResources() {
|
|
251
|
+
return [
|
|
252
|
+
{
|
|
253
|
+
uri: "resource-catalog",
|
|
254
|
+
name: "GitLab CI Entity Catalog",
|
|
255
|
+
description: "JSON list of all supported GitLab CI entity types",
|
|
256
|
+
mimeType: "application/json",
|
|
257
|
+
async handler(): Promise<string> {
|
|
258
|
+
const lexicon = require("./generated/lexicon-gitlab.json") as Record<string, { resourceType: string; kind: string }>;
|
|
259
|
+
const entries = Object.entries(lexicon).map(([className, entry]) => ({
|
|
260
|
+
className,
|
|
261
|
+
resourceType: entry.resourceType,
|
|
262
|
+
kind: entry.kind,
|
|
263
|
+
}));
|
|
264
|
+
return JSON.stringify(entries);
|
|
265
|
+
},
|
|
266
|
+
},
|
|
267
|
+
{
|
|
268
|
+
uri: "examples/basic-pipeline",
|
|
269
|
+
name: "Basic Pipeline Example",
|
|
270
|
+
description: "A basic GitLab CI pipeline with build, test, and deploy stages",
|
|
271
|
+
mimeType: "text/typescript",
|
|
272
|
+
async handler(): Promise<string> {
|
|
273
|
+
return `import { Job, Image, Cache, Artifacts, CI } from "@intentius/chant-lexicon-gitlab";
|
|
274
|
+
|
|
275
|
+
export const build = new Job({
|
|
276
|
+
stage: "build",
|
|
277
|
+
image: new Image({ name: "node:20" }),
|
|
278
|
+
cache: new Cache({ key: CI.CommitRef, paths: ["node_modules/"] }),
|
|
279
|
+
script: ["npm ci", "npm run build"],
|
|
280
|
+
artifacts: new Artifacts({ paths: ["dist/"], expireIn: "1 day" }),
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
export const test = new Job({
|
|
284
|
+
stage: "test",
|
|
285
|
+
image: new Image({ name: "node:20" }),
|
|
286
|
+
cache: new Cache({ key: CI.CommitRef, paths: ["node_modules/"], policy: "pull" }),
|
|
287
|
+
script: ["npm ci", "npm test"],
|
|
288
|
+
artifacts: new Artifacts({
|
|
289
|
+
reports: { junit: "coverage/junit.xml" },
|
|
290
|
+
expireIn: "1 week",
|
|
291
|
+
}),
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
export const deploy = new Job({
|
|
295
|
+
stage: "deploy",
|
|
296
|
+
script: ["./deploy.sh"],
|
|
297
|
+
rules: [{ if: "$CI_COMMIT_BRANCH == \\"main\\"", when: "manual" }],
|
|
298
|
+
});
|
|
299
|
+
`;
|
|
300
|
+
},
|
|
301
|
+
},
|
|
302
|
+
];
|
|
303
|
+
},
|
|
304
|
+
|
|
305
|
+
async docs(options?: { verbose?: boolean }): Promise<void> {
|
|
306
|
+
const { generateDocs } = await import("./codegen/docs");
|
|
307
|
+
await generateDocs(options);
|
|
308
|
+
},
|
|
309
|
+
|
|
310
|
+
skills(): SkillDefinition[] {
|
|
311
|
+
return [
|
|
312
|
+
{
|
|
313
|
+
name: "gitlab-ci",
|
|
314
|
+
description: "GitLab CI/CD best practices and common patterns",
|
|
315
|
+
content: `---
|
|
316
|
+
name: gitlab-ci
|
|
317
|
+
description: GitLab CI/CD best practices and common patterns
|
|
318
|
+
---
|
|
319
|
+
|
|
320
|
+
# GitLab CI/CD with Chant
|
|
321
|
+
|
|
322
|
+
## Common Entity Types
|
|
323
|
+
|
|
324
|
+
- \`Job\` — Pipeline job definition
|
|
325
|
+
- \`Default\` — Default settings inherited by all jobs
|
|
326
|
+
- \`Workflow\` — Pipeline-level configuration
|
|
327
|
+
- \`Artifacts\` — Job artifact configuration
|
|
328
|
+
- \`Cache\` — Cache configuration
|
|
329
|
+
- \`Image\` — Docker image for a job
|
|
330
|
+
- \`Rule\` — Conditional execution rule
|
|
331
|
+
- \`Environment\` — Deployment environment
|
|
332
|
+
- \`Trigger\` — Trigger downstream pipeline
|
|
333
|
+
- \`Include\` — Include external CI configuration
|
|
334
|
+
|
|
335
|
+
## Predefined Variables
|
|
336
|
+
|
|
337
|
+
- \`CI.CommitBranch\` — Current branch name
|
|
338
|
+
- \`CI.CommitSha\` — Current commit SHA
|
|
339
|
+
- \`CI.PipelineSource\` — What triggered the pipeline
|
|
340
|
+
- \`CI.ProjectPath\` — Project path (group/project)
|
|
341
|
+
- \`CI.Registry\` — Container registry URL
|
|
342
|
+
- \`CI.MergeRequestIid\` — MR internal ID
|
|
343
|
+
|
|
344
|
+
## Best Practices
|
|
345
|
+
|
|
346
|
+
1. **Use stages** — Organize jobs into logical stages (build, test, deploy)
|
|
347
|
+
2. **Cache dependencies** — Cache node_modules, pip packages, etc.
|
|
348
|
+
3. **Use rules** — Prefer \`rules:\` over \`only:/except:\` for conditional execution
|
|
349
|
+
4. **Minimize artifacts** — Only preserve files needed by later stages
|
|
350
|
+
5. **Use includes** — Share common configuration across projects
|
|
351
|
+
6. **Set timeouts** — Prevent stuck jobs from blocking pipelines
|
|
352
|
+
`,
|
|
353
|
+
triggers: [
|
|
354
|
+
{ type: "file-pattern", value: "**/*.gitlab.ts" },
|
|
355
|
+
{ type: "context", value: "gitlab" },
|
|
356
|
+
],
|
|
357
|
+
parameters: [],
|
|
358
|
+
examples: [
|
|
359
|
+
{
|
|
360
|
+
title: "Basic test job",
|
|
361
|
+
description: "Create a test job with caching and artifacts",
|
|
362
|
+
input: "Create a test job",
|
|
363
|
+
output: `new Job({
|
|
364
|
+
stage: "test",
|
|
365
|
+
image: new Image({ name: "node:20" }),
|
|
366
|
+
script: ["npm ci", "npm test"],
|
|
367
|
+
cache: new Cache({
|
|
368
|
+
key: "$CI_COMMIT_REF_SLUG",
|
|
369
|
+
paths: ["node_modules/"],
|
|
370
|
+
}),
|
|
371
|
+
artifacts: new Artifacts({
|
|
372
|
+
reports: { junit: "coverage/junit.xml" },
|
|
373
|
+
}),
|
|
374
|
+
})`,
|
|
375
|
+
},
|
|
376
|
+
],
|
|
377
|
+
},
|
|
378
|
+
];
|
|
379
|
+
},
|
|
380
|
+
};
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { gitlabSerializer } from "./serializer";
|
|
3
|
+
import { DECLARABLE_MARKER, type Declarable } from "@intentius/chant/declarable";
|
|
4
|
+
|
|
5
|
+
// ── Mock entities ──────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
class MockJob implements Declarable {
|
|
8
|
+
readonly [DECLARABLE_MARKER] = true as const;
|
|
9
|
+
readonly lexicon = "gitlab";
|
|
10
|
+
readonly entityType = "GitLab::CI::Job";
|
|
11
|
+
readonly kind = "resource" as const;
|
|
12
|
+
readonly props: Record<string, unknown>;
|
|
13
|
+
|
|
14
|
+
constructor(props: Record<string, unknown> = {}) {
|
|
15
|
+
this.props = props;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
class MockDefault implements Declarable {
|
|
20
|
+
readonly [DECLARABLE_MARKER] = true as const;
|
|
21
|
+
readonly lexicon = "gitlab";
|
|
22
|
+
readonly entityType = "GitLab::CI::Default";
|
|
23
|
+
readonly kind = "resource" as const;
|
|
24
|
+
readonly props: Record<string, unknown>;
|
|
25
|
+
|
|
26
|
+
constructor(props: Record<string, unknown> = {}) {
|
|
27
|
+
this.props = props;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
class MockWorkflow implements Declarable {
|
|
32
|
+
readonly [DECLARABLE_MARKER] = true as const;
|
|
33
|
+
readonly lexicon = "gitlab";
|
|
34
|
+
readonly entityType = "GitLab::CI::Workflow";
|
|
35
|
+
readonly kind = "resource" as const;
|
|
36
|
+
readonly props: Record<string, unknown>;
|
|
37
|
+
|
|
38
|
+
constructor(props: Record<string, unknown> = {}) {
|
|
39
|
+
this.props = props;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
class MockPropertyEntity implements Declarable {
|
|
44
|
+
readonly [DECLARABLE_MARKER] = true as const;
|
|
45
|
+
readonly lexicon = "gitlab";
|
|
46
|
+
readonly entityType: string;
|
|
47
|
+
readonly kind = "property" as const;
|
|
48
|
+
readonly props: Record<string, unknown>;
|
|
49
|
+
|
|
50
|
+
constructor(entityType: string, props: Record<string, unknown> = {}) {
|
|
51
|
+
this.entityType = entityType;
|
|
52
|
+
this.props = props;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ── Tests ──────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
describe("gitlabSerializer", () => {
|
|
59
|
+
test("has correct name", () => {
|
|
60
|
+
expect(gitlabSerializer.name).toBe("gitlab");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("has correct rulePrefix", () => {
|
|
64
|
+
expect(gitlabSerializer.rulePrefix).toBe("WGL");
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe("gitlabSerializer.serialize", () => {
|
|
69
|
+
test("serializes empty entities", () => {
|
|
70
|
+
const entities = new Map<string, Declarable>();
|
|
71
|
+
const output = gitlabSerializer.serialize(entities);
|
|
72
|
+
// Empty pipeline should produce minimal output
|
|
73
|
+
expect(output).toBe("\n");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("serializes a simple job", () => {
|
|
77
|
+
const entities = new Map<string, Declarable>();
|
|
78
|
+
entities.set("testJob", new MockJob({
|
|
79
|
+
stage: "test",
|
|
80
|
+
script: ["npm test"],
|
|
81
|
+
}));
|
|
82
|
+
|
|
83
|
+
const output = gitlabSerializer.serialize(entities);
|
|
84
|
+
expect(output).toContain("stages:");
|
|
85
|
+
expect(output).toContain("- test");
|
|
86
|
+
expect(output).toContain("test-job:");
|
|
87
|
+
expect(output).toContain("script:");
|
|
88
|
+
expect(output).toContain("- npm test");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("collects stages from all jobs", () => {
|
|
92
|
+
const entities = new Map<string, Declarable>();
|
|
93
|
+
entities.set("buildJob", new MockJob({ stage: "build", script: ["make"] }));
|
|
94
|
+
entities.set("testJob", new MockJob({ stage: "test", script: ["npm test"] }));
|
|
95
|
+
entities.set("deployJob", new MockJob({ stage: "deploy", script: ["deploy.sh"] }));
|
|
96
|
+
|
|
97
|
+
const output = gitlabSerializer.serialize(entities);
|
|
98
|
+
expect(output).toContain("stages:");
|
|
99
|
+
expect(output).toContain("- build");
|
|
100
|
+
expect(output).toContain("- test");
|
|
101
|
+
expect(output).toContain("- deploy");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("deduplicates stages", () => {
|
|
105
|
+
const entities = new Map<string, Declarable>();
|
|
106
|
+
entities.set("jobA", new MockJob({ stage: "build", script: ["build1"] }));
|
|
107
|
+
entities.set("jobB", new MockJob({ stage: "build", script: ["build2"] }));
|
|
108
|
+
|
|
109
|
+
const output = gitlabSerializer.serialize(entities);
|
|
110
|
+
// "build" should appear only once in the stages list
|
|
111
|
+
const stagesSection = output.split("\n\n")[0]; // stages is the first section
|
|
112
|
+
const stagesMatch = stagesSection.match(/- build/g);
|
|
113
|
+
expect(stagesMatch).toHaveLength(1);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("converts camelCase job names to kebab-case", () => {
|
|
117
|
+
const entities = new Map<string, Declarable>();
|
|
118
|
+
entities.set("myTestJob", new MockJob({ script: ["test"] }));
|
|
119
|
+
|
|
120
|
+
const output = gitlabSerializer.serialize(entities);
|
|
121
|
+
expect(output).toContain("my-test-job:");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("converts camelCase property keys to snake_case", () => {
|
|
125
|
+
const entities = new Map<string, Declarable>();
|
|
126
|
+
entities.set("job", new MockJob({
|
|
127
|
+
beforeScript: ["echo hello"],
|
|
128
|
+
afterScript: ["echo done"],
|
|
129
|
+
expireIn: "1 week",
|
|
130
|
+
}));
|
|
131
|
+
|
|
132
|
+
const output = gitlabSerializer.serialize(entities);
|
|
133
|
+
expect(output).toContain("before_script:");
|
|
134
|
+
expect(output).toContain("after_script:");
|
|
135
|
+
expect(output).toContain("expire_in:");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("serializes default settings", () => {
|
|
139
|
+
const entities = new Map<string, Declarable>();
|
|
140
|
+
entities.set("defaults", new MockDefault({
|
|
141
|
+
interruptible: true,
|
|
142
|
+
timeout: "30 minutes",
|
|
143
|
+
}));
|
|
144
|
+
|
|
145
|
+
const output = gitlabSerializer.serialize(entities);
|
|
146
|
+
expect(output).toContain("default:");
|
|
147
|
+
expect(output).toContain("interruptible: true");
|
|
148
|
+
// "30 minutes" starts with a digit, so it gets quoted
|
|
149
|
+
expect(output).toContain("timeout: '30 minutes'");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("serializes workflow", () => {
|
|
153
|
+
const entities = new Map<string, Declarable>();
|
|
154
|
+
entities.set("workflow", new MockWorkflow({
|
|
155
|
+
name: "My Pipeline",
|
|
156
|
+
}));
|
|
157
|
+
|
|
158
|
+
const output = gitlabSerializer.serialize(entities);
|
|
159
|
+
expect(output).toContain("workflow:");
|
|
160
|
+
expect(output).toContain("name: My Pipeline");
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("skips property-kind declarables", () => {
|
|
164
|
+
const entities = new Map<string, Declarable>();
|
|
165
|
+
entities.set("myImage", new MockPropertyEntity("GitLab::CI::Image", { name: "node:20" }));
|
|
166
|
+
|
|
167
|
+
const output = gitlabSerializer.serialize(entities);
|
|
168
|
+
// Property entities are embedded inline, not serialized as top-level
|
|
169
|
+
expect(output).toBe("\n");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("omits properties not present in props", () => {
|
|
173
|
+
const entities = new Map<string, Declarable>();
|
|
174
|
+
// Only set script — image and cache are not in props at all
|
|
175
|
+
entities.set("job", new MockJob({
|
|
176
|
+
script: ["test"],
|
|
177
|
+
}));
|
|
178
|
+
|
|
179
|
+
const output = gitlabSerializer.serialize(entities);
|
|
180
|
+
expect(output).not.toContain("image:");
|
|
181
|
+
expect(output).not.toContain("cache:");
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("serializes multi-job pipeline with defaults and workflow", () => {
|
|
185
|
+
const entities = new Map<string, Declarable>();
|
|
186
|
+
entities.set("defaults", new MockDefault({
|
|
187
|
+
image: "node:20",
|
|
188
|
+
}));
|
|
189
|
+
entities.set("workflow", new MockWorkflow({
|
|
190
|
+
name: "CI Pipeline",
|
|
191
|
+
}));
|
|
192
|
+
entities.set("lint", new MockJob({ stage: "test", script: ["npm run lint"] }));
|
|
193
|
+
entities.set("test", new MockJob({ stage: "test", script: ["npm test"] }));
|
|
194
|
+
entities.set("deploy", new MockJob({ stage: "deploy", script: ["deploy.sh"] }));
|
|
195
|
+
|
|
196
|
+
const output = gitlabSerializer.serialize(entities);
|
|
197
|
+
expect(output).toContain("stages:");
|
|
198
|
+
expect(output).toContain("default:");
|
|
199
|
+
expect(output).toContain("workflow:");
|
|
200
|
+
expect(output).toContain("lint:");
|
|
201
|
+
expect(output).toContain("test:");
|
|
202
|
+
expect(output).toContain("deploy:");
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
describe("string quoting", () => {
|
|
207
|
+
test("quotes strings starting with $", () => {
|
|
208
|
+
const entities = new Map<string, Declarable>();
|
|
209
|
+
entities.set("job", new MockJob({
|
|
210
|
+
script: ["$CI_COMMIT_SHA"],
|
|
211
|
+
}));
|
|
212
|
+
|
|
213
|
+
const output = gitlabSerializer.serialize(entities);
|
|
214
|
+
// String starting with $ should be quoted
|
|
215
|
+
expect(output).toContain("'$CI_COMMIT_SHA'");
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("quotes strings starting with *", () => {
|
|
219
|
+
const entities = new Map<string, Declarable>();
|
|
220
|
+
entities.set("job", new MockJob({
|
|
221
|
+
script: ["*glob"],
|
|
222
|
+
}));
|
|
223
|
+
|
|
224
|
+
const output = gitlabSerializer.serialize(entities);
|
|
225
|
+
expect(output).toContain("'*glob'");
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("quotes strings containing :", () => {
|
|
229
|
+
const entities = new Map<string, Declarable>();
|
|
230
|
+
entities.set("job", new MockJob({
|
|
231
|
+
script: ["key: value"],
|
|
232
|
+
}));
|
|
233
|
+
|
|
234
|
+
const output = gitlabSerializer.serialize(entities);
|
|
235
|
+
expect(output).toContain("'key: value'");
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test("quotes YAML boolean-like strings", () => {
|
|
239
|
+
const entities = new Map<string, Declarable>();
|
|
240
|
+
entities.set("job", new MockJob({
|
|
241
|
+
variables: { FLAG: "true", OTHER: "false", MAYBE: "null" },
|
|
242
|
+
}));
|
|
243
|
+
|
|
244
|
+
const output = gitlabSerializer.serialize(entities);
|
|
245
|
+
expect(output).toContain("'true'");
|
|
246
|
+
expect(output).toContain("'false'");
|
|
247
|
+
expect(output).toContain("'null'");
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test("does not quote plain strings", () => {
|
|
251
|
+
const entities = new Map<string, Declarable>();
|
|
252
|
+
entities.set("job", new MockJob({
|
|
253
|
+
stage: "test",
|
|
254
|
+
}));
|
|
255
|
+
|
|
256
|
+
const output = gitlabSerializer.serialize(entities);
|
|
257
|
+
expect(output).toContain("stage: test");
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
describe("nested objects and arrays", () => {
|
|
262
|
+
test("serializes nested objects", () => {
|
|
263
|
+
const entities = new Map<string, Declarable>();
|
|
264
|
+
entities.set("job", new MockJob({
|
|
265
|
+
variables: {
|
|
266
|
+
NODE_ENV: "production",
|
|
267
|
+
CI: "true",
|
|
268
|
+
},
|
|
269
|
+
}));
|
|
270
|
+
|
|
271
|
+
const output = gitlabSerializer.serialize(entities);
|
|
272
|
+
expect(output).toContain("variables:");
|
|
273
|
+
expect(output).toContain("node_env: production");
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test("serializes arrays of strings", () => {
|
|
277
|
+
const entities = new Map<string, Declarable>();
|
|
278
|
+
entities.set("job", new MockJob({
|
|
279
|
+
script: ["npm ci", "npm test", "npm run build"],
|
|
280
|
+
}));
|
|
281
|
+
|
|
282
|
+
const output = gitlabSerializer.serialize(entities);
|
|
283
|
+
expect(output).toContain("- npm ci");
|
|
284
|
+
expect(output).toContain("- npm test");
|
|
285
|
+
expect(output).toContain("- npm run build");
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test("serializes boolean values", () => {
|
|
289
|
+
const entities = new Map<string, Declarable>();
|
|
290
|
+
entities.set("job", new MockJob({
|
|
291
|
+
interruptible: true,
|
|
292
|
+
allowFailure: false,
|
|
293
|
+
}));
|
|
294
|
+
|
|
295
|
+
const output = gitlabSerializer.serialize(entities);
|
|
296
|
+
expect(output).toContain("interruptible: true");
|
|
297
|
+
expect(output).toContain("allow_failure: false");
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
test("serializes numeric values", () => {
|
|
301
|
+
const entities = new Map<string, Declarable>();
|
|
302
|
+
entities.set("job", new MockJob({
|
|
303
|
+
parallel: 5,
|
|
304
|
+
}));
|
|
305
|
+
|
|
306
|
+
const output = gitlabSerializer.serialize(entities);
|
|
307
|
+
expect(output).toContain("parallel: 5");
|
|
308
|
+
});
|
|
309
|
+
});
|