@intentius/chant-lexicon-gitlab 0.46.0 → 0.50.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.
Files changed (50) hide show
  1. package/dist/components/generate-op-pipeline.d.ts +37 -0
  2. package/dist/components/generate-op-pipeline.d.ts.map +1 -0
  3. package/dist/index.d.ts +1 -0
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/integrity.json +8 -5
  6. package/dist/lint/audit-catalog.d.ts.map +1 -1
  7. package/dist/lint/post-synth/index.d.ts.map +1 -1
  8. package/dist/lint/post-synth/wgl019.d.ts.map +1 -1
  9. package/dist/lint/post-synth/wgl049.d.ts +14 -0
  10. package/dist/lint/post-synth/wgl049.d.ts.map +1 -0
  11. package/dist/lint/post-synth/wgl050.d.ts +13 -0
  12. package/dist/lint/post-synth/wgl050.d.ts.map +1 -0
  13. package/dist/lint/rules/import-source.d.ts +28 -0
  14. package/dist/lint/rules/import-source.d.ts.map +1 -0
  15. package/dist/lint/rules/missing-script.d.ts.map +1 -1
  16. package/dist/lint/rules/missing-stage.d.ts.map +1 -1
  17. package/dist/manifest.json +1 -1
  18. package/dist/okf/index.md +2 -0
  19. package/dist/okf/rules/WGL049.md +15 -0
  20. package/dist/okf/rules/WGL050.md +15 -0
  21. package/dist/okf/types/Job.md +2 -0
  22. package/dist/op/builders.d.ts +29 -0
  23. package/dist/op/builders.d.ts.map +1 -0
  24. package/dist/plugin.d.ts.map +1 -1
  25. package/dist/rules/import-source.ts +58 -0
  26. package/dist/rules/missing-script.ts +13 -0
  27. package/dist/rules/missing-stage.ts +8 -0
  28. package/dist/rules/wgl019.ts +6 -1
  29. package/dist/rules/wgl049.ts +57 -0
  30. package/dist/rules/wgl050.ts +49 -0
  31. package/package.json +3 -3
  32. package/src/components/generate-op-pipeline.test.ts +94 -0
  33. package/src/components/generate-op-pipeline.ts +111 -0
  34. package/src/index.ts +8 -0
  35. package/src/lint/audit-catalog.ts +5 -0
  36. package/src/lint/post-synth/index.ts +4 -0
  37. package/src/lint/post-synth/wgl019.test.ts +8 -0
  38. package/src/lint/post-synth/wgl019.ts +6 -1
  39. package/src/lint/post-synth/wgl049.test.ts +71 -0
  40. package/src/lint/post-synth/wgl049.ts +57 -0
  41. package/src/lint/post-synth/wgl050.test.ts +57 -0
  42. package/src/lint/post-synth/wgl050.ts +49 -0
  43. package/src/lint/rules/import-source.ts +58 -0
  44. package/src/lint/rules/missing-script.ts +13 -0
  45. package/src/lint/rules/missing-stage.ts +8 -0
  46. package/src/lint/rules/rules.test.ts +60 -0
  47. package/src/op/builders.test.ts +43 -0
  48. package/src/op/builders.ts +39 -0
  49. package/src/plugin.test.ts +1 -1
  50. package/src/plugin.ts +3 -0
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Tests for generate mode's scheduled Op → GitLab CI YAML synthesis (#927).
3
+ * Three things this must prove, mirroring `./generate-pipeline.test.ts`'s
4
+ * acceptance style:
5
+ *
6
+ * 1. Every scheduled Op lands as one job in a single generated file (GitLab
7
+ * has no in-file cron — see the module doc), each job gated to its own
8
+ * Pipeline Schedule via `rules:`.
9
+ * 2. The header comment names every Op's cron and finding-mode, since the
10
+ * cron itself can't live in the YAML.
11
+ * 3. A cross-cutting generator change (runCommand/beforeScript/extraScript)
12
+ * is a single edit reflected in every job's script.
13
+ */
14
+
15
+ import { describe, test, expect } from "vitest";
16
+ import { parseYAML } from "@intentius/chant/yaml";
17
+ import { generateGitlabOpPipeline } from "./generate-op-pipeline";
18
+ import type { ScheduledOpSpec } from "@intentius/chant/lexicon";
19
+
20
+ describe("generateGitlabOpPipeline: one file, one job per scheduled Op", () => {
21
+ test("produces a single file with stages: and one job per Op", () => {
22
+ const specs: ScheduledOpSpec[] = [
23
+ { name: "actions-audit", schedule: "0 6 * * *" },
24
+ { name: "prod-reconcile", schedule: "0 * * * *", findingMode: "merge-request" },
25
+ ];
26
+ const result = generateGitlabOpPipeline(specs);
27
+
28
+ expect(result.files).toHaveLength(1);
29
+ expect(result.files[0].name).toBe("scheduled-ops.gitlab-ci.yml");
30
+
31
+ const parsed = parseYAML(result.files[0].yaml);
32
+ expect(parsed.stages).toEqual(["scheduled-ops"]);
33
+ expect(parsed["actions-audit"]).toBeDefined();
34
+ expect(parsed["prod-reconcile"]).toBeDefined();
35
+ });
36
+
37
+ test("each job is gated to its own Pipeline Schedule via rules:, runs chant run <name>", () => {
38
+ const result = generateGitlabOpPipeline([{ name: "actions-audit", schedule: "0 6 * * *" }]);
39
+ const parsed = parseYAML(result.files[0].yaml);
40
+ const job = parsed["actions-audit"] as Record<string, unknown>;
41
+
42
+ expect(job.stage).toBe("scheduled-ops");
43
+ expect(job.rules).toEqual([
44
+ { if: '$CI_PIPELINE_SOURCE == "schedule" && $CHANT_SCHEDULED_OP == "actions-audit"' },
45
+ ]);
46
+ expect(job.script).toEqual(["chant run actions-audit"]);
47
+ });
48
+
49
+ test("the header comment names every Op's cron, selector value, and finding-mode", () => {
50
+ const result = generateGitlabOpPipeline([
51
+ { name: "actions-audit", schedule: "0 6 * * *", findingMode: "issue" },
52
+ ]);
53
+ expect(result.files[0].yaml).toContain('cron "0 6 * * *"');
54
+ expect(result.files[0].yaml).toContain('CHANT_SCHEDULED_OP="actions-audit"');
55
+ expect(result.files[0].yaml).toContain("finding-mode issue");
56
+ expect(result.files[0].yaml).toContain("GITLAB_TOKEN");
57
+ });
58
+
59
+ test("report mode's header line carries no token requirement", () => {
60
+ const result = generateGitlabOpPipeline([{ name: "actions-audit", schedule: "0 6 * * *" }]);
61
+ expect(result.files[0].yaml).not.toContain("GITLAB_TOKEN");
62
+ });
63
+
64
+ test("an empty Op set still produces the (empty) stages file", () => {
65
+ const result = generateGitlabOpPipeline([]);
66
+ expect(result.files).toHaveLength(1);
67
+ expect(result.jobs).toEqual([]);
68
+ const parsed = parseYAML(result.files[0].yaml);
69
+ expect(parsed.stages).toEqual(["scheduled-ops"]);
70
+ });
71
+ });
72
+
73
+ describe("generateGitlabOpPipeline: a cross-cutting change is one generator edit, not per-job", () => {
74
+ test("runCommand/beforeScript/extraScript apply uniformly across every job", () => {
75
+ const specs: ScheduledOpSpec[] = [
76
+ { name: "actions-audit", schedule: "0 6 * * *" },
77
+ { name: "prod-reconcile", schedule: "0 * * * *" },
78
+ ];
79
+ const result = generateGitlabOpPipeline(specs, {
80
+ runCommand: ["chant", "run", "{name}", "--temporal"],
81
+ beforeScript: ["npm ci"],
82
+ extraScript: ["echo done"],
83
+ });
84
+ const parsed = parseYAML(result.files[0].yaml);
85
+
86
+ for (const spec of specs) {
87
+ const job = parsed[spec.name] as Record<string, unknown>;
88
+ const script = job.script as string[];
89
+ expect(script[0]).toBe("npm ci");
90
+ expect(script[1]).toContain("--temporal");
91
+ expect(script[2]).toBe("echo done");
92
+ }
93
+ });
94
+ });
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Generate mode — scheduled Op → GitLab CI YAML (#927).
3
+ *
4
+ * The Op counterpart to `./generate-pipeline.ts` (#563): that module
5
+ * synthesizes a deploy-time component graph as one `.gitlab-ci.yml`; this one
6
+ * synthesizes a cron-triggered job per stateless Op — the CI-native
7
+ * alternative to a Temporal `TemporalSchedule` for downstream projects that
8
+ * don't run Temporal (`WorkflowAuditOp`/`PipelineAuditOp`/`ReconcileOp` all
9
+ * accept an optional `schedule` precisely for this).
10
+ *
11
+ * Unlike GitHub Actions' per-workflow `on.schedule`, GitLab has no in-file
12
+ * cron at all — a schedule is a project-level object (Settings → CI/CD →
13
+ * Schedules) that runs the project's *existing* `.gitlab-ci.yml` with a
14
+ * chosen cron and CI/CD variables. So every scheduled Op here becomes one
15
+ * job in a single generated file, gated to run only under its own Pipeline
16
+ * Schedule (`$CI_PIPELINE_SOURCE == "schedule"` plus a per-op selector
17
+ * variable) — the cron itself is configured on the Pipeline Schedule, not in
18
+ * this YAML, and the generated file's header comment states what to set up.
19
+ * Each job runs exactly one invocation, `chant run <name>` by default — never
20
+ * inlined audit/reconcile logic. The finding-mode itself is already baked
21
+ * into the Op's own activity args at build time by the composite that
22
+ * created it; GitLab has no per-job `permissions:` concept (unlike GitHub
23
+ * Actions), so a non-`report` mode's write access comes from whatever
24
+ * `GITLAB_TOKEN`/CI-CD-variable configuration the project already has —
25
+ * this generator documents the requirement rather than fabricating a
26
+ * variable nothing reads.
27
+ */
28
+
29
+ import { emitYAML } from "@intentius/chant/yaml";
30
+ import type {
31
+ ComponentPipelineOptions as GenerateGitlabOpOptions,
32
+ OpFindingMode,
33
+ OpPipelineJob,
34
+ OpPipelineResult as GenerateGitlabOpResult,
35
+ ScheduledOpSpec,
36
+ } from "@intentius/chant/lexicon";
37
+
38
+ export type { GenerateGitlabOpOptions, GenerateGitlabOpResult };
39
+
40
+ /** GitLab CI job names must be safe YAML keys; Op names are already kebab-case in every fixture, but normalize defensively (mirrors `./generate-pipeline.ts`'s `toJobName`). */
41
+ function toJobName(opName: string): string {
42
+ return opName.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
43
+ }
44
+
45
+ const DEFAULT_IMAGE = "node:22-slim";
46
+ const STAGE = "scheduled-ops";
47
+
48
+ /** The CI/CD variable a Pipeline Schedule sets to select which job it runs. */
49
+ const SELECTOR_VAR = "CHANT_SCHEDULED_OP";
50
+
51
+ /** One setup line per Op in the generated file's header comment. */
52
+ function setupLine(spec: ScheduledOpSpec, jobName: string, mode: OpFindingMode): string {
53
+ const tokenNote = mode === "report" ? "" : " — needs a GITLAB_TOKEN CI/CD variable (masked, scope: api)";
54
+ return `# ${jobName}: cron "${spec.schedule}", ${SELECTOR_VAR}="${spec.name}", finding-mode ${mode}${tokenNote}`;
55
+ }
56
+
57
+ /**
58
+ * Synthesize one `.gitlab-ci.yml` job per scheduled Op, all in a single file
59
+ * (GitLab has no per-file cron — see the module doc). Wired into core's Op
60
+ * generate mode via the gitlab lexicon plugin's `generateOpPipeline`
61
+ * (../plugin.ts).
62
+ */
63
+ export function generateGitlabOpPipeline(
64
+ ops: ScheduledOpSpec[],
65
+ options: GenerateGitlabOpOptions = {},
66
+ ): GenerateGitlabOpResult {
67
+ const image = options.image ?? DEFAULT_IMAGE;
68
+ const runCommand = options.runCommand ?? ["chant", "run", "{name}"];
69
+ const beforeScript = options.beforeScript ?? [];
70
+ const extraScript = options.extraScript ?? [];
71
+
72
+ const jobs: OpPipelineJob[] = [];
73
+ const doc: Record<string, unknown> = { stages: [STAGE] };
74
+ if (options.variables && Object.keys(options.variables).length > 0) doc.variables = options.variables;
75
+
76
+ const headerLines = [
77
+ "# Scheduled Ops (chant #927) — GitLab has no in-file cron. Create one",
78
+ "# Pipeline Schedule per Op below (Settings > CI/CD > Schedules): set its",
79
+ `# cron to the value noted here and its ${SELECTOR_VAR} CI/CD variable to`,
80
+ "# the Op's name, so only that job runs on that schedule.",
81
+ "#",
82
+ ];
83
+
84
+ for (const spec of ops) {
85
+ const findingMode = spec.findingMode ?? "report";
86
+ const jobName = toJobName(spec.name);
87
+ jobs.push({ jobName, op: spec.name, schedule: spec.schedule, findingMode });
88
+ headerLines.push(setupLine(spec, jobName, findingMode));
89
+
90
+ const runParts = runCommand.map((part) => part.replace("{name}", spec.name));
91
+ const script = [...beforeScript, runParts.join(" "), ...extraScript];
92
+
93
+ doc[jobName] = {
94
+ stage: STAGE,
95
+ image,
96
+ rules: [{ if: `$CI_PIPELINE_SOURCE == "schedule" && $${SELECTOR_VAR} == "${spec.name}"` }],
97
+ script,
98
+ };
99
+ }
100
+
101
+ const sections: string[] = [];
102
+ sections.push("stages:" + emitYAML(doc.stages, 1));
103
+ if (doc.variables) sections.push("variables:" + emitYAML(doc.variables, 1));
104
+ for (const { jobName } of jobs) {
105
+ sections.push(`${jobName}:` + emitYAML(doc[jobName], 1));
106
+ }
107
+
108
+ const yaml = headerLines.join("\n") + "\n\n" + sections.join("\n\n") + "\n";
109
+
110
+ return { files: [{ name: "scheduled-ops.gitlab-ci.yml", yaml }], jobs };
111
+ }
package/src/index.ts CHANGED
@@ -4,6 +4,14 @@ export { gitlabSerializer } from "./serializer";
4
4
  // Plugin
5
5
  export { gitlabPlugin } from "./plugin";
6
6
 
7
+ // Typed Op step-builder wrapper (chant #1288 Stage 2) — gitlabPipeline with
8
+ // authoring-time types derived from this lexicon's own GitlabPipelineArgs
9
+ // (see lexicons/k8s/src/op/builders.ts's module doc for why this lives here
10
+ // rather than in core or the temporal barrel). Opt-in:
11
+ // `@intentius/chant-lexicon-temporal`'s same-named export is core's original
12
+ // untyped builder, unchanged, for cloud-agnostic authoring.
13
+ export { gitlabPipeline } from "./op/builders";
14
+
7
15
  // Intrinsics
8
16
  export { reference, ReferenceIntrinsic } from "./intrinsics";
9
17
 
@@ -44,4 +44,9 @@ export const gitlabAuditCatalog: Record<string, RuleMeta> = {
44
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
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
46
  WGL048: auditRule("WGL048", "report-only", "guidance", "Pipeline without workflow:name", "Add a `workflow:name` for clearer pipeline naming.", { category: "best-practice" }),
47
+
48
+ // Efficiency (#444) — waste, not a safety/correctness issue. Always
49
+ // report-only: none of these warrant a merge on their own.
50
+ WGL049: auditRule("WGL049", "report-only", "guidance", "Dependency install without a cache", "Add a `cache:` covering the dependency directory.", { category: "efficiency" }),
51
+ WGL050: auditRule("WGL050", "report-only", "guidance", "Merge-request job missing interruptible", "Add `interruptible: true` so a superseded pipeline can be cancelled.", { category: "efficiency" }),
47
52
  };
@@ -39,6 +39,8 @@ import { wgl045 } from "./wgl045";
39
39
  import { wgl046 } from "./wgl046";
40
40
  import { wgl047 } from "./wgl047";
41
41
  import { wgl048 } from "./wgl048";
42
+ import { wgl049 } from "./wgl049";
43
+ import { wgl050 } from "./wgl050";
42
44
 
43
45
  export const postSynthChecks: PostSynthCheck[] = [
44
46
  wgl010,
@@ -80,4 +82,6 @@ export const postSynthChecks: PostSynthCheck[] = [
80
82
  wgl046,
81
83
  wgl047,
82
84
  wgl048,
85
+ wgl049,
86
+ wgl050,
83
87
  ];
@@ -53,6 +53,14 @@ describe("WGL019: Missing Retry on Deploy Jobs", () => {
53
53
  expect(diags).toHaveLength(0);
54
54
  });
55
55
 
56
+ test("does not flag deploy job with explicit retry: 0 (#1544)", () => {
57
+ const entities = new Map<string, Declarable>([
58
+ ["deployApp", new MockJob({ script: ["deploy.sh"], stage: "deploy", retry: 0 })],
59
+ ]);
60
+ const diags = wgl019.check(makeCtx(entities));
61
+ expect(diags).toHaveLength(0);
62
+ });
63
+
56
64
  test("does not flag non-deploy job without retry", () => {
57
65
  const entities = new Map<string, Declarable>([
58
66
  ["testJob", new MockJob({ script: ["npm test"], stage: "test" })],
@@ -28,7 +28,12 @@ export const wgl019: PostSynthCheck = {
28
28
  const stage = props.stage as string | undefined;
29
29
  if (!stage || !DEPLOY_STAGES.has(stage.toLowerCase())) continue;
30
30
 
31
- if (!props.retry) {
31
+ // chant #1544 — `!props.retry` treated an explicit `retry: 0` the same
32
+ // as no `retry` at all, warning on a job that stated its policy
33
+ // ("retrying a deploy without a human is deliberate") as if it hadn't.
34
+ // `retry: 0` is falsy but present — only an actually-absent key means
35
+ // "no retry strategy".
36
+ if (props.retry === undefined) {
32
37
  diagnostics.push({
33
38
  checkId: "WGL019",
34
39
  severity: "info",
@@ -0,0 +1,71 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
3
+ import { wgl049 } from "./wgl049";
4
+
5
+ function makeCtx(yaml: string): PostSynthContext {
6
+ return {
7
+ outputs: new Map([["gitlab", yaml]]),
8
+ entities: new Map(),
9
+ buildResult: { outputs: new Map([["gitlab", yaml]]), entities: new Map(), warnings: [], errors: [], sourceFileCount: 1 },
10
+ };
11
+ }
12
+
13
+ describe("WGL049: dependency install without a cache", () => {
14
+ test("flags a job that installs deps with no cache in scope", () => {
15
+ const yaml = `build:
16
+ stage: build
17
+ script:
18
+ - npm ci
19
+ - npm run build
20
+ `;
21
+ const diags = wgl049.check(makeCtx(yaml));
22
+ expect(diags).toHaveLength(1);
23
+ expect(diags[0].checkId).toBe("WGL049");
24
+ expect(diags[0].entity).toBe("build");
25
+ });
26
+
27
+ test("does not flag when the job has its own cache", () => {
28
+ const yaml = `build:
29
+ stage: build
30
+ cache:
31
+ key: npm-cache
32
+ paths:
33
+ - node_modules/
34
+ script:
35
+ - npm ci
36
+ `;
37
+ expect(wgl049.check(makeCtx(yaml))).toHaveLength(0);
38
+ });
39
+
40
+ test("does not flag when a pipeline-wide cache: block exists", () => {
41
+ const yaml = `cache:
42
+ key: global
43
+ paths:
44
+ - node_modules/
45
+
46
+ build:
47
+ stage: build
48
+ script:
49
+ - npm ci
50
+ `;
51
+ expect(wgl049.check(makeCtx(yaml))).toHaveLength(0);
52
+ });
53
+
54
+ test("does not flag a job that extends another config", () => {
55
+ const yaml = `build:
56
+ extends: .node-job
57
+ script:
58
+ - npm ci
59
+ `;
60
+ expect(wgl049.check(makeCtx(yaml))).toHaveLength(0);
61
+ });
62
+
63
+ test("does not flag a job with no dependency install command", () => {
64
+ const yaml = `lint:
65
+ stage: test
66
+ script:
67
+ - eslint .
68
+ `;
69
+ expect(wgl049.check(makeCtx(yaml))).toHaveLength(0);
70
+ });
71
+ });
@@ -0,0 +1,57 @@
1
+ /**
2
+ * WGL049: Dependency Install Without a Cache
3
+ *
4
+ * Flags a job whose `script:` runs a package-manager install command (`npm
5
+ * ci`, `pip install`, `bundle install`, etc.) with no `cache:` in scope —
6
+ * neither on the job itself, nor at the pipeline's `default:`/top level.
7
+ * Every run re-fetches the same dependencies from a cold cache. A job that
8
+ * `extends:` another config is left alone — the cache may be inherited and
9
+ * this check has no way to confirm that. Efficiency (#444), not a
10
+ * correctness or security issue.
11
+ */
12
+
13
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
14
+ import { getPrimaryOutput, extractJobs, extractJobSection } from "./yaml-helpers";
15
+
16
+ const DEP_INSTALL_RE = /\b(npm (install|ci)|yarn install|pnpm install|pip install|pip3 install|bundle install|composer install|go mod download|mvn (install|dependency:resolve))\b/i;
17
+
18
+ function hasPipelineWideCache(yaml: string): boolean {
19
+ const sections = yaml.split("\n\n");
20
+ if (sections.some((s) => /^cache:/.test(s))) return true;
21
+ const defaultSection = sections.find((s) => /^default:/.test(s));
22
+ return !!defaultSection && /\n\s+cache:/.test(defaultSection);
23
+ }
24
+
25
+ export const wgl049: PostSynthCheck = {
26
+ id: "WGL049",
27
+ description: "Job installs dependencies with no cache: in scope",
28
+
29
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
30
+ const diagnostics: PostSynthDiagnostic[] = [];
31
+
32
+ for (const [, output] of ctx.outputs) {
33
+ const yaml = getPrimaryOutput(output);
34
+ if (hasPipelineWideCache(yaml)) continue;
35
+
36
+ for (const [jobName, job] of extractJobs(yaml)) {
37
+ if (jobName.startsWith(".")) continue; // hidden/template job, not run directly
38
+ if (job.extends && job.extends.length > 0) continue; // cache may be inherited; can't confirm
39
+
40
+ const section = extractJobSection(yaml, jobName);
41
+ if (!section) continue;
42
+ if (/^\s+cache:/m.test(section)) continue; // job-level cache present
43
+ if (!DEP_INSTALL_RE.test(section)) continue;
44
+
45
+ diagnostics.push({
46
+ checkId: "WGL049",
47
+ severity: "info",
48
+ message: `Job "${jobName}" installs dependencies with no cache: in scope — every run re-fetches from the registry. Add a cache: covering the dependency directory.`,
49
+ entity: jobName,
50
+ lexicon: "gitlab",
51
+ });
52
+ }
53
+ }
54
+
55
+ return diagnostics;
56
+ },
57
+ };
@@ -0,0 +1,57 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
3
+ import { wgl050 } from "./wgl050";
4
+
5
+ function makeCtx(yaml: string): PostSynthContext {
6
+ return {
7
+ outputs: new Map([["gitlab", yaml]]),
8
+ entities: new Map(),
9
+ buildResult: { outputs: new Map([["gitlab", yaml]]), entities: new Map(), warnings: [], errors: [], sourceFileCount: 1 },
10
+ };
11
+ }
12
+
13
+ describe("WGL050: merge-request job missing interruptible", () => {
14
+ test("flags a merge-request-reachable job with no interruptible: true", () => {
15
+ const yaml = `test:
16
+ rules:
17
+ - if: $CI_PIPELINE_SOURCE == "merge_request_event"
18
+ script:
19
+ - npm test
20
+ `;
21
+ const diags = wgl050.check(makeCtx(yaml));
22
+ expect(diags).toHaveLength(1);
23
+ expect(diags[0].checkId).toBe("WGL050");
24
+ expect(diags[0].entity).toBe("test");
25
+ });
26
+
27
+ test("does not flag when interruptible: true is set", () => {
28
+ const yaml = `test:
29
+ rules:
30
+ - if: $CI_PIPELINE_SOURCE == "merge_request_event"
31
+ interruptible: true
32
+ script:
33
+ - npm test
34
+ `;
35
+ expect(wgl050.check(makeCtx(yaml))).toHaveLength(0);
36
+ });
37
+
38
+ test("does not flag a deploy job", () => {
39
+ const yaml = `deploy-app:
40
+ rules:
41
+ - if: $CI_PIPELINE_SOURCE == "merge_request_event"
42
+ script:
43
+ - ./deploy.sh
44
+ `;
45
+ expect(wgl050.check(makeCtx(yaml))).toHaveLength(0);
46
+ });
47
+
48
+ test("does not flag a job not reachable from merge requests", () => {
49
+ const yaml = `test:
50
+ rules:
51
+ - if: $CI_COMMIT_BRANCH == "main"
52
+ script:
53
+ - npm test
54
+ `;
55
+ expect(wgl050.check(makeCtx(yaml))).toHaveLength(0);
56
+ });
57
+ });
@@ -0,0 +1,49 @@
1
+ /**
2
+ * WGL050: Merge-Request Job Missing interruptible
3
+ *
4
+ * Flags a job reachable from merge-request pipelines that doesn't set
5
+ * `interruptible: true`. Without it, GitLab's auto-cancel-redundant-pipelines
6
+ * setting can't cancel the job when a new push supersedes it, so the runner
7
+ * keeps spending capacity on a pipeline nobody wants anymore. Deploy jobs are
8
+ * left alone — cancelling mid-deploy is its own hazard, not an efficiency
9
+ * win. Efficiency (#444), not a correctness or security issue.
10
+ */
11
+
12
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
13
+ import { getPrimaryOutput, isMergeRequestReachable } from "./yaml-helpers";
14
+
15
+ const RESERVED_TOP_LEVEL_KEYS = new Set(["stages", "default", "workflow", "variables", "include", "cache"]);
16
+
17
+ export const wgl050: PostSynthCheck = {
18
+ id: "WGL050",
19
+ description: "Merge-request-reachable job is not interruptible",
20
+
21
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
22
+ const diagnostics: PostSynthDiagnostic[] = [];
23
+
24
+ for (const [, output] of ctx.outputs) {
25
+ const yaml = getPrimaryOutput(output);
26
+
27
+ for (const section of yaml.split("\n\n")) {
28
+ const top = section.split("\n")[0]?.match(/^(\.?[a-z][a-z0-9_.-]*):/i);
29
+ if (!top) continue;
30
+ const jobName = top[1];
31
+ if (jobName.startsWith(".") || RESERVED_TOP_LEVEL_KEYS.has(jobName)) continue;
32
+ if (/deploy/i.test(jobName)) continue; // cancelling mid-deploy is a hazard, not a win
33
+
34
+ if (!isMergeRequestReachable(section)) continue;
35
+ if (/^\s+interruptible:\s*true\s*$/m.test(section)) continue;
36
+
37
+ diagnostics.push({
38
+ checkId: "WGL050",
39
+ severity: "info",
40
+ message: `Job "${jobName}" runs on merge-request pipelines but is not interruptible: true — when a new commit supersedes this pipeline, this job keeps running instead of being cancelled. Add interruptible: true.`,
41
+ entity: jobName,
42
+ lexicon: "gitlab",
43
+ });
44
+ }
45
+ }
46
+
47
+ return diagnostics;
48
+ },
49
+ };
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Shared helper for gitlab lint rules that match a bare TypeScript
3
+ * identifier (`Job`, ...) and need to know whether it actually came from
4
+ * this lexicon before flagging it (chant #1544 — cross-lexicon rule bleed:
5
+ * `new Job(...)` matches ANY lexicon's `Job` class by name alone, so a
6
+ * multi-lexicon project got gitlab-only rules applied to github/forgejo
7
+ * jobs that were never missing anything).
8
+ */
9
+
10
+ import * as ts from "typescript";
11
+
12
+ /**
13
+ * The module specifier a top-level import bound `name` to, whether via a
14
+ * named import (`import { Job } from "..."`, matched on its local —
15
+ * possibly aliased — binding) or a namespace import (`import * as gl from
16
+ * "..."`, matched on the namespace's own name). Undefined when `name` isn't
17
+ * bound by any top-level import in this file (no import at all, a
18
+ * re-export chain, a dynamic `require`, …) — the caller should treat that
19
+ * as "can't tell", not "not gitlab", to stay conservative for the common
20
+ * single-lexicon-project case (and every pre-#1544 unit test fixture, which
21
+ * has no import statements at all).
22
+ */
23
+ export function importSourceFor(sourceFile: ts.SourceFile, name: string): string | undefined {
24
+ for (const stmt of sourceFile.statements) {
25
+ if (!ts.isImportDeclaration(stmt)) continue;
26
+ const moduleSpecifier = stmt.moduleSpecifier;
27
+ if (!ts.isStringLiteral(moduleSpecifier)) continue;
28
+ const namedBindings = stmt.importClause?.namedBindings;
29
+ if (!namedBindings) continue;
30
+
31
+ if (ts.isNamespaceImport(namedBindings)) {
32
+ if (namedBindings.name.text === name) return moduleSpecifier.text;
33
+ } else if (ts.isNamedImports(namedBindings)) {
34
+ for (const element of namedBindings.elements) {
35
+ if (element.name.text === name) return moduleSpecifier.text;
36
+ }
37
+ }
38
+ }
39
+ return undefined;
40
+ }
41
+
42
+ /**
43
+ * True when `expression` (a `new`-expression callee) resolves — via a
44
+ * traced import — to a lexicon OTHER than gitlab. False for an unresolved
45
+ * import (conservative: still checked) or one that does resolve to gitlab.
46
+ */
47
+ export function isJobFromAnotherLexicon(sourceFile: ts.SourceFile, expression: ts.LeftHandSideExpression): boolean {
48
+ let bindingName: string | undefined;
49
+ if (ts.isIdentifier(expression)) {
50
+ bindingName = expression.text;
51
+ } else if (ts.isPropertyAccessExpression(expression)) {
52
+ bindingName = ts.isIdentifier(expression.expression) ? expression.expression.text : undefined;
53
+ }
54
+ if (!bindingName) return false;
55
+
56
+ const source = importSourceFor(sourceFile, bindingName);
57
+ return source !== undefined && !source.includes("chant-lexicon-gitlab");
58
+ }
@@ -7,6 +7,7 @@
7
7
 
8
8
  import type { LintRule, LintDiagnostic, LintContext } from "@intentius/chant/lint/rule";
9
9
  import * as ts from "typescript";
10
+ import { isJobFromAnotherLexicon } from "./import-source";
10
11
 
11
12
  const VALID_EXECUTION_PROPS = new Set(["script", "trigger", "run"]);
12
13
 
@@ -31,6 +32,18 @@ export const missingScriptRule: LintRule = {
31
32
  isJob = true;
32
33
  }
33
34
 
35
+ // chant #1544 — cross-lexicon rule bleed: `new Job(...)` matches
36
+ // ANY lexicon's `Job` class by bare name alone (github's, forgejo's,
37
+ // ...), so a multi-lexicon project got WGL002 (gitlab-only) applied
38
+ // to github/forgejo jobs that were never missing anything — they
39
+ // just don't use `script`/`trigger`/`run`. Skip when the import
40
+ // resolves to a lexicon other than gitlab; an unresolved import
41
+ // (no `import` statement — most unit-test fixtures, a re-export) is
42
+ // "can't tell", so it keeps the previous, conservative behavior.
43
+ if (isJob && isJobFromAnotherLexicon(sourceFile, expression)) {
44
+ isJob = false;
45
+ }
46
+
34
47
  if (isJob && node.arguments && node.arguments.length > 0) {
35
48
  const props = node.arguments[0];
36
49
  if (ts.isObjectLiteralExpression(props)) {
@@ -7,6 +7,7 @@
7
7
 
8
8
  import type { LintRule, LintDiagnostic, LintContext } from "@intentius/chant/lint/rule";
9
9
  import * as ts from "typescript";
10
+ import { isJobFromAnotherLexicon } from "./import-source";
10
11
 
11
12
  export const missingStageRule: LintRule = {
12
13
  id: "WGL003",
@@ -28,6 +29,13 @@ export const missingStageRule: LintRule = {
28
29
  isJob = true;
29
30
  }
30
31
 
32
+ // chant #1544 — same cross-lexicon rule bleed as WGL002 (see
33
+ // missing-script.ts): a github/forgejo `Job` must not be judged
34
+ // against gitlab's `stage` convention.
35
+ if (isJob && isJobFromAnotherLexicon(sourceFile, expression)) {
36
+ isJob = false;
37
+ }
38
+
31
39
  if (isJob && node.arguments && node.arguments.length > 0) {
32
40
  const props = node.arguments[0];
33
41
  if (ts.isObjectLiteralExpression(props)) {