@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.
Files changed (56) hide show
  1. package/package.json +27 -0
  2. package/src/codegen/__snapshots__/snapshot.test.ts.snap +33 -0
  3. package/src/codegen/docs-cli.ts +3 -0
  4. package/src/codegen/docs.ts +962 -0
  5. package/src/codegen/fetch.ts +73 -0
  6. package/src/codegen/generate-cli.ts +41 -0
  7. package/src/codegen/generate-lexicon.ts +53 -0
  8. package/src/codegen/generate-typescript.ts +144 -0
  9. package/src/codegen/generate.ts +166 -0
  10. package/src/codegen/naming.ts +52 -0
  11. package/src/codegen/package.ts +64 -0
  12. package/src/codegen/parse.test.ts +195 -0
  13. package/src/codegen/parse.ts +531 -0
  14. package/src/codegen/patches.test.ts +99 -0
  15. package/src/codegen/patches.ts +100 -0
  16. package/src/codegen/rollback.ts +26 -0
  17. package/src/codegen/snapshot.test.ts +109 -0
  18. package/src/coverage.test.ts +39 -0
  19. package/src/coverage.ts +52 -0
  20. package/src/generated/index.d.ts +248 -0
  21. package/src/generated/index.ts +23 -0
  22. package/src/generated/lexicon-gitlab.json +77 -0
  23. package/src/generated/runtime.ts +4 -0
  24. package/src/import/generator.test.ts +151 -0
  25. package/src/import/generator.ts +173 -0
  26. package/src/import/parser.test.ts +160 -0
  27. package/src/import/parser.ts +282 -0
  28. package/src/import/roundtrip.test.ts +89 -0
  29. package/src/index.ts +25 -0
  30. package/src/intrinsics.test.ts +42 -0
  31. package/src/intrinsics.ts +40 -0
  32. package/src/lint/post-synth/post-synth.test.ts +155 -0
  33. package/src/lint/post-synth/wgl010.ts +41 -0
  34. package/src/lint/post-synth/wgl011.ts +54 -0
  35. package/src/lint/post-synth/yaml-helpers.ts +88 -0
  36. package/src/lint/rules/artifact-no-expiry.ts +62 -0
  37. package/src/lint/rules/deprecated-only-except.ts +53 -0
  38. package/src/lint/rules/index.ts +8 -0
  39. package/src/lint/rules/missing-script.ts +65 -0
  40. package/src/lint/rules/missing-stage.ts +62 -0
  41. package/src/lint/rules/rules.test.ts +146 -0
  42. package/src/lsp/completions.test.ts +85 -0
  43. package/src/lsp/completions.ts +18 -0
  44. package/src/lsp/hover.test.ts +60 -0
  45. package/src/lsp/hover.ts +36 -0
  46. package/src/plugin.test.ts +228 -0
  47. package/src/plugin.ts +380 -0
  48. package/src/serializer.test.ts +309 -0
  49. package/src/serializer.ts +226 -0
  50. package/src/testdata/ci-schema-fixture.json +2184 -0
  51. package/src/testdata/create-fixture.ts +46 -0
  52. package/src/testdata/load-fixtures.ts +23 -0
  53. package/src/validate-cli.ts +19 -0
  54. package/src/validate.test.ts +43 -0
  55. package/src/validate.ts +125 -0
  56. 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
+ });