@intentius/chant-lexicon-gitlab 0.0.6 → 0.0.9

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 (52) hide show
  1. package/dist/integrity.json +10 -6
  2. package/dist/manifest.json +1 -1
  3. package/dist/meta.json +186 -8
  4. package/dist/rules/wgl012.ts +86 -0
  5. package/dist/rules/wgl013.ts +62 -0
  6. package/dist/rules/wgl014.ts +51 -0
  7. package/dist/rules/wgl015.ts +85 -0
  8. package/dist/rules/yaml-helpers.ts +65 -3
  9. package/dist/skills/chant-gitlab.md +502 -0
  10. package/dist/types/index.d.ts +55 -16
  11. package/package.json +2 -2
  12. package/src/codegen/__snapshots__/snapshot.test.ts.snap +58 -0
  13. package/src/codegen/docs.ts +88 -11
  14. package/src/codegen/generate-lexicon.ts +6 -1
  15. package/src/codegen/generate.ts +45 -50
  16. package/src/codegen/naming.ts +3 -0
  17. package/src/codegen/package.ts +2 -0
  18. package/src/codegen/parse.test.ts +154 -4
  19. package/src/codegen/parse.ts +161 -49
  20. package/src/codegen/snapshot.test.ts +7 -5
  21. package/src/composites/composites.test.ts +452 -0
  22. package/src/composites/docker-build.ts +81 -0
  23. package/src/composites/index.ts +8 -0
  24. package/src/composites/node-pipeline.ts +104 -0
  25. package/src/composites/python-pipeline.ts +75 -0
  26. package/src/composites/review-app.ts +63 -0
  27. package/src/generated/index.d.ts +55 -16
  28. package/src/generated/index.ts +3 -0
  29. package/src/generated/lexicon-gitlab.json +186 -8
  30. package/src/import/generator.ts +3 -2
  31. package/src/import/parser.test.ts +3 -3
  32. package/src/import/parser.ts +12 -26
  33. package/src/index.ts +4 -0
  34. package/src/lint/post-synth/wgl012.test.ts +131 -0
  35. package/src/lint/post-synth/wgl012.ts +86 -0
  36. package/src/lint/post-synth/wgl013.test.ts +164 -0
  37. package/src/lint/post-synth/wgl013.ts +62 -0
  38. package/src/lint/post-synth/wgl014.test.ts +97 -0
  39. package/src/lint/post-synth/wgl014.ts +51 -0
  40. package/src/lint/post-synth/wgl015.test.ts +139 -0
  41. package/src/lint/post-synth/wgl015.ts +85 -0
  42. package/src/lint/post-synth/yaml-helpers.ts +65 -3
  43. package/src/lsp/completions.ts +2 -0
  44. package/src/lsp/hover.ts +2 -0
  45. package/src/plugin.test.ts +44 -19
  46. package/src/plugin.ts +671 -76
  47. package/src/serializer.test.ts +146 -6
  48. package/src/serializer.ts +64 -14
  49. package/src/validate.ts +1 -0
  50. package/src/variables.ts +4 -0
  51. package/dist/skills/gitlab-ci.md +0 -37
  52. package/src/codegen/rollback.ts +0 -26
@@ -1,6 +1,7 @@
1
1
  import { describe, test, expect } from "bun:test";
2
2
  import { gitlabSerializer } from "./serializer";
3
3
  import { DECLARABLE_MARKER, type Declarable } from "@intentius/chant/declarable";
4
+ import { createProperty, createResource } from "@intentius/chant/runtime";
4
5
 
5
6
  // ── Mock entities ──────────────────────────────────────────────────
6
7
 
@@ -121,12 +122,12 @@ describe("gitlabSerializer.serialize", () => {
121
122
  expect(output).toContain("my-test-job:");
122
123
  });
123
124
 
124
- test("converts camelCase property keys to snake_case", () => {
125
+ test("passes through spec-native snake_case property keys", () => {
125
126
  const entities = new Map<string, Declarable>();
126
127
  entities.set("job", new MockJob({
127
- beforeScript: ["echo hello"],
128
- afterScript: ["echo done"],
129
- expireIn: "1 week",
128
+ before_script: ["echo hello"],
129
+ after_script: ["echo done"],
130
+ expire_in: "1 week",
130
131
  }));
131
132
 
132
133
  const output = gitlabSerializer.serialize(entities);
@@ -258,6 +259,145 @@ describe("string quoting", () => {
258
259
  });
259
260
  });
260
261
 
262
+ describe("runtime-style entities (non-enumerable props)", () => {
263
+ // Real generated entities use createResource/createProperty which define
264
+ // DECLARABLE_MARKER, entityType, kind, and props as non-enumerable.
265
+ // Object.entries() skips non-enumerable properties — the serializer must
266
+ // handle these correctly.
267
+
268
+ const RuntimeImage = createProperty("GitLab::CI::Image", "gitlab");
269
+ const RuntimeCache = createProperty("GitLab::CI::Cache", "gitlab");
270
+ const RuntimeArtifacts = createProperty("GitLab::CI::Artifacts", "gitlab");
271
+ const RuntimeRule = createProperty("GitLab::CI::Rule", "gitlab");
272
+ const RuntimeEnvironment = createProperty("GitLab::CI::Environment", "gitlab");
273
+ const RuntimeJob = createResource("GitLab::CI::Job", "gitlab", {});
274
+
275
+ test("serializes runtime-style property entities in jobs", () => {
276
+ const image = new RuntimeImage({ name: "node:20-alpine" });
277
+ const cache = new RuntimeCache({
278
+ key: "$CI_COMMIT_REF_SLUG",
279
+ paths: ["node_modules/"],
280
+ policy: "pull-push",
281
+ });
282
+ const artifacts = new RuntimeArtifacts({
283
+ paths: ["dist/"],
284
+ expire_in: "1 hour",
285
+ });
286
+ const job = new RuntimeJob({
287
+ stage: "build",
288
+ image,
289
+ cache,
290
+ script: ["npm ci", "npm run build"],
291
+ artifacts,
292
+ });
293
+
294
+ const entities = new Map<string, Declarable>();
295
+ entities.set("build", job as unknown as Declarable);
296
+
297
+ const output = gitlabSerializer.serialize(entities);
298
+
299
+ // Image must expand to its properties, not {}
300
+ expect(output).toContain("image:");
301
+ expect(output).toContain("name: node:20-alpine");
302
+ expect(output).not.toContain("image: {}");
303
+
304
+ // Cache must expand
305
+ expect(output).toContain("cache:");
306
+ expect(output).toContain("paths:");
307
+ expect(output).toContain("- node_modules/");
308
+ expect(output).not.toContain("cache: {}");
309
+
310
+ // Artifacts must expand
311
+ expect(output).toContain("artifacts:");
312
+ expect(output).toContain("- dist/");
313
+ expect(output).not.toContain("artifacts: {}");
314
+ });
315
+
316
+ test("serializes runtime-style rules array", () => {
317
+ const rule1 = new RuntimeRule({ if: "$CI_MERGE_REQUEST_IID" });
318
+ const rule2 = new RuntimeRule({ if: "$CI_COMMIT_BRANCH" });
319
+ const job = new RuntimeJob({
320
+ stage: "test",
321
+ script: ["npm test"],
322
+ rules: [rule1, rule2],
323
+ });
324
+
325
+ const entities = new Map<string, Declarable>();
326
+ entities.set("test", job as unknown as Declarable);
327
+
328
+ const output = gitlabSerializer.serialize(entities);
329
+ expect(output).toContain("rules:");
330
+ expect(output).toContain("if: '$CI_MERGE_REQUEST_IID'");
331
+ expect(output).toContain("if: '$CI_COMMIT_BRANCH'");
332
+ });
333
+
334
+ test("serializes runtime-style environment", () => {
335
+ const env = new RuntimeEnvironment({
336
+ name: "production",
337
+ url: "https://example.com",
338
+ });
339
+ const job = new RuntimeJob({
340
+ stage: "deploy",
341
+ script: ["deploy.sh"],
342
+ environment: env,
343
+ });
344
+
345
+ const entities = new Map<string, Declarable>();
346
+ entities.set("deploy", job as unknown as Declarable);
347
+
348
+ const output = gitlabSerializer.serialize(entities);
349
+ expect(output).toContain("environment:");
350
+ expect(output).toContain("name: production");
351
+ expect(output).toContain("url: https://example.com");
352
+ expect(output).not.toContain("environment: {}");
353
+ });
354
+ });
355
+
356
+ describe("array-of-objects YAML formatting", () => {
357
+ test("serializes cache array with nested key object correctly", () => {
358
+ const entities = new Map<string, Declarable>();
359
+ entities.set("job", new MockJob({
360
+ stage: "build",
361
+ script: ["npm ci"],
362
+ cache: [
363
+ {
364
+ key: { files: ["package-lock.json"] },
365
+ paths: [".npm/"],
366
+ policy: "pull-push",
367
+ },
368
+ ],
369
+ }));
370
+
371
+ const output = gitlabSerializer.serialize(entities);
372
+ // The key object should be on a new line, properly indented
373
+ expect(output).toContain("cache:");
374
+ expect(output).toContain("- key:");
375
+ expect(output).toContain("files:");
376
+ expect(output).toContain("- package-lock.json");
377
+ expect(output).toContain("paths:");
378
+ expect(output).toContain("- .npm/");
379
+ expect(output).toContain("policy: pull-push");
380
+ // The key value should NOT be inlined as "key: files:"
381
+ expect(output).not.toMatch(/key: files:/);
382
+ });
383
+
384
+ test("serializes services array with nested objects", () => {
385
+ const entities = new Map<string, Declarable>();
386
+ entities.set("job", new MockJob({
387
+ stage: "build",
388
+ script: ["docker build ."],
389
+ services: [
390
+ { name: "docker:27-dind", alias: "docker" },
391
+ ],
392
+ }));
393
+
394
+ const output = gitlabSerializer.serialize(entities);
395
+ expect(output).toContain("services:");
396
+ expect(output).toContain("- name: docker:27-dind");
397
+ expect(output).toContain("alias: docker");
398
+ });
399
+ });
400
+
261
401
  describe("nested objects and arrays", () => {
262
402
  test("serializes nested objects", () => {
263
403
  const entities = new Map<string, Declarable>();
@@ -270,7 +410,7 @@ describe("nested objects and arrays", () => {
270
410
 
271
411
  const output = gitlabSerializer.serialize(entities);
272
412
  expect(output).toContain("variables:");
273
- expect(output).toContain("node_env: production");
413
+ expect(output).toContain("NODE_ENV: production");
274
414
  });
275
415
 
276
416
  test("serializes arrays of strings", () => {
@@ -289,7 +429,7 @@ describe("nested objects and arrays", () => {
289
429
  const entities = new Map<string, Declarable>();
290
430
  entities.set("job", new MockJob({
291
431
  interruptible: true,
292
- allowFailure: false,
432
+ allow_failure: false,
293
433
  }));
294
434
 
295
435
  const output = gitlabSerializer.serialize(entities);
package/src/serializer.ts CHANGED
@@ -14,20 +14,13 @@ 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
  */
27
20
  function gitlabVisitor(entityNames: Map<Declarable, string>): SerializerVisitor {
28
21
  return {
29
22
  attrRef: (name, _attr) => name,
30
- resourceRef: (name) => name,
23
+ resourceRef: (name) => name.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase(),
31
24
  propertyDeclarable: (entity, walk) => {
32
25
  if (!("props" in entity) || typeof entity.props !== "object" || entity.props === null) {
33
26
  return undefined;
@@ -36,20 +29,57 @@ 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
 
40
+ /**
41
+ * Pre-process values to convert intrinsics to their YAML representation
42
+ * before the walker (which would call toJSON instead of toYAML).
43
+ *
44
+ * IMPORTANT: Must not touch Declarable objects — their identity markers
45
+ * (DECLARABLE_MARKER, entityType, kind, props) are non-enumerable and
46
+ * would be stripped by Object.entries(), producing empty `{}` output.
47
+ */
48
+ function preprocessIntrinsics(value: unknown): unknown {
49
+ if (value === null || value === undefined) return value;
50
+
51
+ if (typeof value === "object" && INTRINSIC_MARKER in value) {
52
+ if ("toYAML" in value && typeof value.toYAML === "function") {
53
+ return (value as { toYAML(): unknown }).toYAML();
54
+ }
55
+ }
56
+
57
+ // Leave Declarables untouched — the walker handles them
58
+ if (typeof value === "object" && value !== null && "entityType" in value) {
59
+ return value;
60
+ }
61
+
62
+ if (Array.isArray(value)) {
63
+ return value.map(preprocessIntrinsics);
64
+ }
65
+
66
+ if (typeof value === "object") {
67
+ const result: Record<string, unknown> = {};
68
+ for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
69
+ result[k] = preprocessIntrinsics(v);
70
+ }
71
+ return result;
72
+ }
73
+
74
+ return value;
75
+ }
76
+
48
77
  /**
49
78
  * Convert a value to YAML-compatible form using the walker.
50
79
  */
51
80
  function toYAMLValue(value: unknown, entityNames: Map<Declarable, string>): unknown {
52
- return walkValue(value, entityNames, gitlabVisitor(entityNames));
81
+ const preprocessed = preprocessIntrinsics(value);
82
+ return walkValue(preprocessed, entityNames, gitlabVisitor(entityNames));
53
83
  }
54
84
 
55
85
  /**
@@ -106,10 +136,21 @@ function emitYAML(value: unknown, indent: number): string {
106
136
  const entries = Object.entries(item as Record<string, unknown>);
107
137
  if (entries.length > 0) {
108
138
  const [firstKey, firstVal] = entries[0];
109
- lines.push(`${prefix}- ${firstKey}: ${emitYAML(firstVal, indent + 2).trimStart()}`);
139
+ const firstEmitted = emitYAML(firstVal, indent + 2);
140
+ if (firstEmitted.startsWith("\n")) {
141
+ // Multi-line value: put on next line, indented under the key
142
+ lines.push(`${prefix}- ${firstKey}:${firstEmitted}`);
143
+ } else {
144
+ lines.push(`${prefix}- ${firstKey}: ${firstEmitted}`);
145
+ }
110
146
  for (let i = 1; i < entries.length; i++) {
111
147
  const [key, val] = entries[i];
112
- lines.push(`${prefix} ${key}: ${emitYAML(val, indent + 2).trimStart()}`);
148
+ const emitted = emitYAML(val, indent + 2);
149
+ if (emitted.startsWith("\n")) {
150
+ lines.push(`${prefix} ${key}:${emitted}`);
151
+ } else {
152
+ lines.push(`${prefix} ${key}: ${emitted}`);
153
+ }
113
154
  }
114
155
  }
115
156
  } else {
@@ -120,7 +161,16 @@ function emitYAML(value: unknown, indent: number): string {
120
161
  }
121
162
 
122
163
  if (typeof value === "object") {
123
- const entries = Object.entries(value as Record<string, unknown>);
164
+ const obj = value as Record<string, unknown>;
165
+
166
+ // Handle tagged values (intrinsics like !reference)
167
+ if ("tag" in obj && "value" in obj && typeof obj.tag === "string") {
168
+ if (obj.tag === "!reference" && Array.isArray(obj.value)) {
169
+ return `!reference [${(obj.value as string[]).join(", ")}]`;
170
+ }
171
+ }
172
+
173
+ const entries = Object.entries(obj);
124
174
  if (entries.length === 0) return "{}";
125
175
  const lines: string[] = [];
126
176
  for (const [key, val] of entries) {
package/src/validate.ts CHANGED
@@ -16,6 +16,7 @@ const REQUIRED_NAMES = [
16
16
  "Artifacts", "Cache", "Image", "Service", "Rule", "Retry",
17
17
  "AllowFailure", "Parallel", "Include", "Release",
18
18
  "Environment", "Trigger", "AutoCancel",
19
+ "WorkflowRule", "Need", "Inherit",
19
20
  ];
20
21
 
21
22
  /**
package/src/variables.ts CHANGED
@@ -8,10 +8,12 @@
8
8
  export const CI = {
9
9
  CommitBranch: "$CI_COMMIT_BRANCH",
10
10
  CommitRef: "$CI_COMMIT_REF_NAME",
11
+ CommitRefSlug: "$CI_COMMIT_REF_SLUG",
11
12
  CommitSha: "$CI_COMMIT_SHA",
12
13
  CommitTag: "$CI_COMMIT_TAG",
13
14
  DefaultBranch: "$CI_DEFAULT_BRANCH",
14
15
  Environment: "$CI_ENVIRONMENT_NAME",
16
+ EnvironmentSlug: "$CI_ENVIRONMENT_SLUG",
15
17
  JobId: "$CI_JOB_ID",
16
18
  JobName: "$CI_JOB_NAME",
17
19
  JobStage: "$CI_JOB_STAGE",
@@ -24,4 +26,6 @@ export const CI = {
24
26
  ProjectPath: "$CI_PROJECT_PATH",
25
27
  Registry: "$CI_REGISTRY",
26
28
  RegistryImage: "$CI_REGISTRY_IMAGE",
29
+ RegistryUser: "$CI_REGISTRY_USER",
30
+ RegistryPassword: "$CI_REGISTRY_PASSWORD",
27
31
  } as const;
@@ -1,37 +0,0 @@
1
- ---
2
- name: gitlab-ci
3
- description: GitLab CI/CD best practices and common patterns
4
- ---
5
-
6
- # GitLab CI/CD with Chant
7
-
8
- ## Common Entity Types
9
-
10
- - `Job` — Pipeline job definition
11
- - `Default` — Default settings inherited by all jobs
12
- - `Workflow` — Pipeline-level configuration
13
- - `Artifacts` — Job artifact configuration
14
- - `Cache` — Cache configuration
15
- - `Image` — Docker image for a job
16
- - `Rule` — Conditional execution rule
17
- - `Environment` — Deployment environment
18
- - `Trigger` — Trigger downstream pipeline
19
- - `Include` — Include external CI configuration
20
-
21
- ## Predefined Variables
22
-
23
- - `CI.CommitBranch` — Current branch name
24
- - `CI.CommitSha` — Current commit SHA
25
- - `CI.PipelineSource` — What triggered the pipeline
26
- - `CI.ProjectPath` — Project path (group/project)
27
- - `CI.Registry` — Container registry URL
28
- - `CI.MergeRequestIid` — MR internal ID
29
-
30
- ## Best Practices
31
-
32
- 1. **Use stages** — Organize jobs into logical stages (build, test, deploy)
33
- 2. **Cache dependencies** — Cache node_modules, pip packages, etc.
34
- 3. **Use rules** — Prefer `rules:` over `only:/except:` for conditional execution
35
- 4. **Minimize artifacts** — Only preserve files needed by later stages
36
- 5. **Use includes** — Share common configuration across projects
37
- 6. **Set timeouts** — Prevent stuck jobs from blocking pipelines
@@ -1,26 +0,0 @@
1
- /**
2
- * Rollback and snapshot management for GitLab CI lexicon.
3
- *
4
- * Wraps the core rollback module with GitLab-specific artifact names.
5
- */
6
-
7
- export type { ArtifactSnapshot, SnapshotInfo } from "@intentius/chant/codegen/rollback";
8
- export {
9
- snapshotArtifacts,
10
- saveSnapshot,
11
- restoreSnapshot,
12
- listSnapshots,
13
- } from "@intentius/chant/codegen/rollback";
14
-
15
- /**
16
- * GitLab-specific artifact filenames to snapshot.
17
- */
18
- export const GITLAB_ARTIFACT_NAMES = ["lexicon-gitlab.json", "index.d.ts", "index.ts"];
19
-
20
- /**
21
- * Snapshot GitLab lexicon artifacts.
22
- */
23
- export function snapshotGitLabArtifacts(generatedDir: string) {
24
- const { snapshotArtifacts } = require("@intentius/chant/codegen/rollback");
25
- return snapshotArtifacts(generatedDir, GITLAB_ARTIFACT_NAMES);
26
- }