@intentius/chant-lexicon-gitlab 0.120.0 → 0.121.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.
@@ -1 +1 @@
1
- {"version":3,"file":"generator.d.ts","sourceRoot":"","sources":["../../src/import/generator.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,mBAAmB,EAAE,aAAa,EAAE,MAAM,mCAAmC,CAAC;AAC5F,OAAO,KAAK,EAAE,UAAU,EAAc,MAAM,gCAAgC,CAAC;AAsC7E;;GAEG;AACH,qBAAa,eAAgB,YAAW,mBAAmB;IACzD,QAAQ,CAAC,EAAE,EAAE,UAAU,GAAG,aAAa,EAAE,CA0DxC;IAED,OAAO,CAAC,yBAAyB;IAYjC,OAAO,CAAC,SAAS;IAejB,OAAO,CAAC,SAAS;IA2BjB,OAAO,CAAC,WAAW;CA2BpB"}
1
+ {"version":3,"file":"generator.d.ts","sourceRoot":"","sources":["../../src/import/generator.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,mBAAmB,EAAE,aAAa,EAAE,MAAM,mCAAmC,CAAC;AAC5F,OAAO,KAAK,EAAE,UAAU,EAAc,MAAM,gCAAgC,CAAC;AA0C7E;;GAEG;AACH,qBAAa,eAAgB,YAAW,mBAAmB;IACzD,QAAQ,CAAC,EAAE,EAAE,UAAU,GAAG,aAAa,EAAE,CA8DxC;IAED,OAAO,CAAC,yBAAyB;IAejC,OAAO,CAAC,SAAS;IAejB,OAAO,CAAC,SAAS;IAqCjB,OAAO,CAAC,WAAW;CA2BpB"}
@@ -1 +1 @@
1
- {"version":3,"file":"parser.d.ts","sourceRoot":"","sources":["../../src/import/parser.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,cAAc,EAAE,UAAU,EAAc,MAAM,gCAAgC,CAAC;AA6B7F;;GAEG;AACH,qBAAa,YAAa,YAAW,cAAc;IACjD,KAAK,CAAC,OAAO,EAAE,MAAM,GAAG,UAAU,CA4DjC;CACF"}
1
+ {"version":3,"file":"parser.d.ts","sourceRoot":"","sources":["../../src/import/parser.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,cAAc,EAAE,UAAU,EAAc,MAAM,gCAAgC,CAAC;AA6B7F;;GAEG;AACH,qBAAa,YAAa,YAAW,cAAc;IACjD,KAAK,CAAC,OAAO,EAAE,MAAM,GAAG,UAAU,CA4EjC;CACF"}
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "algorithm": "sha256",
3
3
  "artifacts": {
4
- "manifest.json": "b2e850bbb36b4d9288070eaa6c6f240149f2487a1534b498184d9282a1f0982e",
4
+ "manifest.json": "694e53b8684573c4917b2ffe236247835db6e58188b8b44e5a381e401ca35a08",
5
5
  "meta.json": "db082fbf90c5941c498b2bee0f106103449c1a4c257716ca2220f0dd490450b2",
6
6
  "types/index.d.ts": "a048a66ebe20da0cb5fc889054cac69044d9bf882e2c56b5c5bf113d1f3ba57c",
7
7
  "rules/artifact-no-expiry.ts": "3f3cabf9792cbf8207e53a25f506715466b19ec25e9c3b4d0d77fed6b2eb4542",
@@ -55,5 +55,5 @@
55
55
  "skills/chant-gitlab-migrate.md": "4853d04980560b379e5b0f7267dcb0a1e5d5879cb9e9931681ce76b2a5fe69d8",
56
56
  "skills/chant-gitlab-patterns.md": "6d9a44e9e8de4c3820be9d65381b0d2ede4cb3626aaaf0011e21838a9fbf556e"
57
57
  },
58
- "composite": "c0d291cbf1bda2ce61c417054a931919434b86868d2022cb9d6efc9d5f0175b2"
58
+ "composite": "773f17febcc5969a1d05fc81090404f1950d2258d388c3d8932224be38dbc460"
59
59
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitlab",
3
- "version": "0.120.0",
3
+ "version": "0.121.1",
4
4
  "chantVersion": ">=0.1.0",
5
5
  "namespace": "GitLab",
6
6
  "intrinsics": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant-lexicon-gitlab",
3
- "version": "0.120.0",
3
+ "version": "0.121.1",
4
4
  "description": "GitLab CI lexicon for chant — declarative IaC in TypeScript",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://intentius.io/chant",
@@ -67,8 +67,8 @@
67
67
  "typescript": "^5.9.3"
68
68
  },
69
69
  "peerDependencies": {
70
- "@intentius/chant": "^0.120.0",
71
- "@intentius/chant-lexicon-github": "^0.120.0",
70
+ "@intentius/chant": "^0.121.1",
71
+ "@intentius/chant-lexicon-github": "^0.121.1",
72
72
  "typescript": "^5.9.3"
73
73
  },
74
74
  "peerDependenciesMeta": {
@@ -148,4 +148,14 @@ describe("GitLabGenerator", () => {
148
148
  const files = generator.generate(ir);
149
149
  expect(files[0].content).toContain("Pipeline stages: build, test, deploy");
150
150
  });
151
+
152
+ test("a key that is not an identifier is quoted", () => {
153
+ const ir: TemplateIR = {
154
+ resources: [{ logicalId: "pipeline", type: "GitLab::CI::Pipeline", properties: { variables: { "MY-VAR": "x", GIT_DEPTH: "0" } } }],
155
+ parameters: [],
156
+ };
157
+ const content = generator.generate(ir)[0]!.content;
158
+ expect(content).toContain('"MY-VAR": "x"');
159
+ expect(content).toContain('GIT_DEPTH: "0"');
160
+ });
151
161
  });
@@ -15,6 +15,7 @@ const TYPE_TO_CLASS: Record<string, string> = {
15
15
  "GitLab::CI::Job": "Job",
16
16
  "GitLab::CI::Default": "Default",
17
17
  "GitLab::CI::Workflow": "Workflow",
18
+ "GitLab::CI::Pipeline": "Pipeline",
18
19
  };
19
20
 
20
21
  /**
@@ -44,6 +45,9 @@ const PROPERTY_CONSTRUCTORS: Record<string, string> = {
44
45
  inherit: "Inherit",
45
46
  };
46
47
 
48
+ /** An object key as TypeScript accepts it: bare when it is an identifier, quoted otherwise. */
49
+ const tsKey = (key: string): string => (/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key) ? key : JSON.stringify(key));
50
+
47
51
  /**
48
52
  * Generate TypeScript source code from a GitLab CI IR.
49
53
  */
@@ -68,14 +72,18 @@ export class GitLabGenerator implements TypeScriptGenerator {
68
72
  lines.push(`import { ${imports} } from "@intentius/chant-lexicon-gitlab";`);
69
73
  lines.push("");
70
74
 
75
+ // A Pipeline resource declares the stages and includes (#3742); without
76
+ // one, the metadata is written as comments.
77
+ const hasPipeline = ir.resources.some((r) => r.type === "GitLab::CI::Pipeline");
78
+
71
79
  // Emit stages if present in metadata
72
- if (ir.metadata?.stages && Array.isArray(ir.metadata.stages)) {
80
+ if (!hasPipeline && ir.metadata?.stages && Array.isArray(ir.metadata.stages)) {
73
81
  lines.push(`// Pipeline stages: ${(ir.metadata.stages as string[]).join(", ")}`);
74
82
  lines.push("");
75
83
  }
76
84
 
77
85
  // Emit includes as comments
78
- if (ir.metadata?.include) {
86
+ if (!hasPipeline && ir.metadata?.include) {
79
87
  lines.push("// Imported includes (not converted):");
80
88
  const includes = Array.isArray(ir.metadata.include) ? ir.metadata.include : [ir.metadata.include];
81
89
  for (const inc of includes) {
@@ -117,6 +125,9 @@ export class GitLabGenerator implements TypeScriptGenerator {
117
125
  if (key === "rules" && Array.isArray(value)) {
118
126
  used.add("Rule");
119
127
  }
128
+ if (key === "include" && (Array.isArray(value) ? value : [value]).some((i) => typeof i === "object" && i !== null)) {
129
+ used.add("Include");
130
+ }
120
131
  }
121
132
  }
122
133
 
@@ -128,7 +139,7 @@ export class GitLabGenerator implements TypeScriptGenerator {
128
139
  for (const [key, value] of Object.entries(props)) {
129
140
  if (value === undefined || value === null) continue;
130
141
  const emitted = this.emitValue(key, value, depth + 1);
131
- entries.push(`${innerIndent}${key}: ${emitted},`);
142
+ entries.push(`${innerIndent}${tsKey(key)}: ${emitted},`);
132
143
  }
133
144
 
134
145
  if (entries.length === 0) return "{}";
@@ -145,6 +156,16 @@ export class GitLabGenerator implements TypeScriptGenerator {
145
156
  return `new ${constructor}(${propsStr})`;
146
157
  }
147
158
 
159
+ // A Pipeline's include: each object is an Include, a string stays a string.
160
+ if (key === "include") {
161
+ const item = (i: unknown, d: number) =>
162
+ typeof i === "object" && i !== null && !Array.isArray(i) ? `new Include(${this.emitProps(i as Record<string, unknown>, d)})` : this.emitLiteral(i, d);
163
+ if (!Array.isArray(value)) return item(value, depth);
164
+ const indent = " ".repeat(depth);
165
+ const innerIndent = " ".repeat(depth + 1);
166
+ return `[\n${value.map((i) => `${innerIndent}${item(i, depth + 1)},`).join("\n")}\n${indent}]`;
167
+ }
168
+
148
169
  // Rules array — wrap each item in Rule constructor
149
170
  if (key === "rules" && Array.isArray(value)) {
150
171
  const items = value.map((item) => {
@@ -183,7 +204,7 @@ export class GitLabGenerator implements TypeScriptGenerator {
183
204
  if (entries.length === 0) return "{}";
184
205
  const indent = " ".repeat(depth);
185
206
  const innerIndent = " ".repeat(depth + 1);
186
- const items = entries.map(([k, v]) => `${innerIndent}${k}: ${this.emitLiteral(v, depth + 1)},`);
207
+ const items = entries.map(([k, v]) => `${innerIndent}${tsKey(k)}: ${this.emitLiteral(v, depth + 1)},`);
187
208
  return `{\n${items.join("\n")}\n${indent}}`;
188
209
  }
189
210
 
@@ -15,10 +15,11 @@ test-job:
15
15
  - npm test
16
16
  `;
17
17
  const ir = parser.parse(yaml);
18
- expect(ir.resources).toHaveLength(1);
19
- expect(ir.resources[0].type).toBe("GitLab::CI::Job");
20
- expect(ir.resources[0].properties.stage).toBe("test");
21
- expect(ir.resources[0].properties.script).toEqual(["npm test"]);
18
+ expect(ir.resources).toHaveLength(2);
19
+ expect(ir.resources[0].type).toBe("GitLab::CI::Pipeline");
20
+ expect(ir.resources[1].type).toBe("GitLab::CI::Job");
21
+ expect(ir.resources[1].properties.stage).toBe("test");
22
+ expect(ir.resources[1].properties.script).toEqual(["npm test"]);
22
23
  });
23
24
 
24
25
  test("parses multiple jobs", () => {
@@ -134,9 +135,24 @@ test-job:
134
135
  - npm test
135
136
  `;
136
137
  const ir = parser.parse(yaml);
137
- // Only the job should be in resources (not stages, variables, include)
138
+ // Only the job is a Job; stages, variables and include are the Pipeline (#3742)
138
139
  const jobs = ir.resources.filter((r) => r.type === "GitLab::CI::Job");
139
140
  expect(jobs).toHaveLength(1);
141
+ expect(ir.resources[0]).toEqual({
142
+ logicalId: "pipeline",
143
+ type: "GitLab::CI::Pipeline",
144
+ properties: { include: [{ local: "shared.yml" }], stages: ["test"], variables: { NODE_ENV: "production" } },
145
+ });
146
+ });
147
+
148
+ test("a job named pipeline keeps its name; the Pipeline takes another (#3742)", () => {
149
+ const ir = parser.parse(`stages: [test]\npipeline:\n stage: test\n script: [x]\n`);
150
+ expect(ir.resources.map((r) => r.logicalId)).toEqual(["pipelineConfig", "pipeline"]);
151
+ });
152
+
153
+ test("a file with no include, stages or variables has no Pipeline", () => {
154
+ const ir = parser.parse(`job:\n script: [x]\n`);
155
+ expect(ir.resources.map((r) => r.type)).toEqual(["GitLab::CI::Job"]);
140
156
  });
141
157
 
142
158
  test("handles JSON input", () => {
@@ -145,7 +161,7 @@ test-job:
145
161
  "test-job": { stage: "test", script: ["npm test"] },
146
162
  });
147
163
  const ir = parser.parse(json);
148
- expect(ir.resources).toHaveLength(1);
164
+ expect(ir.resources.map((r) => r.type)).toEqual(["GitLab::CI::Pipeline", "GitLab::CI::Job"]);
149
165
  });
150
166
 
151
167
  test("returns empty parameters (GitLab CI has no parameters)", () => {
@@ -87,7 +87,23 @@ export class GitLabParser implements TemplateParser {
87
87
  }
88
88
  }
89
89
 
90
- // Record include references as metadata
90
+ // The file's top-level include, stages and variables are a Pipeline
91
+ // (#3742), so a rebuild writes them back: stages as written, a stage no
92
+ // job uses and the declared order included.
93
+ const pipeline: Record<string, unknown> = {};
94
+ if (doc.include !== undefined && doc.include !== null) pipeline.include = doc.include;
95
+ if (Array.isArray(doc.stages)) pipeline.stages = doc.stages;
96
+ if (doc.variables && typeof doc.variables === "object") pipeline.variables = doc.variables;
97
+ if (Object.keys(pipeline).length > 0) {
98
+ const taken = new Set(resources.map((r) => r.logicalId));
99
+ resources.unshift({
100
+ logicalId: taken.has("pipeline") ? "pipelineConfig" : "pipeline",
101
+ type: "GitLab::CI::Pipeline",
102
+ properties: pipeline,
103
+ });
104
+ }
105
+
106
+ // Also kept as metadata, for callers that read the IR without the Pipeline.
91
107
  const metadata: Record<string, unknown> = {};
92
108
  if (doc.stages) metadata.stages = doc.stages;
93
109
  if (doc.include) metadata.include = doc.include;
@@ -65,7 +65,8 @@ deploy-prod:
65
65
  - deploy.sh
66
66
  `;
67
67
  const ir = parser.parse(yaml);
68
- expect(ir.resources).toHaveLength(3);
68
+ // the Pipeline and 3 jobs
69
+ expect(ir.resources).toHaveLength(4);
69
70
 
70
71
  const files = generator.generate(ir);
71
72
  const content = files[0].content;
@@ -73,7 +74,8 @@ deploy-prod:
73
74
  expect(content).toContain("buildApp");
74
75
  expect(content).toContain("runTests");
75
76
  expect(content).toContain("deployProd");
76
- expect(content).toContain("Pipeline stages: build, test, deploy");
77
+ expect(content).toContain("new Pipeline(");
78
+ expect(content).toContain('stages: ["build", "test", "deploy"]');
77
79
  });
78
80
 
79
81
  test("pipeline with defaults and workflow roundtrip", () => {
@@ -108,7 +110,8 @@ test-job:
108
110
  expect(files).toHaveLength(1);
109
111
  const content = files[0].content;
110
112
 
111
- expect(ir.resources).toHaveLength(1);
113
+ // the Pipeline and 1 job
114
+ expect(ir.resources).toHaveLength(2);
112
115
  expect(content).toContain("new Job(");
113
116
  expect(content).toContain("npm ci");
114
117
  expect(content).toContain("npm test");
@@ -122,13 +125,13 @@ test-job:
122
125
 
123
126
  const content = files[0].content;
124
127
 
125
- // 4 jobs: build-app, lint, unit-tests, deploy-staging
126
- expect(ir.resources).toHaveLength(4);
128
+ // the Pipeline and 4 jobs: build-app, lint, unit-tests, deploy-staging
129
+ expect(ir.resources).toHaveLength(5);
127
130
  expect(content).toContain("buildApp");
128
131
  expect(content).toContain("lint");
129
132
  expect(content).toContain("unitTests");
130
133
  expect(content).toContain("deployStaging");
131
- expect(content).toContain("Pipeline stages: build, test, deploy");
134
+ expect(content).toContain('stages: ["build", "test", "deploy"]');
132
135
  // Artifacts and cache references
133
136
  expect(content).toContain("dist/");
134
137
  expect(content).toContain("package-lock.json");
@@ -141,7 +144,8 @@ test-job:
141
144
 
142
145
  const content = files[0].content;
143
146
 
144
- expect(ir.resources).toHaveLength(2);
147
+ // the Pipeline and 2 jobs
148
+ expect(ir.resources).toHaveLength(3);
145
149
  expect(content).toContain("buildImage");
146
150
  expect(content).toContain("pushLatest");
147
151
  expect(content).toContain("docker:27-cli");
@@ -157,8 +161,8 @@ test-job:
157
161
 
158
162
  const content = files[0].content;
159
163
 
160
- // 5 jobs: build, deploy-review, stop-review, deploy-staging, deploy-production
161
- expect(ir.resources).toHaveLength(5);
164
+ // the Pipeline and 5 jobs: build, deploy-review, stop-review, deploy-staging, deploy-production
165
+ expect(ir.resources).toHaveLength(6);
162
166
  expect(content).toContain("deployReview");
163
167
  expect(content).toContain("stopReview");
164
168
  expect(content).toContain("deployStaging");
@@ -179,7 +183,8 @@ test-job:
179
183
  // default + build + unit-test + integration-test + deploy
180
184
  expect(ir.resources.length).toBeGreaterThanOrEqual(3);
181
185
  expect(content).toContain("new Default(");
182
- // Include references should be captured as comments
186
+ // Include references are the Pipeline's include (#3742)
187
+ expect(content).toContain("new Pipeline(");
183
188
  expect(content).toContain("Auto-DevOps.gitlab-ci.yml");
184
189
  expect(content).toContain("interruptible");
185
190
  });
@@ -191,8 +196,8 @@ test-job:
191
196
 
192
197
  const content = files[0].content;
193
198
 
194
- // frontend, backend, e2e-matrix, deploy-all
195
- expect(ir.resources).toHaveLength(4);
199
+ // the Pipeline, frontend, backend, e2e-matrix, deploy-all
200
+ expect(ir.resources).toHaveLength(5);
196
201
  expect(content).toContain("frontend");
197
202
  expect(content).toContain("backend");
198
203
  expect(content).toContain("e2eMatrix");
@@ -219,3 +224,50 @@ test-job:
219
224
  }
220
225
  });
221
226
  });
227
+
228
+ describe("roundtrip: parse → generate → build (#3742)", () => {
229
+ test("top-level include, variables and stages come back from the build, an unused stage and the order included", async () => {
230
+ const { mkdirSync, rmSync, writeFileSync } = await import("fs");
231
+ const { build } = await import("@intentius/chant/build");
232
+ const { gitlabSerializer } = await import("../serializer");
233
+ const { gitlabPlugin } = await import("../plugin");
234
+ const { parseYAML } = await import("@intentius/chant/yaml");
235
+
236
+ const yaml = `
237
+ include:
238
+ - local: ci/shared.yml
239
+ variables:
240
+ GIT_DEPTH: "0"
241
+ stages: [review, apply, ops]
242
+
243
+ plan:
244
+ stage: review
245
+ script: [plan.sh]
246
+
247
+ apply:
248
+ stage: apply
249
+ script: [apply.sh]
250
+ `;
251
+ const [file] = generator.generate(parser.parse(yaml));
252
+ expect(file!.content).toContain("new Pipeline(");
253
+ expect(file!.content).toContain("new Include(");
254
+
255
+ const dir = join(dirname(fileURLToPath(import.meta.url)), `.import-build-tmp-${process.pid}`);
256
+ try {
257
+ mkdirSync(join(dir, "src"), { recursive: true });
258
+ writeFileSync(join(dir, "src", file!.path), file!.content);
259
+ const result = await build(join(dir, "src"), [gitlabSerializer], undefined, {
260
+ intrinsics: gitlabPlugin.intrinsics!(),
261
+ lexicons: ["gitlab"],
262
+ });
263
+ expect(result.errors).toEqual([]);
264
+ const out = result.outputs.get("gitlab");
265
+ const doc = parseYAML(typeof out === "string" ? out : String((out as { primary?: string }).primary)) as Record<string, unknown>;
266
+ expect(doc.include).toEqual([{ local: "ci/shared.yml" }]);
267
+ expect(doc.variables).toEqual({ GIT_DEPTH: "0" });
268
+ expect(doc.stages).toEqual(["review", "apply", "ops"]);
269
+ } finally {
270
+ rmSync(dir, { recursive: true, force: true });
271
+ }
272
+ }, 120_000);
273
+ });