@intentius/chant-lexicon-gitlab 0.13.0 → 0.15.0

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.
@@ -0,0 +1,238 @@
1
+ /**
2
+ * Tests for generate mode's component → GitLab CI YAML synthesis (#563,
3
+ * epic #551, Phase 3). Three things this must prove, matching the issue's
4
+ * acceptance criteria:
5
+ *
6
+ * 1. A component set produces structurally valid GitLab CI YAML (parses
7
+ * back via `../yaml.ts`'s `parseYAML`, with `stages:` + one job per
8
+ * component, each job carrying a `script:`/`stage:`/`image:`).
9
+ * 2. Jobs map onto components in wave order — a component's job lands in
10
+ * the same wave `resolveComponentGraph` (the driver's own graph
11
+ * resolution, ../driver.ts) computes, and depends (`needs:`) only on its
12
+ * own `dependsOn` edges, never on unrelated jobs.
13
+ * 3. A cross-cutting generator change (e.g. "sign every image before
14
+ * deploy") is a single edit to the generator's options — reflected in
15
+ * every job's script — never a per-component edit.
16
+ */
17
+
18
+ import { describe, test, expect } from "vitest";
19
+ import { parseYAML } from "@intentius/chant/yaml";
20
+ import { generateGitlabPipeline } from "./generate-pipeline";
21
+ import { resolveComponentGraph, DependencyCycleError, UnknownDependencyError, type DriverComponent } from "@intentius/chant/components/driver";
22
+ import { searchService } from "@intentius/chant/components/pilots/alb-ecs.pilot";
23
+ import { ordersTable } from "@intentius/chant/components/pilots/dynamodb.pilot";
24
+
25
+ /** Real pilot components (#555) plus the shared-alb infra `search-service` depends on, as a realistic multi-wave input. */
26
+ function pilotComponents(): DriverComponent[] {
27
+ return [
28
+ { name: ordersTable.name, dependsOn: ordersTable.dependsOn, deploy: ordersTable.deploy },
29
+ { name: "shared-alb", dependsOn: [], deploy: [] },
30
+ { name: searchService.name, dependsOn: searchService.dependsOn, deploy: searchService.deploy },
31
+ ];
32
+ }
33
+
34
+ describe("generateGitlabPipeline: cross-stack output threading (artifacts)", () => {
35
+ test("a depended-upon component dumps its outputs as an artifact; its dependents seed from it", () => {
36
+ // search-service dependsOn shared-alb; orders-table is depended on by nothing.
37
+ const parsed = parseYAML(generateGitlabPipeline(pilotComponents()).yaml);
38
+
39
+ // Producer: shared-alb hands its resolved outputs to a separate downstream
40
+ // job, so it dumps them and publishes the file as an artifact.
41
+ const shared = parsed["shared-alb"] as Record<string, unknown>;
42
+ expect((shared.script as string[]).join(" ")).toContain("--dump-outputs shared-alb.outputs.json");
43
+ expect(shared.artifacts).toEqual({ paths: ["shared-alb.outputs.json"] });
44
+
45
+ // Consumer: search-service seeds from shared-alb's dumped outputs (delivered
46
+ // across the needs: edge) so its stackOutput() references resolve.
47
+ const svc = parsed["search-service"] as Record<string, unknown>;
48
+ expect((svc.script as string[]).join(" ")).toContain("--seed-outputs shared-alb.outputs.json");
49
+
50
+ // A component nothing depends on neither dumps nor carries an artifact.
51
+ const orders = parsed["orders-table"] as Record<string, unknown>;
52
+ expect((orders.script as string[]).join(" ")).not.toContain("--dump-outputs");
53
+ expect(orders.artifacts).toBeUndefined();
54
+ expect((orders.script as string[]).join(" ")).not.toContain("--seed-outputs");
55
+ });
56
+ });
57
+
58
+ describe("generateGitlabPipeline: structurally valid YAML", () => {
59
+ test("produces YAML with a stages: list and one job per component", () => {
60
+ const result = generateGitlabPipeline(pilotComponents(), { env: "staging" });
61
+
62
+ const parsed = parseYAML(result.yaml);
63
+ expect(Array.isArray(parsed.stages)).toBe(true);
64
+ expect(parsed.stages).toEqual(["wave-1", "wave-2"]);
65
+
66
+ for (const name of ["orders-table", "shared-alb", "search-service"]) {
67
+ const job = parsed[name] as Record<string, unknown>;
68
+ expect(job).toBeDefined();
69
+ expect(typeof job.stage).toBe("string");
70
+ expect(typeof job.image).toBe("string");
71
+ expect(Array.isArray(job.script)).toBe(true);
72
+ expect((job.script as string[]).length).toBeGreaterThan(0);
73
+ }
74
+ });
75
+
76
+ test("every job's script is a single thin trigger invocation, not inlined deploy steps", () => {
77
+ const result = generateGitlabPipeline(pilotComponents(), { env: "staging" });
78
+ const parsed = parseYAML(result.yaml);
79
+
80
+ for (const job of result.jobs) {
81
+ const props = parsed[job.jobName] as Record<string, unknown>;
82
+ const script = props.script as string[];
83
+ // The thin trigger: hands off to the component via `chant run`, carrying
84
+ // no build/publish/apply/cfn-deploy/ecs-update-service keywords — those
85
+ // verbs live in the component's own composition, never in the YAML.
86
+ expect(script.some((line) => line.includes(`chant run --components ${job.component}`))).toBe(true);
87
+ for (const line of script) {
88
+ expect(line).not.toMatch(/docker build|docker push|aws cloudformation|ecs update-service/);
89
+ }
90
+ }
91
+ });
92
+
93
+ test("an empty component set produces a pipeline with no stages and no jobs", () => {
94
+ const result = generateGitlabPipeline([]);
95
+ expect(result.stages).toEqual([]);
96
+ expect(result.jobs).toEqual([]);
97
+ const parsed = parseYAML(result.yaml);
98
+ expect(parsed.stages).toEqual([]);
99
+ });
100
+
101
+ test("propagates a dependency cycle error the same way the interpret driver's own graph resolution does", () => {
102
+ const cyclical: DriverComponent[] = [
103
+ { name: "a", dependsOn: ["b"], deploy: [] },
104
+ { name: "b", dependsOn: ["a"], deploy: [] },
105
+ ];
106
+ expect(() => generateGitlabPipeline(cyclical)).toThrow(DependencyCycleError);
107
+ });
108
+
109
+ test("propagates an unknown-dependency error for a dangling dependsOn", () => {
110
+ const dangling: DriverComponent[] = [{ name: "a", dependsOn: ["ghost"], deploy: [] }];
111
+ expect(() => generateGitlabPipeline(dangling)).toThrow(UnknownDependencyError);
112
+ });
113
+ });
114
+
115
+ describe("generateGitlabPipeline: jobs map to components in wave order", () => {
116
+ test("independent components share one stage; a dependent lands in a strictly later stage", () => {
117
+ const components = pilotComponents();
118
+ const { waves } = resolveComponentGraph(components);
119
+ const result = generateGitlabPipeline(components);
120
+
121
+ // Same wave count/membership as the driver's own graph resolution — no
122
+ // separate, divergent ordering logic in the generator.
123
+ expect(result.stages).toHaveLength(waves.length);
124
+
125
+ const stageOf = (name: string) => result.jobs.find((j) => j.component === name)!.stage;
126
+ expect(stageOf("orders-table")).toBe(stageOf("shared-alb")); // independent, same wave
127
+ expect(result.stages.indexOf(stageOf("search-service"))).toBeGreaterThan(
128
+ result.stages.indexOf(stageOf("shared-alb")),
129
+ ); // dependent, later wave
130
+ });
131
+
132
+ test("a job's needs: are exactly its component's dependsOn edges, no more and no less", () => {
133
+ const result = generateGitlabPipeline(pilotComponents());
134
+
135
+ const ordersJob = result.jobs.find((j) => j.component === "orders-table")!;
136
+ const albJob = result.jobs.find((j) => j.component === "shared-alb")!;
137
+ const searchJob = result.jobs.find((j) => j.component === "search-service")!;
138
+
139
+ expect(ordersJob.needs).toEqual([]);
140
+ expect(albJob.needs).toEqual([]);
141
+ expect(searchJob.needs).toEqual(["shared-alb"]);
142
+ });
143
+
144
+ test("a three-wave fan-out produces three stages in dependency order", () => {
145
+ const components: DriverComponent[] = [
146
+ { name: "base", dependsOn: [], deploy: [] },
147
+ { name: "middle", dependsOn: ["base"], deploy: [] },
148
+ { name: "top", dependsOn: ["middle"], deploy: [] },
149
+ ];
150
+ const result = generateGitlabPipeline(components);
151
+
152
+ expect(result.stages).toEqual(["wave-1", "wave-2", "wave-3"]);
153
+ expect(result.jobs.find((j) => j.component === "base")!.stage).toBe("wave-1");
154
+ expect(result.jobs.find((j) => j.component === "middle")!.stage).toBe("wave-2");
155
+ expect(result.jobs.find((j) => j.component === "top")!.stage).toBe("wave-3");
156
+ });
157
+ });
158
+
159
+ describe("generateGitlabPipeline: a cross-cutting change is one generator edit, not per-pipeline", () => {
160
+ test("adding extraScript (e.g. image signing) appears in every job with no per-component changes", () => {
161
+ const components = pilotComponents();
162
+
163
+ const before = generateGitlabPipeline(components, { env: "production" });
164
+ for (const job of before.jobs) {
165
+ const parsed = parseYAML(before.yaml);
166
+ const script = (parsed[job.jobName] as Record<string, unknown>).script as string[];
167
+ expect(script).not.toContain("cosign sign --yes $IMAGE_REF");
168
+ }
169
+
170
+ // Simulates the cross-cutting change from the epic's worked example
171
+ // ("sign every image"): ONE edit to the generator's options, applied
172
+ // uniformly, with the component declarations themselves untouched.
173
+ const after = generateGitlabPipeline(components, {
174
+ env: "production",
175
+ extraScript: ["cosign sign --yes $IMAGE_REF"],
176
+ });
177
+
178
+ const parsedAfter = parseYAML(after.yaml);
179
+ expect(after.jobs.length).toBe(before.jobs.length);
180
+ for (const job of after.jobs) {
181
+ const script = (parsedAfter[job.jobName] as Record<string, unknown>).script as string[];
182
+ expect(script).toContain("cosign sign --yes $IMAGE_REF");
183
+ }
184
+ });
185
+
186
+ test("changing the trigger command (runCommand) updates every job's script uniformly", () => {
187
+ const components = pilotComponents();
188
+ const result = generateGitlabPipeline(components, {
189
+ runCommand: ["chant", "run", "--components", "{name}", "--env", "staging", "--temporal"],
190
+ });
191
+ const parsed = parseYAML(result.yaml);
192
+
193
+ for (const job of result.jobs) {
194
+ const script = (parsed[job.jobName] as Record<string, unknown>).script as string[];
195
+ // The runCommand prefix reflects in every job; output-threading flags
196
+ // (--seed-outputs/--dump-outputs) may be appended per the dependency graph.
197
+ expect(script[0].startsWith(`chant run --components ${job.component} --env staging --temporal`)).toBe(true);
198
+ }
199
+ });
200
+
201
+ test("changing beforeScript (e.g. a registry login) prepends to every job uniformly", () => {
202
+ const components = pilotComponents();
203
+ const result = generateGitlabPipeline(components, {
204
+ beforeScript: ["echo $CI_REGISTRY_PASSWORD | docker login -u $CI_REGISTRY_USER --password-stdin $CI_REGISTRY"],
205
+ });
206
+ const parsed = parseYAML(result.yaml);
207
+
208
+ for (const job of result.jobs) {
209
+ const script = (parsed[job.jobName] as Record<string, unknown>).script as string[];
210
+ expect(script[0]).toMatch(/docker login/);
211
+ }
212
+ });
213
+ });
214
+
215
+ describe("generateGitlabPipeline: options", () => {
216
+ test("emits a variables: block when provided", () => {
217
+ const result = generateGitlabPipeline(pilotComponents(), {
218
+ variables: { CHANT_ENV: "staging" },
219
+ });
220
+ const parsed = parseYAML(result.yaml);
221
+ expect(parsed.variables).toEqual({ CHANT_ENV: "staging" });
222
+ });
223
+
224
+ test("uses a custom image for every job when provided", () => {
225
+ const result = generateGitlabPipeline(pilotComponents(), { image: "chant/cli:latest" });
226
+ const parsed = parseYAML(result.yaml);
227
+ for (const job of result.jobs) {
228
+ expect((parsed[job.jobName] as Record<string, unknown>).image).toBe("chant/cli:latest");
229
+ }
230
+ });
231
+
232
+ test("defaults env to production when not provided", () => {
233
+ const result = generateGitlabPipeline(pilotComponents());
234
+ const parsed = parseYAML(result.yaml);
235
+ const job = parsed["shared-alb"] as Record<string, unknown>;
236
+ expect((job.script as string[])[0]).toContain("--env production");
237
+ });
238
+ });
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Generate mode — component → GitLab CI YAML (#563, epic #551, Phase 3).
3
+ *
4
+ * The other half of "two modes, both anti-sprawl" (see
5
+ * docs/src/content/docs/components/orchestration.mdx#generate-mode and epic
6
+ * #551 §"5. Orchestrator → generate mode"): interpret mode (`../driver.ts`,
7
+ * #556) runs components directly; generate mode synthesizes a **thin**
8
+ * `.gitlab-ci.yml` from the same declarations for teams who want plain CI as
9
+ * the trigger/runner.
10
+ *
11
+ * The generated pipeline is a trigger, not the deploy logic:
12
+ * - Ordering + parallel-safe waves are resolved once, generically, by
13
+ * `resolveComponentGraph` (../driver.ts) — the exact function the local
14
+ * interpret driver uses. Generate mode does not re-derive or duplicate
15
+ * that graph logic.
16
+ * - Each wave becomes one GitLab CI `stage`; every component in a wave
17
+ * becomes one job in that stage, so independent components run in
18
+ * parallel and dependents wait for their dependencies via natural stage
19
+ * ordering (mirrored explicitly with `needs:` for direct edges, so GitLab
20
+ * can still parallelize across non-adjacent stages when safe).
21
+ * - Each job's `script` is exactly one invocation that hands off to the
22
+ * component's own composition (`chant run --components <name> ...` by
23
+ * default) — never inlined build/publish/apply steps. The deploy logic
24
+ * lives in the component's `deploy` phases and the capabilities they
25
+ * reference, not in this YAML.
26
+ *
27
+ * Cross-cutting changes (e.g. "sign every image before deploy") are made by
28
+ * editing `GenerateGitlabOptions.extraScript`/`beforeScript` (or the
29
+ * component's own composition) ONCE here — never per generated job. See
30
+ * `generate-gitlab.test.ts`'s "cross-cutting change" case for a
31
+ * demonstration: one generator-option edit reflects in every job without
32
+ * touching the component declarations.
33
+ */
34
+
35
+ import { emitYAML } from "@intentius/chant/yaml";
36
+ import { resolveComponentGraph, type DriverComponent } from "@intentius/chant/components/driver";
37
+ import type {
38
+ ComponentPipelineJob as GeneratedJob,
39
+ ComponentPipelineOptions as GenerateGitlabOptions,
40
+ ComponentPipelineResult as GenerateGitlabResult,
41
+ } from "@intentius/chant/lexicon";
42
+
43
+ export type { GeneratedJob, GenerateGitlabOptions, GenerateGitlabResult };
44
+
45
+ /** GitLab CI job names must be safe YAML keys; component names are already kebab-case in every fixture, but normalize defensively. */
46
+ function toJobName(componentName: string): string {
47
+ return componentName.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
48
+ }
49
+
50
+ const DEFAULT_IMAGE = "node:22-slim";
51
+
52
+ /**
53
+ * Synthesize a `.gitlab-ci.yml` pipeline from a set of components: one stage
54
+ * per parallel-safe wave (`resolveComponentGraph`), one thin trigger job per
55
+ * component. Throws `DependencyCycleError`/`UnknownDependencyError` (from
56
+ * core's driver) exactly like the interpret driver does, since both consume
57
+ * the same graph resolution. Wired into core's generate mode via the gitlab
58
+ * lexicon plugin's `generateComponentPipeline` (../plugin.ts).
59
+ */
60
+ export function generateGitlabPipeline(
61
+ components: DriverComponent[],
62
+ options: GenerateGitlabOptions = {},
63
+ ): GenerateGitlabResult {
64
+ const env = options.env ?? "production";
65
+ const image = options.image ?? DEFAULT_IMAGE;
66
+ const runCommand = options.runCommand ?? ["chant", "run", "--components", "{name}", "--env", env];
67
+ const beforeScript = options.beforeScript ?? [];
68
+ const extraScript = options.extraScript ?? [];
69
+
70
+ const { waves } = resolveComponentGraph(components);
71
+ const byName = new Map(components.map((c) => [c.name, c]));
72
+
73
+ // Components that something else depends on must hand their resolved outputs
74
+ // (stack outputs, published artifact refs) to their dependents, which run as
75
+ // separate jobs/processes. Each such producer dumps its outputs to a file and
76
+ // declares it a job artifact; each dependent seeds from that file (delivered
77
+ // across the `needs:` edge by GitLab's artifact passing) so a `stackOutput()`
78
+ // / `@<dep>.publish.*` reference resolves even though the producer ran in a
79
+ // different job. Without this, a single-component job has no in-memory outputs
80
+ // for its dependencies — see epic #551 / the adopt-alb-services example.
81
+ const dependedUpon = new Set<string>();
82
+ for (const c of components) for (const dep of c.dependsOn ?? []) dependedUpon.add(dep);
83
+ const outputsFile = (name: string) => `${name}.outputs.json`;
84
+
85
+ const stages = waves.map((_, i) => `wave-${i + 1}`);
86
+ const jobs: GeneratedJob[] = [];
87
+ const jobNameByComponent = new Map<string, string>();
88
+ for (const wave of waves) {
89
+ for (const name of wave) jobNameByComponent.set(name, toJobName(name));
90
+ }
91
+
92
+ const doc: Record<string, unknown> = {};
93
+ doc.stages = stages;
94
+ if (options.variables && Object.keys(options.variables).length > 0) {
95
+ doc.variables = options.variables;
96
+ }
97
+
98
+ waves.forEach((wave, waveIndex) => {
99
+ const stage = stages[waveIndex];
100
+ for (const name of wave) {
101
+ const component = byName.get(name)!;
102
+ const jobName = jobNameByComponent.get(name)!;
103
+ const needs = (component.dependsOn ?? []).map((dep) => jobNameByComponent.get(dep)!).sort();
104
+
105
+ jobs.push({ jobName, component: name, stage, needs });
106
+
107
+ // Build the run invocation, then append output-threading flags: seed from
108
+ // each dependency's dumped outputs, and dump this component's own outputs
109
+ // if a dependent will need them.
110
+ const runParts = runCommand.map((part) => part.replace("{name}", name));
111
+ for (const dep of component.dependsOn ?? []) runParts.push("--seed-outputs", outputsFile(dep));
112
+ if (dependedUpon.has(name)) runParts.push("--dump-outputs", outputsFile(name));
113
+
114
+ const script = [...beforeScript, runParts.join(" "), ...extraScript];
115
+
116
+ const jobProps: Record<string, unknown> = {
117
+ stage,
118
+ image,
119
+ script,
120
+ };
121
+ if (needs.length > 0) jobProps.needs = needs;
122
+ // Publish this component's dumped outputs so dependent jobs receive it.
123
+ if (dependedUpon.has(name)) jobProps.artifacts = { paths: [outputsFile(name)] };
124
+ doc[jobName] = jobProps;
125
+ }
126
+ });
127
+
128
+ const sections: string[] = [];
129
+ sections.push("stages:" + emitYAML(stages, 1));
130
+ if (doc.variables) sections.push("variables:" + emitYAML(doc.variables, 1));
131
+ for (const job of jobs) {
132
+ const props = doc[job.jobName] as Record<string, unknown>;
133
+ sections.push(`${job.jobName}:` + emitYAML(props, 1));
134
+ }
135
+
136
+ return { yaml: sections.join("\n\n") + "\n", stages, jobs };
137
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * The gitlab lexicon's chant audit catalog — metadata for its post-synth rules
3
+ * (WGL GitLab CI rules). Contributed via gitlabPlugin.auditCatalog() (#687).
4
+ */
5
+ import { auditRule, GH_INJECTION, GH_OIDC, GH_PWN, GH_SECRETS, SCORECARD_PINNED, type RuleMeta } from "@intentius/chant/audit/catalog";
6
+
7
+ export const gitlabAuditCatalog: Record<string, RuleMeta> = {
8
+ WGL010: auditRule("WGL010", "merge-worthy", "guidance", "Job references an undefined stage", "Add the stage to `stages:` or fix the job's `stage:`.", { category: "correctness" }),
9
+ WGL011: auditRule("WGL011", "merge-worthy", "guidance", "Job rules always evaluate to never", "Fix the `rules:` so the job can run; it is currently unreachable.", { category: "correctness" }),
10
+ WGL012: auditRule("WGL012", "report-only", "guidance", "Deprecated property", "Replace the deprecated GitLab CI property.", { category: "best-practice" }),
11
+ WGL013: auditRule("WGL013", "merge-worthy", "guidance", "Invalid needs target", "Fix the dangling/self `needs:` reference.", { category: "correctness" }),
12
+ WGL014: auditRule("WGL014", "merge-worthy", "guidance", "Invalid extends target", "Point `extends:` at a template that exists in the pipeline.", { category: "correctness" }),
13
+ WGL015: auditRule("WGL015", "merge-worthy", "guidance", "Circular needs chain", "Break the cycle in the job dependency graph.", { category: "correctness" }),
14
+ WGL016: auditRule("WGL016", "merge-worthy", "guidance", "Hardcoded secret in variables", "Move the secret out of `variables:` into a masked/protected CI variable and rotate it.", { authority: [GH_SECRETS] }),
15
+ WGL017: auditRule("WGL017", "merge-worthy", "guidance", "Insecure (non-HTTPS) registry", "Use an HTTPS registry endpoint.", { category: "security" }),
16
+ WGL018: auditRule("WGL018", "report-only", "guidance", "Missing job timeout", "Add a `timeout:` to bound long-running jobs.", { category: "best-practice" }),
17
+ WGL019: auditRule("WGL019", "report-only", "guidance", "Missing retry on deploy job", "Add a `retry:` strategy to deploy jobs.", { category: "best-practice" }),
18
+ WGL020: auditRule("WGL020", "merge-worthy", "guidance", "Duplicate job names", "Rename so each job resolves to a unique name.", { category: "correctness" }),
19
+ WGL021: auditRule("WGL021", "report-only", "guidance", "Unused global variable", "Remove the unused global `variables:` entry.", { category: "best-practice" }),
20
+ WGL022: auditRule("WGL022", "report-only", "guidance", "Missing artifacts expiry", "Add `expire_in:` to artifacts to avoid disk bloat.", { category: "best-practice" }),
21
+ WGL023: auditRule("WGL023", "report-only", "guidance", "Overly broad rules (when: always)", "Add real conditions to the job's `rules:`.", { category: "best-practice" }),
22
+ WGL024: auditRule("WGL024", "report-only", "guidance", "Manual job without allow_failure", "Add `allow_failure: true` so a manual job doesn't block the pipeline.", { category: "best-practice" }),
23
+ WGL025: auditRule("WGL025", "report-only", "guidance", "Cache without a key", "Add a `cache.key` to avoid cross-job cache collisions.", { category: "best-practice" }),
24
+ WGL026: auditRule("WGL026", "merge-worthy", "guidance", "Privileged DinD service without TLS", "Set `DOCKER_TLS_CERTDIR` for privileged Docker-in-Docker services.", { category: "security" }),
25
+ WGL027: auditRule("WGL027", "merge-worthy", "guidance", "Empty script", "Give the job a non-empty `script:`; it currently does nothing.", { category: "correctness" }),
26
+ WGL028: auditRule("WGL028", "report-only", "guidance", "Redundant needs", "Drop `needs:` already implied by stage ordering.", { category: "best-practice" }),
27
+ WGL029: auditRule("WGL029", "merge-worthy", "guidance", "include/component resolved by a moving ref", "Pin `include:project`/component to a tag or commit SHA, not a branch.", { authority: [SCORECARD_PINNED] }),
28
+ WGL030: auditRule("WGL030", "merge-worthy", "guidance", "Insecure or mutable include:remote", "Use HTTPS and pin the remote include to an immutable ref.", { authority: [SCORECARD_PINNED] }),
29
+ WGL031: auditRule("WGL031", "merge-worthy", "deterministic", "Container image not pinned to a digest", "Pin the image to an immutable `@sha256:` digest.", { authority: [SCORECARD_PINNED] }),
30
+ WGL032: auditRule("WGL032", "merge-worthy", "guidance", "Possible include/component impersonation", "Verify the include source; it resembles a well-known project.", { authority: [SCORECARD_PINNED] }),
31
+ WGL033: auditRule("WGL033", "merge-worthy", "guidance", "OIDC id_token without a scoped audience", "Set a specific `aud:` on the OIDC id_token.", { authority: [GH_OIDC] }),
32
+ WGL034: auditRule("WGL034", "merge-worthy", "guidance", "OIDC id_token mintable from a merge-request pipeline", "Restrict OIDC token minting to protected pipelines.", { authority: [GH_OIDC, GH_PWN] }),
33
+ WGL035: auditRule("WGL035", "merge-worthy", "guidance", "Untrusted CI variable interpolated into a script", "Pass untrusted variables via the environment and quote them; don't inline.", { authority: [GH_INJECTION] }),
34
+ WGL036: auditRule("WGL036", "merge-worthy", "guidance", "Privileged service reachable from merge-request pipelines", "Block privileged/DinD services on merge-request pipelines.", { authority: [GH_PWN] }),
35
+ WGL037: auditRule("WGL037", "merge-worthy", "guidance", "Security gate on an untrusted ref regex", "Don't gate security decisions on a regex over an untrusted ref variable.", { authority: [GH_PWN] }),
36
+ WGL038: auditRule("WGL038", "merge-worthy", "guidance", "Secret reachable from a merge-request pipeline", "Scope secret-like variables to protected branches/pipelines.", { authority: [GH_SECRETS, GH_PWN] }),
37
+ WGL039: auditRule("WGL039", "merge-worthy", "guidance", "Secret printed to job logs", "Stop echoing the secret-like variable; mask it.", { authority: [GH_SECRETS] }),
38
+ WGL040: auditRule("WGL040", "merge-worthy", "guidance", "Hardcoded credential in a registry login", "Move the credential to a masked CI variable and rotate it.", { authority: [GH_SECRETS] }),
39
+ WGL041: auditRule("WGL041", "merge-worthy", "guidance", "Tautological rules:if condition", "Fix the always-true `rules:if`; it may neutralize a gate.", { category: "correctness" }),
40
+ WGL042: auditRule("WGL042", "report-only", "guidance", "Unreachable rules after an unconditional match", "Remove the dead `rules:` entries after the catch-all.", { category: "best-practice" }),
41
+ WGL043: auditRule("WGL043", "merge-worthy", "guidance", "Match-anything regex gate in rules:if", "Tighten the regex; a match-anything gate is no gate.", { authority: [GH_PWN] }),
42
+ WGL044: auditRule("WGL044", "merge-worthy", "guidance", "Public artifacts expose build output", "Mark sensitive artifacts non-public (`public: false`).", { category: "security" }),
43
+ WGL045: auditRule("WGL045", "merge-worthy", "guidance", "Artifact path may capture a credential file", "Narrow the artifact path so it can't capture credential files.", { authority: [GH_SECRETS] }),
44
+ WGL046: auditRule("WGL046", "merge-worthy", "guidance", "Cache populated in a merge-request pipeline", "Don't populate caches from merge-request pipelines (poisoning risk).", { authority: [GH_PWN] }),
45
+ WGL047: auditRule("WGL047", "merge-worthy", "guidance", "Software piped to a shell without verification", "Verify a checksum/signature before executing fetched scripts.", { authority: [SCORECARD_PINNED] }),
46
+ WGL048: auditRule("WGL048", "report-only", "guidance", "Pipeline without workflow:name", "Add a `workflow:name` for clearer pipeline naming.", { category: "best-practice" }),
47
+ };
package/src/plugin.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  import type { LexiconPlugin, IntrinsicDef, InitTemplateSet } from "@intentius/chant/lexicon";
9
9
  import type { LintRule } from "@intentius/chant/lint/rule";
10
10
  import { postSynthChecks as postSynthCheckList } from "./lint/post-synth";
11
+ import { gitlabAuditCatalog } from "./lint/audit-catalog";
11
12
  import { createSkillsLoader, createDiffTool, createCatalogResource } from "@intentius/chant/lexicon-plugin-helpers";
12
13
  import { join, dirname } from "path";
13
14
  import { fileURLToPath } from "url";
@@ -21,9 +22,21 @@ import { gitlabCompletions } from "./lsp/completions";
21
22
  import { gitlabHover } from "./lsp/hover";
22
23
  import { GitLabParser } from "./import/parser";
23
24
  import { GitLabGenerator } from "./import/generator";
25
+ import { generateGitlabPipeline } from "./components/generate-pipeline";
24
26
 
25
27
  export const gitlabPlugin: LexiconPlugin = {
26
28
  name: "gitlab",
29
+ auditCatalog: () => gitlabAuditCatalog,
30
+ // Generate mode (#688): synthesize a .gitlab-ci.yml from the component graph.
31
+ generateComponentPipeline: (components, options) => generateGitlabPipeline(components, options),
32
+ // Self-upgrade: where the pinned GitLab schema version lives + its upstream (#685).
33
+ upstreamPin: {
34
+ file: "src/codegen/fetch.ts",
35
+ pattern: /export const GITLAB_SCHEMA_VERSION\s*=\s*"([^"]+)"/,
36
+ replace: (v, line) =>
37
+ line.replace(/export const GITLAB_SCHEMA_VERSION\s*=\s*"[^"]+"/, `export const GITLAB_SCHEMA_VERSION = "${v}"`),
38
+ upstream: { owner: "gitlab-org", repo: "gitlab", kind: "tags", tagSuffix: "-ee" },
39
+ },
27
40
  serializer: gitlabSerializer,
28
41
 
29
42
  lintRules(): LintRule[] {