@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
@@ -0,0 +1,151 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { GitLabGenerator } from "./generator";
3
+ import type { TemplateIR } from "@intentius/chant/import/parser";
4
+
5
+ const generator = new GitLabGenerator();
6
+
7
+ describe("GitLabGenerator", () => {
8
+ test("generates import statement", () => {
9
+ const ir: TemplateIR = {
10
+ resources: [
11
+ { logicalId: "testJob", type: "GitLab::CI::Job", properties: { script: ["test"] } },
12
+ ],
13
+ parameters: [],
14
+ };
15
+
16
+ const files = generator.generate(ir);
17
+ expect(files).toHaveLength(1);
18
+ expect(files[0].path).toBe("main.ts");
19
+ expect(files[0].content).toContain('from "@intentius/chant-lexicon-gitlab"');
20
+ expect(files[0].content).toContain("Job");
21
+ });
22
+
23
+ test("generates Job constructor", () => {
24
+ const ir: TemplateIR = {
25
+ resources: [
26
+ {
27
+ logicalId: "buildJob",
28
+ type: "GitLab::CI::Job",
29
+ properties: {
30
+ stage: "build",
31
+ script: ["npm run build"],
32
+ },
33
+ },
34
+ ],
35
+ parameters: [],
36
+ };
37
+
38
+ const files = generator.generate(ir);
39
+ const content = files[0].content;
40
+ expect(content).toContain("export const buildJob = new Job(");
41
+ expect(content).toContain('stage: "build"');
42
+ expect(content).toContain('script: ["npm run build"]');
43
+ });
44
+
45
+ test("generates Default constructor", () => {
46
+ const ir: TemplateIR = {
47
+ resources: [
48
+ {
49
+ logicalId: "defaults",
50
+ type: "GitLab::CI::Default",
51
+ properties: { interruptible: true },
52
+ },
53
+ ],
54
+ parameters: [],
55
+ };
56
+
57
+ const files = generator.generate(ir);
58
+ expect(files[0].content).toContain("export const defaults = new Default(");
59
+ expect(files[0].content).toContain("interruptible: true");
60
+ });
61
+
62
+ test("generates Workflow constructor", () => {
63
+ const ir: TemplateIR = {
64
+ resources: [
65
+ {
66
+ logicalId: "workflow",
67
+ type: "GitLab::CI::Workflow",
68
+ properties: { name: "CI Pipeline" },
69
+ },
70
+ ],
71
+ parameters: [],
72
+ };
73
+
74
+ const files = generator.generate(ir);
75
+ expect(files[0].content).toContain("export const workflow = new Workflow(");
76
+ expect(files[0].content).toContain('"CI Pipeline"');
77
+ });
78
+
79
+ test("wraps nested property types in constructors", () => {
80
+ const ir: TemplateIR = {
81
+ resources: [
82
+ {
83
+ logicalId: "testJob",
84
+ type: "GitLab::CI::Job",
85
+ properties: {
86
+ script: ["test"],
87
+ artifacts: { paths: ["dist/"], expireIn: "1 week" },
88
+ cache: { key: "node", paths: ["node_modules/"] },
89
+ },
90
+ },
91
+ ],
92
+ parameters: [],
93
+ };
94
+
95
+ const files = generator.generate(ir);
96
+ const content = files[0].content;
97
+ expect(content).toContain("new Artifacts(");
98
+ expect(content).toContain("new Cache(");
99
+ expect(content).toContain("Artifacts");
100
+ expect(content).toContain("Cache");
101
+ });
102
+
103
+ test("wraps rules in Rule constructors", () => {
104
+ const ir: TemplateIR = {
105
+ resources: [
106
+ {
107
+ logicalId: "testJob",
108
+ type: "GitLab::CI::Job",
109
+ properties: {
110
+ script: ["test"],
111
+ rules: [{ if: "$CI_COMMIT_BRANCH", when: "always" }],
112
+ },
113
+ },
114
+ ],
115
+ parameters: [],
116
+ };
117
+
118
+ const files = generator.generate(ir);
119
+ const content = files[0].content;
120
+ expect(content).toContain("new Rule(");
121
+ expect(content).toContain("Rule");
122
+ });
123
+
124
+ test("generates multiple resources", () => {
125
+ const ir: TemplateIR = {
126
+ resources: [
127
+ { logicalId: "buildJob", type: "GitLab::CI::Job", properties: { stage: "build", script: ["build"] } },
128
+ { logicalId: "testJob", type: "GitLab::CI::Job", properties: { stage: "test", script: ["test"] } },
129
+ ],
130
+ parameters: [],
131
+ };
132
+
133
+ const files = generator.generate(ir);
134
+ const content = files[0].content;
135
+ expect(content).toContain("export const buildJob");
136
+ expect(content).toContain("export const testJob");
137
+ });
138
+
139
+ test("includes stages comment from metadata", () => {
140
+ const ir: TemplateIR = {
141
+ resources: [
142
+ { logicalId: "testJob", type: "GitLab::CI::Job", properties: { script: ["test"] } },
143
+ ],
144
+ parameters: [],
145
+ metadata: { stages: ["build", "test", "deploy"] },
146
+ };
147
+
148
+ const files = generator.generate(ir);
149
+ expect(files[0].content).toContain("Pipeline stages: build, test, deploy");
150
+ });
151
+ });
@@ -0,0 +1,173 @@
1
+ /**
2
+ * TypeScript code generator for GitLab CI import.
3
+ *
4
+ * Converts a TemplateIR (from parsed .gitlab-ci.yml) into TypeScript
5
+ * source code using the @intentius/chant-lexicon-gitlab constructors.
6
+ */
7
+
8
+ import type { TypeScriptGenerator, GeneratedFile } from "@intentius/chant/import/generator";
9
+ import type { TemplateIR, ResourceIR } from "@intentius/chant/import/parser";
10
+
11
+ /**
12
+ * Map GitLab CI entity types to their constructor names.
13
+ */
14
+ const TYPE_TO_CLASS: Record<string, string> = {
15
+ "GitLab::CI::Job": "Job",
16
+ "GitLab::CI::Default": "Default",
17
+ "GitLab::CI::Workflow": "Workflow",
18
+ };
19
+
20
+ /**
21
+ * Properties that reference known property entities.
22
+ */
23
+ const PROPERTY_CONSTRUCTORS: Record<string, string> = {
24
+ artifacts: "Artifacts",
25
+ cache: "Cache",
26
+ image: "Image",
27
+ retry: "Retry",
28
+ allowFailure: "AllowFailure",
29
+ parallel: "Parallel",
30
+ environment: "Environment",
31
+ trigger: "Trigger",
32
+ autoCancel: "AutoCancel",
33
+ };
34
+
35
+ /**
36
+ * Generate TypeScript source code from a GitLab CI IR.
37
+ */
38
+ export class GitLabGenerator implements TypeScriptGenerator {
39
+ generate(ir: TemplateIR): GeneratedFile[] {
40
+ const lines: string[] = [];
41
+
42
+ // Collect which constructors are needed
43
+ const usedConstructors = new Set<string>();
44
+ for (const resource of ir.resources) {
45
+ const cls = TYPE_TO_CLASS[resource.type];
46
+ if (cls) usedConstructors.add(cls);
47
+
48
+ // Check properties for nested constructors
49
+ this.collectNestedConstructors(resource.properties, usedConstructors);
50
+ }
51
+
52
+ // Import statement
53
+ const imports = [...usedConstructors].sort().join(", ");
54
+ lines.push(`import { ${imports} } from "@intentius/chant-lexicon-gitlab";`);
55
+ lines.push("");
56
+
57
+ // Emit stages if present in metadata
58
+ if (ir.metadata?.stages && Array.isArray(ir.metadata.stages)) {
59
+ lines.push(`// Pipeline stages: ${(ir.metadata.stages as string[]).join(", ")}`);
60
+ lines.push("");
61
+ }
62
+
63
+ // Emit includes as comments
64
+ if (ir.metadata?.include) {
65
+ lines.push("// Imported includes (not converted):");
66
+ const includes = Array.isArray(ir.metadata.include) ? ir.metadata.include : [ir.metadata.include];
67
+ for (const inc of includes) {
68
+ if (typeof inc === "string") {
69
+ lines.push(`// - ${inc}`);
70
+ } else if (typeof inc === "object" && inc !== null) {
71
+ lines.push(`// - ${JSON.stringify(inc)}`);
72
+ }
73
+ }
74
+ lines.push("");
75
+ }
76
+
77
+ // Emit resources
78
+ for (const resource of ir.resources) {
79
+ const cls = TYPE_TO_CLASS[resource.type];
80
+ if (!cls) continue;
81
+
82
+ const varName = resource.logicalId;
83
+ const propsStr = this.emitProps(resource.properties, 1);
84
+
85
+ lines.push(`export const ${varName} = new ${cls}(${propsStr});`);
86
+ lines.push("");
87
+ }
88
+
89
+ return [{ path: "main.ts", content: lines.join("\n") }];
90
+ }
91
+
92
+ private collectNestedConstructors(props: Record<string, unknown>, used: Set<string>): void {
93
+ for (const [key, value] of Object.entries(props)) {
94
+ const constructor = PROPERTY_CONSTRUCTORS[key];
95
+ if (constructor && typeof value === "object" && value !== null && !Array.isArray(value)) {
96
+ used.add(constructor);
97
+ }
98
+ if (key === "rules" && Array.isArray(value)) {
99
+ used.add("Rule");
100
+ }
101
+ }
102
+ }
103
+
104
+ private emitProps(props: Record<string, unknown>, depth: number): string {
105
+ const indent = " ".repeat(depth);
106
+ const innerIndent = " ".repeat(depth + 1);
107
+ const entries: string[] = [];
108
+
109
+ for (const [key, value] of Object.entries(props)) {
110
+ if (value === undefined || value === null) continue;
111
+ const emitted = this.emitValue(key, value, depth + 1);
112
+ entries.push(`${innerIndent}${key}: ${emitted},`);
113
+ }
114
+
115
+ if (entries.length === 0) return "{}";
116
+ return `{\n${entries.join("\n")}\n${indent}}`;
117
+ }
118
+
119
+ private emitValue(key: string, value: unknown, depth: number): string {
120
+ if (value === null || value === undefined) return "undefined";
121
+
122
+ // Check if this key maps to a property constructor
123
+ const constructor = PROPERTY_CONSTRUCTORS[key];
124
+ if (constructor && typeof value === "object" && !Array.isArray(value)) {
125
+ const propsStr = this.emitProps(value as Record<string, unknown>, depth);
126
+ return `new ${constructor}(${propsStr})`;
127
+ }
128
+
129
+ // Rules array — wrap each item in Rule constructor
130
+ if (key === "rules" && Array.isArray(value)) {
131
+ const items = value.map((item) => {
132
+ if (typeof item === "object" && item !== null) {
133
+ const propsStr = this.emitProps(item as Record<string, unknown>, depth + 1);
134
+ return `new Rule(${propsStr})`;
135
+ }
136
+ return JSON.stringify(item);
137
+ });
138
+ const indent = " ".repeat(depth);
139
+ const innerIndent = " ".repeat(depth + 1);
140
+ return `[\n${items.map((i) => `${innerIndent}${i},`).join("\n")}\n${indent}]`;
141
+ }
142
+
143
+ return this.emitLiteral(value, depth);
144
+ }
145
+
146
+ private emitLiteral(value: unknown, depth: number): string {
147
+ if (value === null || value === undefined) return "undefined";
148
+ if (typeof value === "string") return JSON.stringify(value);
149
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
150
+
151
+ if (Array.isArray(value)) {
152
+ if (value.length === 0) return "[]";
153
+ const items = value.map((item) => this.emitLiteral(item, depth + 1));
154
+ // Short arrays on one line
155
+ const oneLine = `[${items.join(", ")}]`;
156
+ if (oneLine.length < 80) return oneLine;
157
+ const indent = " ".repeat(depth);
158
+ const innerIndent = " ".repeat(depth + 1);
159
+ return `[\n${items.map((i) => `${innerIndent}${i},`).join("\n")}\n${indent}]`;
160
+ }
161
+
162
+ if (typeof value === "object") {
163
+ const entries = Object.entries(value as Record<string, unknown>);
164
+ if (entries.length === 0) return "{}";
165
+ const indent = " ".repeat(depth);
166
+ const innerIndent = " ".repeat(depth + 1);
167
+ const items = entries.map(([k, v]) => `${innerIndent}${k}: ${this.emitLiteral(v, depth + 1)},`);
168
+ return `{\n${items.join("\n")}\n${indent}}`;
169
+ }
170
+
171
+ return String(value);
172
+ }
173
+ }
@@ -0,0 +1,160 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { GitLabParser } from "./parser";
3
+
4
+ const parser = new GitLabParser();
5
+
6
+ describe("GitLabParser", () => {
7
+ test("parses simple pipeline with one job", () => {
8
+ const yaml = `
9
+ stages:
10
+ - test
11
+
12
+ test-job:
13
+ stage: test
14
+ script:
15
+ - npm test
16
+ `;
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"]);
22
+ });
23
+
24
+ test("parses multiple jobs", () => {
25
+ const yaml = `
26
+ stages:
27
+ - build
28
+ - test
29
+
30
+ build-job:
31
+ stage: build
32
+ script:
33
+ - make build
34
+
35
+ test-job:
36
+ stage: test
37
+ script:
38
+ - make test
39
+ `;
40
+ const ir = parser.parse(yaml);
41
+ const jobs = ir.resources.filter((r) => r.type === "GitLab::CI::Job");
42
+ expect(jobs).toHaveLength(2);
43
+ });
44
+
45
+ test("parses default settings", () => {
46
+ const yaml = `
47
+ default:
48
+ interruptible: true
49
+ timeout: 30 minutes
50
+ `;
51
+ const ir = parser.parse(yaml);
52
+ const defaults = ir.resources.find((r) => r.type === "GitLab::CI::Default");
53
+ expect(defaults).toBeDefined();
54
+ expect(defaults!.properties.interruptible).toBe(true);
55
+ });
56
+
57
+ test("parses workflow", () => {
58
+ const yaml = `
59
+ workflow:
60
+ name: My Pipeline
61
+ `;
62
+ const ir = parser.parse(yaml);
63
+ const workflow = ir.resources.find((r) => r.type === "GitLab::CI::Workflow");
64
+ expect(workflow).toBeDefined();
65
+ expect(workflow!.properties.name).toBe("My Pipeline");
66
+ });
67
+
68
+ test("converts snake_case keys to camelCase", () => {
69
+ const yaml = `
70
+ test-job:
71
+ stage: test
72
+ before_script:
73
+ - echo setup
74
+ after_script:
75
+ - echo done
76
+ script:
77
+ - npm test
78
+ `;
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"]);
82
+ });
83
+
84
+ test("converts kebab-case job names to camelCase", () => {
85
+ const yaml = `
86
+ my-test-job:
87
+ stage: test
88
+ script:
89
+ - test
90
+ `;
91
+ const ir = parser.parse(yaml);
92
+ expect(ir.resources[0].logicalId).toBe("myTestJob");
93
+ });
94
+
95
+ test("preserves original name in metadata", () => {
96
+ const yaml = `
97
+ deploy-prod:
98
+ stage: deploy
99
+ script:
100
+ - deploy.sh
101
+ `;
102
+ const ir = parser.parse(yaml);
103
+ expect(ir.resources[0].metadata?.originalName).toBe("deploy-prod");
104
+ });
105
+
106
+ test("records stages in metadata", () => {
107
+ const yaml = `
108
+ stages:
109
+ - build
110
+ - test
111
+ - deploy
112
+
113
+ test-job:
114
+ stage: test
115
+ script:
116
+ - npm test
117
+ `;
118
+ const ir = parser.parse(yaml);
119
+ expect(ir.metadata?.stages).toEqual(["build", "test", "deploy"]);
120
+ });
121
+
122
+ test("skips reserved keys as jobs", () => {
123
+ const yaml = `
124
+ stages:
125
+ - test
126
+ variables:
127
+ NODE_ENV: production
128
+ include:
129
+ - local: shared.yml
130
+
131
+ test-job:
132
+ stage: test
133
+ script:
134
+ - npm test
135
+ `;
136
+ const ir = parser.parse(yaml);
137
+ // Only the job should be in resources (not stages, variables, include)
138
+ const jobs = ir.resources.filter((r) => r.type === "GitLab::CI::Job");
139
+ expect(jobs).toHaveLength(1);
140
+ });
141
+
142
+ test("handles JSON input", () => {
143
+ const json = JSON.stringify({
144
+ stages: ["test"],
145
+ "test-job": { stage: "test", script: ["npm test"] },
146
+ });
147
+ const ir = parser.parse(json);
148
+ expect(ir.resources).toHaveLength(1);
149
+ });
150
+
151
+ test("returns empty parameters (GitLab CI has no parameters)", () => {
152
+ const yaml = `
153
+ test-job:
154
+ script:
155
+ - test
156
+ `;
157
+ const ir = parser.parse(yaml);
158
+ expect(ir.parameters).toEqual([]);
159
+ });
160
+ });