@intentius/chant-lexicon-gitlab 0.0.4 → 0.0.8

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.
@@ -29,7 +29,7 @@ export function generateLexiconJSON(
29
29
  typeName: r.resource.typeName,
30
30
  attributes: r.resource.attributes,
31
31
  properties: r.resource.properties,
32
- propertyTypes: r.propertyTypes.map((pt) => ({ name: pt.name, cfnType: pt.defType })),
32
+ propertyTypes: r.propertyTypes.map((pt) => ({ name: pt.name, specType: pt.defType })),
33
33
  }));
34
34
 
35
35
  const entries = buildRegistry<LexiconEntry>(registryResources, naming, {
@@ -3,7 +3,9 @@
3
3
  * with GitLab-specific manifest building and skill collection.
4
4
  */
5
5
 
6
+ import { createRequire } from "module";
6
7
  import { readFileSync } from "fs";
8
+ const require = createRequire(import.meta.url);
7
9
  import { join, dirname } from "path";
8
10
  import { fileURLToPath } from "url";
9
11
  import type { IntrinsicDef } from "@intentius/chant/lexicon";
@@ -5,9 +5,9 @@ import { loadSchemaFixture } from "../testdata/load-fixtures";
5
5
  const fixture = loadSchemaFixture();
6
6
 
7
7
  describe("parseCISchema", () => {
8
- test("returns 15 entities", () => {
8
+ test("returns 16 entities", () => {
9
9
  const results = parseCISchema(fixture);
10
- expect(results).toHaveLength(15);
10
+ expect(results).toHaveLength(16);
11
11
  });
12
12
 
13
13
  test("returns 3 resource entities", () => {
@@ -20,14 +20,15 @@ describe("parseCISchema", () => {
20
20
  expect(names).toContain("GitLab::CI::Workflow");
21
21
  });
22
22
 
23
- test("returns 12 property entities", () => {
23
+ test("returns 13 property entities", () => {
24
24
  const results = parseCISchema(fixture);
25
25
  const properties = results.filter((r) => r.isProperty);
26
- expect(properties).toHaveLength(12);
26
+ expect(properties).toHaveLength(13);
27
27
  const names = properties.map((r) => r.resource.typeName);
28
28
  expect(names).toContain("GitLab::CI::Artifacts");
29
29
  expect(names).toContain("GitLab::CI::Cache");
30
30
  expect(names).toContain("GitLab::CI::Image");
31
+ expect(names).toContain("GitLab::CI::Service");
31
32
  expect(names).toContain("GitLab::CI::Rule");
32
33
  expect(names).toContain("GitLab::CI::Retry");
33
34
  expect(names).toContain("GitLab::CI::AllowFailure");
@@ -132,6 +132,7 @@ const PROPERTY_ENTITIES: Array<{
132
132
  { typeName: "GitLab::CI::Artifacts", source: "#/definitions/artifacts", description: "Job artifact configuration" },
133
133
  { typeName: "GitLab::CI::Cache", source: "#/definitions/cache_item", description: "Cache configuration" },
134
134
  { typeName: "GitLab::CI::Image", source: "#/definitions/image", description: "Docker image for a job" },
135
+ { typeName: "GitLab::CI::Service", source: "#/definitions/services:item", description: "Docker service container for a job" },
135
136
  { typeName: "GitLab::CI::Rule", source: "#/definitions/rules:item", description: "Conditional rule for job execution" },
136
137
  { typeName: "GitLab::CI::Retry", source: "#/definitions/retry", description: "Job retry configuration" },
137
138
  { typeName: "GitLab::CI::AllowFailure", source: "#/definitions/allow_failure", description: "Allow failure configuration" },
@@ -266,17 +267,14 @@ function extractPropertyEntity(
266
267
  * Resolve a source path to a schema definition.
267
268
  */
268
269
  function resolveSource(schema: CISchema, source: string): CISchemaDefinition | null {
269
- // Special case: rules array item extraction
270
- if (source === "#/definitions/rules:item") {
271
- const rulesDef = schema.definitions?.rules;
272
- if (!rulesDef) return null;
273
- // rules is an array — get items
274
- const items = rulesDef.items;
275
- if (!items) return null;
276
- return findObjectVariant(items);
277
- }
278
-
279
270
  if (source.startsWith("#/definitions/")) {
271
+ // Array item extraction: "#/definitions/foo:item" → foo.items object variant
272
+ if (source.includes(":item")) {
273
+ const defName = source.slice("#/definitions/".length).replace(":item", "");
274
+ const arrayDef = schema.definitions?.[defName];
275
+ if (!arrayDef?.items) return null;
276
+ return findObjectVariant(arrayDef.items);
277
+ }
280
278
  const defName = source.slice("#/definitions/".length);
281
279
  return schema.definitions?.[defName] ?? null;
282
280
  }
@@ -10,7 +10,7 @@ describe("generated lexicon-gitlab.json", () => {
10
10
  const registry = JSON.parse(content);
11
11
 
12
12
  test("is valid JSON with expected entries", () => {
13
- expect(Object.keys(registry)).toHaveLength(15);
13
+ expect(Object.keys(registry)).toHaveLength(16);
14
14
  });
15
15
 
16
16
  test("contains all resource entities", () => {
@@ -30,7 +30,7 @@ describe("generated lexicon-gitlab.json", () => {
30
30
  const propertyNames = [
31
31
  "AllowFailure", "Artifacts", "AutoCancel", "Cache",
32
32
  "Environment", "Image", "Include", "Parallel",
33
- "Release", "Retry", "Rule", "Trigger",
33
+ "Release", "Retry", "Rule", "Service", "Trigger",
34
34
  ];
35
35
  for (const name of propertyNames) {
36
36
  expect(registry[name]).toBeDefined();
@@ -55,7 +55,7 @@ describe("generated index.d.ts", () => {
55
55
  "Job", "Default", "Workflow",
56
56
  "AllowFailure", "Artifacts", "AutoCancel", "Cache",
57
57
  "Environment", "Image", "Include", "Parallel",
58
- "Release", "Retry", "Rule", "Trigger",
58
+ "Release", "Retry", "Rule", "Service", "Trigger",
59
59
  ];
60
60
  for (const cls of expectedClasses) {
61
61
  expect(content).toContain(`export declare class ${cls}`);
@@ -203,6 +203,19 @@ export declare class Rule {
203
203
  });
204
204
  }
205
205
 
206
+ export declare class Service {
207
+ constructor(props: {
208
+ /** Full name of the image that should be used. It should contain the Registry part if needed. */
209
+ name: string;
210
+ alias?: string;
211
+ command?: string[];
212
+ docker?: Record<string, unknown>;
213
+ entrypoint?: string[];
214
+ pull_policy?: "always" | "never" | "if-not-present" | "always" | "never" | "if-not-present"[];
215
+ variables?: Record<string, unknown>;
216
+ });
217
+ }
218
+
206
219
  export declare class Trigger {
207
220
  constructor(props: {
208
221
  /** Path to the project, e.g. `group/project`, or `group/sub-group/project`. */
@@ -16,6 +16,7 @@ export const Parallel = createProperty("GitLab::CI::Parallel", "gitlab");
16
16
  export const Release = createProperty("GitLab::CI::Release", "gitlab");
17
17
  export const Retry = createProperty("GitLab::CI::Retry", "gitlab");
18
18
  export const Rule = createProperty("GitLab::CI::Rule", "gitlab");
19
+ export const Service = createProperty("GitLab::CI::Service", "gitlab");
19
20
  export const Trigger = createProperty("GitLab::CI::Trigger", "gitlab");
20
21
 
21
22
  // Re-exports for convenience
@@ -64,6 +64,11 @@
64
64
  "kind": "property",
65
65
  "lexicon": "gitlab"
66
66
  },
67
+ "Service": {
68
+ "resourceType": "GitLab::CI::Service",
69
+ "kind": "property",
70
+ "lexicon": "gitlab"
71
+ },
67
72
  "Trigger": {
68
73
  "resourceType": "GitLab::CI::Trigger",
69
74
  "kind": "property",
@@ -65,7 +65,7 @@ workflow:
65
65
  expect(workflow!.properties.name).toBe("My Pipeline");
66
66
  });
67
67
 
68
- test("converts snake_case keys to camelCase", () => {
68
+ test("preserves spec-native snake_case property keys", () => {
69
69
  const yaml = `
70
70
  test-job:
71
71
  stage: test
@@ -77,8 +77,8 @@ test-job:
77
77
  - npm test
78
78
  `;
79
79
  const ir = parser.parse(yaml);
80
- expect(ir.resources[0].properties.beforeScript).toEqual(["echo setup"]);
81
- expect(ir.resources[0].properties.afterScript).toEqual(["echo done"]);
80
+ expect(ir.resources[0].properties.before_script).toEqual(["echo setup"]);
81
+ expect(ir.resources[0].properties.after_script).toEqual(["echo done"]);
82
82
  });
83
83
 
84
84
  test("converts kebab-case job names to camelCase", () => {
@@ -10,6 +10,14 @@ import type { TemplateParser, TemplateIR, ResourceIR } from "@intentius/chant/im
10
10
  /**
11
11
  * Reserved top-level keys in .gitlab-ci.yml that are NOT job definitions.
12
12
  */
13
+ /**
14
+ * Convert snake_case to camelCase — used only for TS variable names (logicalId),
15
+ * NOT for spec property names.
16
+ */
17
+ function snakeToCamelCase(name: string): string {
18
+ return name.replace(/_([a-z])/g, (_, c: string) => c.toUpperCase());
19
+ }
20
+
13
21
  const RESERVED_KEYS = new Set([
14
22
  "stages",
15
23
  "variables",
@@ -24,28 +32,6 @@ const RESERVED_KEYS = new Set([
24
32
  "pages",
25
33
  ]);
26
34
 
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
35
 
50
36
  /**
51
37
  * Parse a YAML document into a plain object.
@@ -227,7 +213,7 @@ export class GitLabParser implements TemplateParser {
227
213
  resources.push({
228
214
  logicalId: "defaults",
229
215
  type: "GitLab::CI::Default",
230
- properties: camelCaseKeys(doc.default) as Record<string, unknown>,
216
+ properties: doc.default as Record<string, unknown>,
231
217
  });
232
218
  }
233
219
 
@@ -236,7 +222,7 @@ export class GitLabParser implements TemplateParser {
236
222
  resources.push({
237
223
  logicalId: "workflow",
238
224
  type: "GitLab::CI::Workflow",
239
- properties: camelCaseKeys(doc.workflow) as Record<string, unknown>,
225
+ properties: doc.workflow as Record<string, unknown>,
240
226
  });
241
227
  }
242
228
 
@@ -256,9 +242,9 @@ export class GitLabParser implements TemplateParser {
256
242
  obj.needs !== undefined
257
243
  ) {
258
244
  resources.push({
259
- logicalId: toCamelCase(key.replace(/-/g, "_")),
245
+ logicalId: snakeToCamelCase(key.replace(/-/g, "_")),
260
246
  type: "GitLab::CI::Job",
261
- properties: camelCaseKeys(obj) as Record<string, unknown>,
247
+ properties: obj as Record<string, unknown>,
262
248
  metadata: {
263
249
  originalName: key,
264
250
  stage: typeof obj.stage === "string" ? obj.stage : undefined,
@@ -1,5 +1,7 @@
1
+ import { createRequire } from "module";
1
2
  import type { CompletionContext, CompletionItem } from "@intentius/chant/lsp/types";
2
3
  import { LexiconIndex, lexiconCompletions, type LexiconEntry } from "@intentius/chant/lsp/lexicon-providers";
4
+ const require = createRequire(import.meta.url);
3
5
 
4
6
  let cachedIndex: LexiconIndex | null = null;
5
7
 
package/src/lsp/hover.ts CHANGED
@@ -1,5 +1,7 @@
1
+ import { createRequire } from "module";
1
2
  import type { HoverContext, HoverInfo } from "@intentius/chant/lsp/types";
2
3
  import { LexiconIndex, lexiconHover, type LexiconEntry } from "@intentius/chant/lsp/lexicon-providers";
4
+ const require = createRequire(import.meta.url);
3
5
 
4
6
  let cachedIndex: LexiconIndex | null = null;
5
7
 
@@ -96,7 +96,6 @@ describe("gitlabPlugin", () => {
96
96
  test("returns init templates", () => {
97
97
  const templates = gitlabPlugin.initTemplates!();
98
98
  expect(templates).toBeDefined();
99
- expect(templates["_.ts"]).toBeDefined();
100
99
  expect(templates["config.ts"]).toBeDefined();
101
100
  expect(templates["test.ts"]).toBeDefined();
102
101
  });
@@ -160,9 +159,12 @@ describe("gitlabPlugin", () => {
160
159
  test("returns skills", () => {
161
160
  const skills = gitlabPlugin.skills!();
162
161
  expect(skills).toHaveLength(1);
163
- expect(skills[0].name).toBe("gitlab-ci");
162
+ expect(skills[0].name).toBe("chant-gitlab");
164
163
  expect(skills[0].description).toBeDefined();
165
- expect(skills[0].content).toContain("GitLab CI/CD");
164
+ expect(skills[0].content).toContain("skill: chant-gitlab");
165
+ expect(skills[0].content).toContain("user-invocable: true");
166
+ expect(skills[0].content).toContain("chant build");
167
+ expect(skills[0].content).toContain("chant lint");
166
168
  expect(skills[0].triggers).toHaveLength(2);
167
169
  expect(skills[0].examples).toHaveLength(1);
168
170
  });
@@ -192,7 +194,7 @@ describe("gitlabPlugin", () => {
192
194
  const result = await catalog.handler();
193
195
  const parsed = JSON.parse(result);
194
196
  expect(Array.isArray(parsed)).toBe(true);
195
- expect(parsed.length).toBe(15);
197
+ expect(parsed.length).toBe(16);
196
198
  const job = parsed.find((e: { className: string }) => e.className === "Job");
197
199
  expect(job).toBeDefined();
198
200
  expect(job.kind).toBe("resource");
@@ -218,10 +220,6 @@ describe("gitlabPlugin", () => {
218
220
  expect(typeof gitlabPlugin.coverage).toBe("function");
219
221
  });
220
222
 
221
- test("has rollback method", () => {
222
- expect(typeof gitlabPlugin.rollback).toBe("function");
223
- });
224
-
225
223
  test("has docs method", () => {
226
224
  expect(typeof gitlabPlugin.docs).toBe("function");
227
225
  });
package/src/plugin.ts CHANGED
@@ -5,7 +5,9 @@
5
5
  * for GitLab CI/CD pipelines.
6
6
  */
7
7
 
8
+ import { createRequire } from "module";
8
9
  import type { LexiconPlugin, IntrinsicDef, SkillDefinition } from "@intentius/chant/lexicon";
10
+ const require = createRequire(import.meta.url);
9
11
  import type { LintRule } from "@intentius/chant/lint/rule";
10
12
  import type { PostSynthCheck } from "@intentius/chant/lint/post-synth";
11
13
  import { gitlabSerializer } from "./serializer";
@@ -41,20 +43,19 @@ export const gitlabPlugin: LexiconPlugin = {
41
43
 
42
44
  initTemplates(): Record<string, string> {
43
45
  return {
44
- "_.ts": `export * from "./config";\n`,
45
46
  "config.ts": `/**
46
47
  * Shared pipeline configuration
47
48
  */
48
49
 
49
- import * as gl from "@intentius/chant-lexicon-gitlab";
50
+ import { Image, Cache } from "@intentius/chant-lexicon-gitlab";
50
51
 
51
52
  // Default image for all jobs
52
- export const defaultImage = new gl.Image({
53
+ export const defaultImage = new Image({
53
54
  name: "node:20-alpine",
54
55
  });
55
56
 
56
57
  // Standard cache configuration
57
- export const npmCache = new gl.Cache({
58
+ export const npmCache = new Cache({
58
59
  key: "$CI_COMMIT_REF_SLUG",
59
60
  paths: ["node_modules/"],
60
61
  policy: "pull-push",
@@ -64,15 +65,15 @@ export const npmCache = new gl.Cache({
64
65
  * Test job
65
66
  */
66
67
 
67
- import * as gl from "@intentius/chant-lexicon-gitlab";
68
- import * as _ from "./_";
68
+ import { Job, Artifacts } from "@intentius/chant-lexicon-gitlab";
69
+ import { defaultImage, npmCache } from "./config";
69
70
 
70
- export const test = new gl.Job({
71
+ export const test = new Job({
71
72
  stage: "test",
72
- image: _.defaultImage,
73
- cache: _.npmCache,
73
+ image: defaultImage,
74
+ cache: npmCache,
74
75
  script: ["npm ci", "npm test"],
75
- artifacts: new gl.Artifacts({
76
+ artifacts: new Artifacts({
76
77
  reports: { junit: "coverage/junit.xml" },
77
78
  paths: ["coverage/"],
78
79
  expireIn: "1 week",
@@ -142,18 +143,9 @@ export const test = new gl.Job({
142
143
 
143
144
  async validate(options?: { verbose?: boolean }): Promise<void> {
144
145
  const { validate } = await import("./validate");
146
+ const { printValidationResult } = await import("@intentius/chant/codegen/validate");
145
147
  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.");
148
+ printValidationResult(result);
157
149
  },
158
150
 
159
151
  async coverage(options?: { verbose?: boolean; minOverall?: number }): Promise<void> {
@@ -166,7 +158,7 @@ export const test = new gl.Job({
166
158
 
167
159
  async package(options?: { verbose?: boolean; force?: boolean }): Promise<void> {
168
160
  const { packageLexicon } = await import("./codegen/package");
169
- const { writeFileSync, mkdirSync } = await import("fs");
161
+ const { writeBundleSpec } = await import("@intentius/chant/codegen/package");
170
162
  const { join, dirname } = await import("path");
171
163
  const { fileURLToPath } = await import("url");
172
164
 
@@ -174,53 +166,11 @@ export const test = new gl.Job({
174
166
 
175
167
  const pkgDir = dirname(dirname(fileURLToPath(import.meta.url)));
176
168
  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
- }
169
+ writeBundleSpec(spec, distDir);
195
170
 
196
171
  console.error(`Packaged ${stats.resources} entities, ${stats.ruleCount} rules, ${stats.skillCount} skills`);
197
172
  },
198
173
 
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
174
  mcpTools() {
225
175
  return [
226
176
  {
@@ -310,45 +260,67 @@ export const deploy = new Job({
310
260
  skills(): SkillDefinition[] {
311
261
  return [
312
262
  {
313
- name: "gitlab-ci",
314
- description: "GitLab CI/CD best practices and common patterns",
263
+ name: "chant-gitlab",
264
+ description: "GitLab CI/CD pipeline management workflows, patterns, and troubleshooting",
315
265
  content: `---
316
- name: gitlab-ci
317
- description: GitLab CI/CD best practices and common patterns
266
+ skill: chant-gitlab
267
+ description: Build, validate, and deploy GitLab CI pipelines from a chant project
268
+ user-invocable: true
318
269
  ---
319
270
 
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
271
+ # Deploying GitLab CI Pipelines from Chant
272
+
273
+ This project defines GitLab CI jobs as TypeScript in \`src/\`. Use these steps to build, validate, and deploy.
274
+
275
+ ## Build the pipeline
276
+
277
+ \`\`\`bash
278
+ chant build src/ --output .gitlab-ci.yml
279
+ \`\`\`
280
+
281
+ ## Validate before pushing
282
+
283
+ \`\`\`bash
284
+ chant lint src/
285
+ \`\`\`
286
+
287
+ For API-level validation against your GitLab instance:
288
+ \`\`\`bash
289
+ curl --header "PRIVATE-TOKEN: $GITLAB_TOKEN" \\
290
+ "https://gitlab.com/api/v4/ci/lint" \\
291
+ --data "{\\"content\\": \\"$(cat .gitlab-ci.yml)\\"}"
292
+ \`\`\`
293
+
294
+ ## Deploy
295
+
296
+ Commit and push the generated \`.gitlab-ci.yml\` — GitLab runs the pipeline automatically:
297
+
298
+ \`\`\`bash
299
+ chant build src/ --output .gitlab-ci.yml
300
+ git add .gitlab-ci.yml
301
+ git commit -m "Update pipeline"
302
+ git push
303
+ \`\`\`
304
+
305
+ ## Check pipeline status
306
+
307
+ - GitLab UI: project → CI/CD → Pipelines
308
+ - API: \`curl --header "PRIVATE-TOKEN: $GITLAB_TOKEN" "https://gitlab.com/api/v4/projects/$PROJECT_ID/pipelines?per_page=5"\`
309
+
310
+ ## Retry a failed job
311
+
312
+ - GitLab UI: click Retry on the failed job
313
+ - API: \`curl --request POST --header "PRIVATE-TOKEN: $GITLAB_TOKEN" "https://gitlab.com/api/v4/projects/$PROJECT_ID/jobs/$JOB_ID/retry"\`
314
+
315
+ ## Cancel a running pipeline
316
+
317
+ - API: \`curl --request POST --header "PRIVATE-TOKEN: $GITLAB_TOKEN" "https://gitlab.com/api/v4/projects/$PROJECT_ID/pipelines/$PIPELINE_ID/cancel"\`
318
+
319
+ ## Troubleshooting
320
+
321
+ - Check job logs in GitLab UI: project → CI/CD → Jobs → click the job
322
+ - \`chant lint src/\` catches: missing scripts (WGL002), deprecated only/except (WGL001), missing stages (WGL003), artifacts without expiry (WGL004)
323
+ - Post-synth checks (WGL010, WGL011) run during build
352
324
  `,
353
325
  triggers: [
354
326
  { type: "file-pattern", value: "**/*.gitlab.ts" },
@@ -121,12 +121,12 @@ describe("gitlabSerializer.serialize", () => {
121
121
  expect(output).toContain("my-test-job:");
122
122
  });
123
123
 
124
- test("converts camelCase property keys to snake_case", () => {
124
+ test("passes through spec-native snake_case property keys", () => {
125
125
  const entities = new Map<string, Declarable>();
126
126
  entities.set("job", new MockJob({
127
- beforeScript: ["echo hello"],
128
- afterScript: ["echo done"],
129
- expireIn: "1 week",
127
+ before_script: ["echo hello"],
128
+ after_script: ["echo done"],
129
+ expire_in: "1 week",
130
130
  }));
131
131
 
132
132
  const output = gitlabSerializer.serialize(entities);
@@ -270,7 +270,7 @@ describe("nested objects and arrays", () => {
270
270
 
271
271
  const output = gitlabSerializer.serialize(entities);
272
272
  expect(output).toContain("variables:");
273
- expect(output).toContain("node_env: production");
273
+ expect(output).toContain("NODE_ENV: production");
274
274
  });
275
275
 
276
276
  test("serializes arrays of strings", () => {
@@ -289,7 +289,7 @@ describe("nested objects and arrays", () => {
289
289
  const entities = new Map<string, Declarable>();
290
290
  entities.set("job", new MockJob({
291
291
  interruptible: true,
292
- allowFailure: false,
292
+ allow_failure: false,
293
293
  }));
294
294
 
295
295
  const output = gitlabSerializer.serialize(entities);
package/src/serializer.ts CHANGED
@@ -14,13 +14,6 @@ import type { LexiconOutput } from "@intentius/chant/lexicon-output";
14
14
  import { walkValue, type SerializerVisitor } from "@intentius/chant/serializer-walker";
15
15
  import { INTRINSIC_MARKER } from "@intentius/chant/intrinsic";
16
16
 
17
- /**
18
- * Convert camelCase or PascalCase to snake_case.
19
- */
20
- function toSnakeCase(name: string): string {
21
- return name.replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase();
22
- }
23
-
24
17
  /**
25
18
  * GitLab CI visitor for the generic serializer walker.
26
19
  */
@@ -36,12 +29,11 @@ function gitlabVisitor(entityNames: Map<Declarable, string>): SerializerVisitor
36
29
  const result: Record<string, unknown> = {};
37
30
  for (const [key, value] of Object.entries(props)) {
38
31
  if (value !== undefined) {
39
- result[toSnakeCase(key)] = walk(value);
32
+ result[key] = walk(value);
40
33
  }
41
34
  }
42
35
  return Object.keys(result).length > 0 ? result : undefined;
43
36
  },
44
- transformKey: toSnakeCase,
45
37
  };
46
38
  }
47
39