@intentius/chant-lexicon-gitlab 0.0.16 → 0.0.22
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.
- package/dist/integrity.json +17 -4
- package/dist/manifest.json +1 -1
- package/dist/rules/wgl016.ts +82 -0
- package/dist/rules/wgl017.ts +54 -0
- package/dist/rules/wgl018.ts +39 -0
- package/dist/rules/wgl019.ts +44 -0
- package/dist/rules/wgl020.ts +56 -0
- package/dist/rules/wgl021.ts +62 -0
- package/dist/rules/wgl022.ts +44 -0
- package/dist/rules/wgl023.ts +51 -0
- package/dist/rules/wgl024.ts +46 -0
- package/dist/rules/wgl025.ts +49 -0
- package/dist/rules/wgl026.ts +67 -0
- package/dist/rules/wgl027.ts +54 -0
- package/dist/rules/wgl028.ts +67 -0
- package/dist/rules/yaml-helpers.ts +82 -0
- package/dist/skills/chant-gitlab.md +2 -2
- package/package.json +20 -2
- package/src/codegen/fetch.test.ts +30 -0
- package/src/codegen/generate.test.ts +65 -0
- package/src/codegen/idempotency.test.ts +28 -0
- package/src/codegen/naming.test.ts +93 -0
- package/src/codegen/snapshot.test.ts +28 -19
- package/src/composites/composites.test.ts +160 -0
- package/src/coverage.test.ts +15 -7
- package/src/import/roundtrip.test.ts +132 -0
- package/src/lint/post-synth/wgl016.test.ts +72 -0
- package/src/lint/post-synth/wgl016.ts +82 -0
- package/src/lint/post-synth/wgl017.test.ts +53 -0
- package/src/lint/post-synth/wgl017.ts +54 -0
- package/src/lint/post-synth/wgl018.test.ts +69 -0
- package/src/lint/post-synth/wgl018.ts +39 -0
- package/src/lint/post-synth/wgl019.test.ts +76 -0
- package/src/lint/post-synth/wgl019.ts +44 -0
- package/src/lint/post-synth/wgl020.test.ts +54 -0
- package/src/lint/post-synth/wgl020.ts +56 -0
- package/src/lint/post-synth/wgl021.test.ts +62 -0
- package/src/lint/post-synth/wgl021.ts +62 -0
- package/src/lint/post-synth/wgl022.test.ts +86 -0
- package/src/lint/post-synth/wgl022.ts +44 -0
- package/src/lint/post-synth/wgl023.test.ts +88 -0
- package/src/lint/post-synth/wgl023.ts +51 -0
- package/src/lint/post-synth/wgl024.test.ts +77 -0
- package/src/lint/post-synth/wgl024.ts +46 -0
- package/src/lint/post-synth/wgl025.test.ts +85 -0
- package/src/lint/post-synth/wgl025.ts +49 -0
- package/src/lint/post-synth/wgl026.test.ts +87 -0
- package/src/lint/post-synth/wgl026.ts +67 -0
- package/src/lint/post-synth/wgl027.test.ts +84 -0
- package/src/lint/post-synth/wgl027.ts +54 -0
- package/src/lint/post-synth/wgl028.test.ts +95 -0
- package/src/lint/post-synth/wgl028.ts +67 -0
- package/src/lint/post-synth/yaml-helpers.ts +82 -0
- package/src/lsp/completions.test.ts +16 -6
- package/src/lsp/hover.test.ts +18 -7
- package/src/plugin.test.ts +14 -1
- package/src/plugin.ts +20 -3
- package/src/testdata/pipelines/deploy-envs.gitlab-ci.yml +60 -0
- package/src/testdata/pipelines/docker-build.gitlab-ci.yml +41 -0
- package/src/testdata/pipelines/includes-templates.gitlab-ci.yml +52 -0
- package/src/testdata/pipelines/monorepo.gitlab-ci.yml +51 -0
- package/src/testdata/pipelines/multi-stage.gitlab-ci.yml +56 -0
- package/src/testdata/pipelines/simple.gitlab-ci.yml +9 -0
- package/src/validate.test.ts +12 -6
- package/src/variables.test.ts +58 -0
|
@@ -98,6 +98,46 @@ describe("DockerBuild", () => {
|
|
|
98
98
|
expect(expanded.has("dockerBuild")).toBe(true);
|
|
99
99
|
expect(expanded.size).toBe(1);
|
|
100
100
|
});
|
|
101
|
+
|
|
102
|
+
test("default dockerfile and context", () => {
|
|
103
|
+
const instance = DockerBuild({});
|
|
104
|
+
const props = (instance.build as any).props;
|
|
105
|
+
expect(props.script[0]).toContain("-f Dockerfile");
|
|
106
|
+
expect(props.script[0]).toMatch(/\.\s*$/);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("custom dockerfile path", () => {
|
|
110
|
+
const instance = DockerBuild({ dockerfile: "docker/app.Dockerfile" });
|
|
111
|
+
const props = (instance.build as any).props;
|
|
112
|
+
expect(props.script[0]).toContain("-f docker/app.Dockerfile");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("custom context directory", () => {
|
|
116
|
+
const instance = DockerBuild({ context: "./app" });
|
|
117
|
+
const props = (instance.build as any).props;
|
|
118
|
+
expect(props.script[0]).toContain("./app");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("empty buildArgs produces no --build-arg flags", () => {
|
|
122
|
+
const instance = DockerBuild({ buildArgs: {} });
|
|
123
|
+
const props = (instance.build as any).props;
|
|
124
|
+
expect(props.script[0]).not.toContain("--build-arg");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("multiple buildArgs are all present", () => {
|
|
128
|
+
const instance = DockerBuild({
|
|
129
|
+
buildArgs: { NODE_ENV: "production", VERSION: "1.0" },
|
|
130
|
+
});
|
|
131
|
+
const props = (instance.build as any).props;
|
|
132
|
+
expect(props.script[0]).toContain("--build-arg NODE_ENV=production");
|
|
133
|
+
expect(props.script[0]).toContain("--build-arg VERSION=1.0");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("no rules by default", () => {
|
|
137
|
+
const instance = DockerBuild({});
|
|
138
|
+
const props = (instance.build as any).props;
|
|
139
|
+
expect(props.rules).toBeUndefined();
|
|
140
|
+
});
|
|
101
141
|
});
|
|
102
142
|
|
|
103
143
|
// ---------------------------------------------------------------------------
|
|
@@ -201,6 +241,52 @@ describe("NodePipeline", () => {
|
|
|
201
241
|
expect(expanded.has("appBuild")).toBe(true);
|
|
202
242
|
expect(expanded.has("appTest")).toBe(true);
|
|
203
243
|
});
|
|
244
|
+
|
|
245
|
+
test("custom artifact paths", () => {
|
|
246
|
+
const instance = NodePipeline({ buildArtifactPaths: ["build/", "out/"] });
|
|
247
|
+
const props = (instance.build as any).props;
|
|
248
|
+
const artProps = (props.artifacts as any).props;
|
|
249
|
+
expect(artProps.paths).toEqual(["build/", "out/"]);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test("custom artifact expiry", () => {
|
|
253
|
+
const instance = NodePipeline({ artifactExpiry: "30 minutes" });
|
|
254
|
+
const props = (instance.build as any).props;
|
|
255
|
+
const artProps = (props.artifacts as any).props;
|
|
256
|
+
expect(artProps.expire_in).toBe("30 minutes");
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test("cache has pull-push policy", () => {
|
|
260
|
+
const instance = NodePipeline({});
|
|
261
|
+
const defaultProps = (instance.defaults as any).props;
|
|
262
|
+
const cacheProps = (defaultProps.cache[0] as any).props;
|
|
263
|
+
expect(cacheProps.policy).toBe("pull-push");
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test("pnpm install command", () => {
|
|
267
|
+
const instance = NodePipeline({ packageManager: "pnpm" });
|
|
268
|
+
const buildProps = (instance.build as any).props;
|
|
269
|
+
expect(buildProps.script[0]).toBe("pnpm install --frozen-lockfile");
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test("bun install command", () => {
|
|
273
|
+
const instance = NodePipeline({ packageManager: "bun" });
|
|
274
|
+
const buildProps = (instance.build as any).props;
|
|
275
|
+
expect(buildProps.script[0]).toBe("bun install --frozen-lockfile");
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test("test job stage is test", () => {
|
|
279
|
+
const instance = NodePipeline({});
|
|
280
|
+
const props = (instance.test as any).props;
|
|
281
|
+
expect(props.stage).toBe("test");
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
test("test artifacts always reported", () => {
|
|
285
|
+
const instance = NodePipeline({});
|
|
286
|
+
const props = (instance.test as any).props;
|
|
287
|
+
const artProps = (props.artifacts as any).props;
|
|
288
|
+
expect(artProps.when).toBe("always");
|
|
289
|
+
});
|
|
204
290
|
});
|
|
205
291
|
|
|
206
292
|
// ---------------------------------------------------------------------------
|
|
@@ -329,6 +415,42 @@ describe("PythonPipeline", () => {
|
|
|
329
415
|
expect(expanded.has("pyTest")).toBe(true);
|
|
330
416
|
expect(expanded.has("pyLint")).toBe(false);
|
|
331
417
|
});
|
|
418
|
+
|
|
419
|
+
test("custom requirements file", () => {
|
|
420
|
+
const instance = PythonPipeline({ requirementsFile: "requirements-dev.txt" });
|
|
421
|
+
const defaultProps = (instance.defaults as any).props;
|
|
422
|
+
const cacheProps = (defaultProps.cache[0] as any).props;
|
|
423
|
+
expect(cacheProps.key).toEqual({ files: ["requirements-dev.txt"] });
|
|
424
|
+
expect(defaultProps.before_script).toContain("pip install -r requirements-dev.txt");
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
test("poetry mode sets virtualenvs.in-project config", () => {
|
|
428
|
+
const instance = PythonPipeline({ usePoetry: true });
|
|
429
|
+
const defaultProps = (instance.defaults as any).props;
|
|
430
|
+
expect(defaultProps.before_script).toContain("poetry config virtualenvs.in-project true");
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
test("custom lint command", () => {
|
|
434
|
+
const instance = PythonPipeline({ lintCommand: "flake8 src/" });
|
|
435
|
+
const members = instance.members as any;
|
|
436
|
+
const props = (members.lint as any).props;
|
|
437
|
+
expect(props.script).toContain("flake8 src/");
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
test("test and lint jobs share the test stage", () => {
|
|
441
|
+
const instance = PythonPipeline({});
|
|
442
|
+
const testProps = (instance.test as any).props;
|
|
443
|
+
const lintProps = ((instance.members as any).lint as any).props;
|
|
444
|
+
expect(testProps.stage).toBe("test");
|
|
445
|
+
expect(lintProps.stage).toBe("test");
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
test("cache has pull-push policy", () => {
|
|
449
|
+
const instance = PythonPipeline({});
|
|
450
|
+
const defaultProps = (instance.defaults as any).props;
|
|
451
|
+
const cacheProps = (defaultProps.cache[0] as any).props;
|
|
452
|
+
expect(cacheProps.policy).toBe("pull-push");
|
|
453
|
+
});
|
|
332
454
|
});
|
|
333
455
|
|
|
334
456
|
// ---------------------------------------------------------------------------
|
|
@@ -449,4 +571,42 @@ describe("ReviewApp", () => {
|
|
|
449
571
|
// which the serializer converts to "staging-stop" in YAML
|
|
450
572
|
expect(envProps.on_stop).toBe("staging-stop");
|
|
451
573
|
});
|
|
574
|
+
|
|
575
|
+
test("default auto_stop_in is 1 week", () => {
|
|
576
|
+
const instance = ReviewApp(baseProps);
|
|
577
|
+
const props = (instance.deploy as any).props;
|
|
578
|
+
const envProps = (props.environment as any).props;
|
|
579
|
+
expect(envProps.auto_stop_in).toBe("1 week");
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
test("custom auto_stop_in", () => {
|
|
583
|
+
const instance = ReviewApp({ ...baseProps, autoStopIn: "2 days" });
|
|
584
|
+
const props = (instance.deploy as any).props;
|
|
585
|
+
const envProps = (props.environment as any).props;
|
|
586
|
+
expect(envProps.auto_stop_in).toBe("2 days");
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
test("default stage is deploy for both jobs", () => {
|
|
590
|
+
const instance = ReviewApp(baseProps);
|
|
591
|
+
const deployProps = (instance.deploy as any).props;
|
|
592
|
+
const stopProps = (instance.stop as any).props;
|
|
593
|
+
expect(deployProps.stage).toBe("deploy");
|
|
594
|
+
expect(stopProps.stage).toBe("deploy");
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
test("stop script as array", () => {
|
|
598
|
+
const instance = ReviewApp({
|
|
599
|
+
...baseProps,
|
|
600
|
+
stopScript: ["helm uninstall app", "kubectl delete ns review"],
|
|
601
|
+
});
|
|
602
|
+
const props = (instance.stop as any).props;
|
|
603
|
+
expect(props.script).toEqual(["helm uninstall app", "kubectl delete ns review"]);
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
test("deploy environment url defaults to CI_ENVIRONMENT_SLUG pattern", () => {
|
|
607
|
+
const instance = ReviewApp(baseProps);
|
|
608
|
+
const props = (instance.deploy as any).props;
|
|
609
|
+
const envProps = (props.environment as any).props;
|
|
610
|
+
expect(envProps.url).toContain("$CI_ENVIRONMENT_SLUG");
|
|
611
|
+
});
|
|
452
612
|
});
|
package/src/coverage.test.ts
CHANGED
|
@@ -1,20 +1,24 @@
|
|
|
1
1
|
import { describe, test, expect } from "bun:test";
|
|
2
|
-
import {
|
|
3
|
-
import { readFileSync } from "fs";
|
|
2
|
+
import { existsSync, readFileSync } from "fs";
|
|
4
3
|
import { join, dirname } from "path";
|
|
5
4
|
import { fileURLToPath } from "url";
|
|
6
5
|
|
|
7
6
|
const basePath = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
8
|
-
const
|
|
7
|
+
const lexiconPath = join(basePath, "src", "generated", "lexicon-gitlab.json");
|
|
8
|
+
const hasGenerated = existsSync(lexiconPath);
|
|
9
9
|
|
|
10
10
|
describe("coverage analysis", () => {
|
|
11
|
-
test("computes coverage for GitLab lexicon", () => {
|
|
11
|
+
test.skipIf(!hasGenerated)("computes coverage for GitLab lexicon", async () => {
|
|
12
|
+
const { computeCoverage } = await import("./coverage");
|
|
13
|
+
const lexiconJSON = readFileSync(lexiconPath, "utf-8");
|
|
12
14
|
const report = computeCoverage(lexiconJSON);
|
|
13
15
|
expect(report.resourceCount).toBe(3); // Job, Default, Workflow
|
|
14
16
|
expect(report.resources).toHaveLength(3);
|
|
15
17
|
});
|
|
16
18
|
|
|
17
|
-
test("reports resource names correctly", () => {
|
|
19
|
+
test.skipIf(!hasGenerated)("reports resource names correctly", async () => {
|
|
20
|
+
const { computeCoverage } = await import("./coverage");
|
|
21
|
+
const lexiconJSON = readFileSync(lexiconPath, "utf-8");
|
|
18
22
|
const report = computeCoverage(lexiconJSON);
|
|
19
23
|
const names = report.resources.map((r) => r.name);
|
|
20
24
|
expect(names).toContain("Job");
|
|
@@ -22,7 +26,9 @@ describe("coverage analysis", () => {
|
|
|
22
26
|
expect(names).toContain("Workflow");
|
|
23
27
|
});
|
|
24
28
|
|
|
25
|
-
test("overallPct returns a number", () => {
|
|
29
|
+
test.skipIf(!hasGenerated)("overallPct returns a number", async () => {
|
|
30
|
+
const { computeCoverage, overallPct } = await import("./coverage");
|
|
31
|
+
const lexiconJSON = readFileSync(lexiconPath, "utf-8");
|
|
26
32
|
const report = computeCoverage(lexiconJSON);
|
|
27
33
|
const pct = overallPct(report);
|
|
28
34
|
expect(typeof pct).toBe("number");
|
|
@@ -30,7 +36,9 @@ describe("coverage analysis", () => {
|
|
|
30
36
|
expect(pct).toBeLessThanOrEqual(100);
|
|
31
37
|
});
|
|
32
38
|
|
|
33
|
-
test("formatSummary returns readable string", () => {
|
|
39
|
+
test.skipIf(!hasGenerated)("formatSummary returns readable string", async () => {
|
|
40
|
+
const { computeCoverage, formatSummary } = await import("./coverage");
|
|
41
|
+
const lexiconJSON = readFileSync(lexiconPath, "utf-8");
|
|
34
42
|
const report = computeCoverage(lexiconJSON);
|
|
35
43
|
const summary = formatSummary(report);
|
|
36
44
|
expect(summary).toContain("Coverage Report");
|
|
@@ -1,11 +1,22 @@
|
|
|
1
1
|
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { readFileSync, readdirSync } from "fs";
|
|
3
|
+
import { join, dirname } from "path";
|
|
4
|
+
import { fileURLToPath } from "url";
|
|
2
5
|
import { GitLabParser } from "./parser";
|
|
3
6
|
import { GitLabGenerator } from "./generator";
|
|
4
7
|
|
|
5
8
|
const parser = new GitLabParser();
|
|
6
9
|
const generator = new GitLabGenerator();
|
|
7
10
|
|
|
11
|
+
const pipelinesDir = join(
|
|
12
|
+
dirname(dirname(fileURLToPath(import.meta.url))),
|
|
13
|
+
"testdata",
|
|
14
|
+
"pipelines",
|
|
15
|
+
);
|
|
16
|
+
|
|
8
17
|
describe("roundtrip: parse → generate", () => {
|
|
18
|
+
// ---- inline tests (existing smoke tests) ----
|
|
19
|
+
|
|
9
20
|
test("simple pipeline roundtrip", () => {
|
|
10
21
|
const yaml = `
|
|
11
22
|
stages:
|
|
@@ -86,4 +97,125 @@ test-job:
|
|
|
86
97
|
expect(content).toContain("new Workflow(");
|
|
87
98
|
expect(content).toContain("new Job(");
|
|
88
99
|
});
|
|
100
|
+
|
|
101
|
+
// ---- fixture-based tests ----
|
|
102
|
+
|
|
103
|
+
test("simple.gitlab-ci.yml fixture roundtrip", () => {
|
|
104
|
+
const yaml = readFileSync(join(pipelinesDir, "simple.gitlab-ci.yml"), "utf-8");
|
|
105
|
+
const ir = parser.parse(yaml);
|
|
106
|
+
const files = generator.generate(ir);
|
|
107
|
+
|
|
108
|
+
expect(files).toHaveLength(1);
|
|
109
|
+
const content = files[0].content;
|
|
110
|
+
|
|
111
|
+
expect(ir.resources).toHaveLength(1);
|
|
112
|
+
expect(content).toContain("new Job(");
|
|
113
|
+
expect(content).toContain("npm ci");
|
|
114
|
+
expect(content).toContain("npm test");
|
|
115
|
+
expect(content).toContain("node:22-alpine");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("multi-stage.gitlab-ci.yml fixture roundtrip", () => {
|
|
119
|
+
const yaml = readFileSync(join(pipelinesDir, "multi-stage.gitlab-ci.yml"), "utf-8");
|
|
120
|
+
const ir = parser.parse(yaml);
|
|
121
|
+
const files = generator.generate(ir);
|
|
122
|
+
|
|
123
|
+
const content = files[0].content;
|
|
124
|
+
|
|
125
|
+
// 4 jobs: build-app, lint, unit-tests, deploy-staging
|
|
126
|
+
expect(ir.resources).toHaveLength(4);
|
|
127
|
+
expect(content).toContain("buildApp");
|
|
128
|
+
expect(content).toContain("lint");
|
|
129
|
+
expect(content).toContain("unitTests");
|
|
130
|
+
expect(content).toContain("deployStaging");
|
|
131
|
+
expect(content).toContain("Pipeline stages: build, test, deploy");
|
|
132
|
+
// Artifacts and cache references
|
|
133
|
+
expect(content).toContain("dist/");
|
|
134
|
+
expect(content).toContain("package-lock.json");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("docker-build.gitlab-ci.yml fixture roundtrip", () => {
|
|
138
|
+
const yaml = readFileSync(join(pipelinesDir, "docker-build.gitlab-ci.yml"), "utf-8");
|
|
139
|
+
const ir = parser.parse(yaml);
|
|
140
|
+
const files = generator.generate(ir);
|
|
141
|
+
|
|
142
|
+
const content = files[0].content;
|
|
143
|
+
|
|
144
|
+
expect(ir.resources).toHaveLength(2);
|
|
145
|
+
expect(content).toContain("buildImage");
|
|
146
|
+
expect(content).toContain("pushLatest");
|
|
147
|
+
expect(content).toContain("docker:27-cli");
|
|
148
|
+
expect(content).toContain("docker:27-dind");
|
|
149
|
+
expect(content).toContain("docker build");
|
|
150
|
+
expect(content).toContain("docker push");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("deploy-envs.gitlab-ci.yml fixture roundtrip", () => {
|
|
154
|
+
const yaml = readFileSync(join(pipelinesDir, "deploy-envs.gitlab-ci.yml"), "utf-8");
|
|
155
|
+
const ir = parser.parse(yaml);
|
|
156
|
+
const files = generator.generate(ir);
|
|
157
|
+
|
|
158
|
+
const content = files[0].content;
|
|
159
|
+
|
|
160
|
+
// 5 jobs: build, deploy-review, stop-review, deploy-staging, deploy-production
|
|
161
|
+
expect(ir.resources).toHaveLength(5);
|
|
162
|
+
expect(content).toContain("deployReview");
|
|
163
|
+
expect(content).toContain("stopReview");
|
|
164
|
+
expect(content).toContain("deployStaging");
|
|
165
|
+
expect(content).toContain("deployProduction");
|
|
166
|
+
// Environment constructs
|
|
167
|
+
expect(content).toContain("review/$CI_COMMIT_REF_SLUG");
|
|
168
|
+
expect(content).toContain("staging");
|
|
169
|
+
expect(content).toContain("production");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("includes-templates.gitlab-ci.yml fixture roundtrip", () => {
|
|
173
|
+
const yaml = readFileSync(join(pipelinesDir, "includes-templates.gitlab-ci.yml"), "utf-8");
|
|
174
|
+
const ir = parser.parse(yaml);
|
|
175
|
+
const files = generator.generate(ir);
|
|
176
|
+
|
|
177
|
+
const content = files[0].content;
|
|
178
|
+
|
|
179
|
+
// default + build + unit-test + integration-test + deploy
|
|
180
|
+
expect(ir.resources.length).toBeGreaterThanOrEqual(3);
|
|
181
|
+
expect(content).toContain("new Default(");
|
|
182
|
+
// Include references should be captured as comments
|
|
183
|
+
expect(content).toContain("Auto-DevOps.gitlab-ci.yml");
|
|
184
|
+
expect(content).toContain("interruptible");
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("monorepo.gitlab-ci.yml fixture roundtrip", () => {
|
|
188
|
+
const yaml = readFileSync(join(pipelinesDir, "monorepo.gitlab-ci.yml"), "utf-8");
|
|
189
|
+
const ir = parser.parse(yaml);
|
|
190
|
+
const files = generator.generate(ir);
|
|
191
|
+
|
|
192
|
+
const content = files[0].content;
|
|
193
|
+
|
|
194
|
+
// frontend, backend, e2e-matrix, deploy-all
|
|
195
|
+
expect(ir.resources).toHaveLength(4);
|
|
196
|
+
expect(content).toContain("frontend");
|
|
197
|
+
expect(content).toContain("backend");
|
|
198
|
+
expect(content).toContain("e2eMatrix");
|
|
199
|
+
expect(content).toContain("deployAll");
|
|
200
|
+
// Monorepo-specific constructs
|
|
201
|
+
expect(content).toContain("cypress");
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
// ---- fixture directory sweep ----
|
|
205
|
+
|
|
206
|
+
test("all pipeline fixtures parse and generate without error", () => {
|
|
207
|
+
const fixtures = readdirSync(pipelinesDir).filter((f) => f.endsWith(".yml"));
|
|
208
|
+
expect(fixtures.length).toBeGreaterThanOrEqual(6);
|
|
209
|
+
|
|
210
|
+
for (const fixture of fixtures) {
|
|
211
|
+
const yaml = readFileSync(join(pipelinesDir, fixture), "utf-8");
|
|
212
|
+
const ir = parser.parse(yaml);
|
|
213
|
+
expect(ir.resources.length).toBeGreaterThan(0);
|
|
214
|
+
|
|
215
|
+
const files = generator.generate(ir);
|
|
216
|
+
expect(files.length).toBeGreaterThan(0);
|
|
217
|
+
expect(files[0].content).toContain("import");
|
|
218
|
+
expect(files[0].content).toContain("export const");
|
|
219
|
+
}
|
|
220
|
+
});
|
|
89
221
|
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { wgl016, checkSecretsInVariables } from "./wgl016";
|
|
3
|
+
|
|
4
|
+
describe("WGL016: Secrets in Variables", () => {
|
|
5
|
+
test("check metadata", () => {
|
|
6
|
+
expect(wgl016.id).toBe("WGL016");
|
|
7
|
+
expect(wgl016.description).toContain("Secrets");
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test("flags hardcoded password variable", () => {
|
|
11
|
+
const yaml = `variables:
|
|
12
|
+
DB_PASSWORD: my-secret-pass123
|
|
13
|
+
DB_HOST: postgres
|
|
14
|
+
`;
|
|
15
|
+
const diags = checkSecretsInVariables(yaml);
|
|
16
|
+
expect(diags).toHaveLength(1);
|
|
17
|
+
expect(diags[0].checkId).toBe("WGL016");
|
|
18
|
+
expect(diags[0].severity).toBe("error");
|
|
19
|
+
expect(diags[0].message).toContain("DB_PASSWORD");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("flags hardcoded token variable", () => {
|
|
23
|
+
const yaml = `variables:
|
|
24
|
+
API_TOKEN: abc123xyz
|
|
25
|
+
`;
|
|
26
|
+
const diags = checkSecretsInVariables(yaml);
|
|
27
|
+
expect(diags).toHaveLength(1);
|
|
28
|
+
expect(diags[0].message).toContain("API_TOKEN");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("flags hardcoded secret variable", () => {
|
|
32
|
+
const yaml = `variables:
|
|
33
|
+
APP_SECRET: supersecretvalue
|
|
34
|
+
`;
|
|
35
|
+
const diags = checkSecretsInVariables(yaml);
|
|
36
|
+
expect(diags).toHaveLength(1);
|
|
37
|
+
expect(diags[0].message).toContain("APP_SECRET");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("does not flag variable references", () => {
|
|
41
|
+
const yaml = `variables:
|
|
42
|
+
DB_PASSWORD: $CI_DB_PASSWORD
|
|
43
|
+
API_TOKEN: \${SECRET_TOKEN}
|
|
44
|
+
`;
|
|
45
|
+
const diags = checkSecretsInVariables(yaml);
|
|
46
|
+
expect(diags).toHaveLength(0);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("does not flag non-secret variables", () => {
|
|
50
|
+
const yaml = `variables:
|
|
51
|
+
NODE_ENV: production
|
|
52
|
+
DB_HOST: postgres
|
|
53
|
+
STAGE: deploy
|
|
54
|
+
`;
|
|
55
|
+
const diags = checkSecretsInVariables(yaml);
|
|
56
|
+
expect(diags).toHaveLength(0);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("flags multiple secret variables", () => {
|
|
60
|
+
const yaml = `variables:
|
|
61
|
+
DB_PASSWORD: pass123
|
|
62
|
+
API_SECRET: secret456
|
|
63
|
+
`;
|
|
64
|
+
const diags = checkSecretsInVariables(yaml);
|
|
65
|
+
expect(diags).toHaveLength(2);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("no diagnostics on empty yaml", () => {
|
|
69
|
+
const diags = checkSecretsInVariables("");
|
|
70
|
+
expect(diags).toHaveLength(0);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WGL016: Secrets in Variables
|
|
3
|
+
*
|
|
4
|
+
* Detects hardcoded passwords, tokens, or secrets in `variables:` blocks.
|
|
5
|
+
* These should use CI/CD masked variables instead.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
9
|
+
import { getPrimaryOutput } from "./yaml-helpers";
|
|
10
|
+
|
|
11
|
+
const SECRET_PATTERNS = [
|
|
12
|
+
/password\s*[:=]\s*['"]?[^\s'"]+/i,
|
|
13
|
+
/secret\s*[:=]\s*['"]?[^\s'"]+/i,
|
|
14
|
+
/token\s*[:=]\s*['"]?[^\s'"]+/i,
|
|
15
|
+
/api[_-]?key\s*[:=]\s*['"]?[^\s'"]+/i,
|
|
16
|
+
/private[_-]?key\s*[:=]\s*['"]?[^\s'"]+/i,
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
/** Variable name patterns that indicate credentials. */
|
|
20
|
+
const SECRET_VAR_NAMES = [
|
|
21
|
+
/password/i,
|
|
22
|
+
/secret/i,
|
|
23
|
+
/token/i,
|
|
24
|
+
/api[_-]?key/i,
|
|
25
|
+
/private[_-]?key/i,
|
|
26
|
+
/credentials?/i,
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
/** Values that are clearly references (not hardcoded secrets). */
|
|
30
|
+
function isReference(value: string): boolean {
|
|
31
|
+
return value.startsWith("$") || value.startsWith("${");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function checkSecretsInVariables(yaml: string): PostSynthDiagnostic[] {
|
|
35
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
36
|
+
|
|
37
|
+
// Extract variables blocks (global and per-job)
|
|
38
|
+
const varBlocks = yaml.matchAll(/^(\s*)variables:\n((?:\1\s+.+\n?)+)/gm);
|
|
39
|
+
|
|
40
|
+
for (const block of varBlocks) {
|
|
41
|
+
const lines = block[2].split("\n");
|
|
42
|
+
for (const line of lines) {
|
|
43
|
+
const kv = line.match(/^\s+(\w+):\s+(.+)$/);
|
|
44
|
+
if (!kv) continue;
|
|
45
|
+
|
|
46
|
+
const [, varName, rawValue] = kv;
|
|
47
|
+
const value = rawValue.trim().replace(/^['"]|['"]$/g, "");
|
|
48
|
+
|
|
49
|
+
if (isReference(value)) continue;
|
|
50
|
+
|
|
51
|
+
// Check if variable name suggests a secret
|
|
52
|
+
for (const pattern of SECRET_VAR_NAMES) {
|
|
53
|
+
if (pattern.test(varName)) {
|
|
54
|
+
diagnostics.push({
|
|
55
|
+
checkId: "WGL016",
|
|
56
|
+
severity: "error",
|
|
57
|
+
message: `Variable "${varName}" appears to contain a hardcoded secret — use a CI/CD masked variable instead`,
|
|
58
|
+
entity: varName,
|
|
59
|
+
lexicon: "gitlab",
|
|
60
|
+
});
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return diagnostics;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export const wgl016: PostSynthCheck = {
|
|
71
|
+
id: "WGL016",
|
|
72
|
+
description: "Secrets in variables — hardcoded passwords or tokens in variables blocks",
|
|
73
|
+
|
|
74
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
75
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
76
|
+
for (const [, output] of ctx.outputs) {
|
|
77
|
+
const yaml = getPrimaryOutput(output);
|
|
78
|
+
diagnostics.push(...checkSecretsInVariables(yaml));
|
|
79
|
+
}
|
|
80
|
+
return diagnostics;
|
|
81
|
+
},
|
|
82
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { wgl017, checkInsecureRegistry } from "./wgl017";
|
|
3
|
+
|
|
4
|
+
describe("WGL017: Insecure Registry", () => {
|
|
5
|
+
test("check metadata", () => {
|
|
6
|
+
expect(wgl017.id).toBe("WGL017");
|
|
7
|
+
expect(wgl017.description).toContain("Insecure");
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test("flags docker push to HTTP registry", () => {
|
|
11
|
+
const yaml = `build-image:
|
|
12
|
+
script:
|
|
13
|
+
- docker push http://registry.local/myimage:latest
|
|
14
|
+
`;
|
|
15
|
+
const diags = checkInsecureRegistry(yaml);
|
|
16
|
+
expect(diags).toHaveLength(1);
|
|
17
|
+
expect(diags[0].severity).toBe("warning");
|
|
18
|
+
expect(diags[0].message).toContain("build-image");
|
|
19
|
+
expect(diags[0].message).toContain("insecure");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("flags docker pull from HTTP registry", () => {
|
|
23
|
+
const yaml = `test-job:
|
|
24
|
+
script:
|
|
25
|
+
- docker pull http://insecure-registry.com/myimage
|
|
26
|
+
`;
|
|
27
|
+
const diags = checkInsecureRegistry(yaml);
|
|
28
|
+
expect(diags).toHaveLength(1);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("does not flag HTTPS registry", () => {
|
|
32
|
+
const yaml = `build-image:
|
|
33
|
+
script:
|
|
34
|
+
- docker push https://registry.gitlab.com/myimage:latest
|
|
35
|
+
`;
|
|
36
|
+
const diags = checkInsecureRegistry(yaml);
|
|
37
|
+
expect(diags).toHaveLength(0);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("does not flag registry without protocol", () => {
|
|
41
|
+
const yaml = `build-image:
|
|
42
|
+
script:
|
|
43
|
+
- docker push $CI_REGISTRY_IMAGE:latest
|
|
44
|
+
`;
|
|
45
|
+
const diags = checkInsecureRegistry(yaml);
|
|
46
|
+
expect(diags).toHaveLength(0);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("no diagnostics on empty yaml", () => {
|
|
50
|
+
const diags = checkInsecureRegistry("");
|
|
51
|
+
expect(diags).toHaveLength(0);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WGL017: Insecure Registry
|
|
3
|
+
*
|
|
4
|
+
* Detects Docker push/pull to non-HTTPS registries in job scripts.
|
|
5
|
+
* Using HTTP for container registries is a security risk.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
9
|
+
import { getPrimaryOutput, extractJobs } from "./yaml-helpers";
|
|
10
|
+
|
|
11
|
+
const INSECURE_REGISTRY_PATTERN = /docker\s+(push|pull|tag|login)\s+.*http:\/\/[^\s]+/;
|
|
12
|
+
|
|
13
|
+
export function checkInsecureRegistry(yaml: string): PostSynthDiagnostic[] {
|
|
14
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
15
|
+
|
|
16
|
+
const sections = yaml.split("\n\n");
|
|
17
|
+
for (const section of sections) {
|
|
18
|
+
const lines = section.split("\n");
|
|
19
|
+
if (lines.length === 0) continue;
|
|
20
|
+
|
|
21
|
+
const topMatch = lines[0].match(/^(\.?[a-z][a-z0-9_.-]*):/);
|
|
22
|
+
if (!topMatch) continue;
|
|
23
|
+
const jobName = topMatch[1];
|
|
24
|
+
|
|
25
|
+
for (const line of lines) {
|
|
26
|
+
if (INSECURE_REGISTRY_PATTERN.test(line)) {
|
|
27
|
+
diagnostics.push({
|
|
28
|
+
checkId: "WGL017",
|
|
29
|
+
severity: "warning",
|
|
30
|
+
message: `Job "${jobName}" uses an insecure (HTTP) container registry — use HTTPS instead`,
|
|
31
|
+
entity: jobName,
|
|
32
|
+
lexicon: "gitlab",
|
|
33
|
+
});
|
|
34
|
+
break;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return diagnostics;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const wgl017: PostSynthCheck = {
|
|
43
|
+
id: "WGL017",
|
|
44
|
+
description: "Insecure registry — Docker push/pull to non-HTTPS registry",
|
|
45
|
+
|
|
46
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
47
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
48
|
+
for (const [, output] of ctx.outputs) {
|
|
49
|
+
const yaml = getPrimaryOutput(output);
|
|
50
|
+
diagnostics.push(...checkInsecureRegistry(yaml));
|
|
51
|
+
}
|
|
52
|
+
return diagnostics;
|
|
53
|
+
},
|
|
54
|
+
};
|