@intentius/chant-lexicon-gitlab 0.2.0 → 0.4.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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "algorithm": "sha256",
3
3
  "artifacts": {
4
- "manifest.json": "9bfe8386c57d5e1081398a3128f8c1ddde259e0ada0d4ec5e2ea4fafc3301eb1",
4
+ "manifest.json": "756eed0ed84a1582c77c4c21bbb21e0871f0aa098d602eb4d6ad7e51536b1b34",
5
5
  "meta.json": "931fc3246a55645b1493080bbeb160e5d205e42349e8bdca96ca243adb5f0da3",
6
6
  "types/index.d.ts": "5cd2e99f135a929b72bdd822d00d780d39b1cd407ba0cfb3d511c7ac9d667b58",
7
7
  "rules/artifact-no-expiry.ts": "3f3cabf9792cbf8207e53a25f506715466b19ec25e9c3b4d0d77fed6b2eb4542",
@@ -50,7 +50,7 @@
50
50
  "rules/yaml-helpers.ts": "5835982d5d088eb4788ebad8160b181bfdb6fc94d546eabee90a4c278a913988",
51
51
  "skills/chant-gitlab.md": "1e26a0c50ea891423479bd7fec3bc133f9185727c4a3eaadd525d7e7436056b6",
52
52
  "skills/chant-gitlab-migrate.md": "4853d04980560b379e5b0f7267dcb0a1e5d5879cb9e9931681ce76b2a5fe69d8",
53
- "skills/chant-gitlab-patterns.md": "cefda1b656f222293d16381dfbf452b2b376b545a69f62e55dc38907fe8aa7fa"
53
+ "skills/chant-gitlab-patterns.md": "6d9a44e9e8de4c3820be9d65381b0d2ede4cb3626aaaf0011e21838a9fbf556e"
54
54
  },
55
- "composite": "26728547d1b80d6bd7ac26734b9bc312b73971bc8a3238dcde789a85e1d83541"
55
+ "composite": "b9e5cdbd0ea6fcb5fc4229270fbc48219286f9f63703531806202eced8ff5c8f"
56
56
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitlab",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "chantVersion": ">=0.1.0",
5
5
  "namespace": "GitLab",
6
6
  "intrinsics": [
@@ -197,6 +197,21 @@ export const app = NodePipeline({
197
197
  });
198
198
  ```
199
199
 
200
+ ### Publish the plan to the merge request
201
+
202
+ `MrPlanReport` turns `chant lifecycle plan` into the MR plan widget (the `terraform` artifact type above). It runs the plan with `--report gitlab-mr`, writes the `{create,update,delete}` counts to `tfplan.json`, and declares it as `artifacts:reports:terraform`:
203
+
204
+ ```typescript
205
+ import { MrPlanReport } from "@intentius/chant-lexicon-gitlab";
206
+
207
+ export const plan = MrPlanReport({
208
+ environment: "prod",
209
+ before: ["aws sts get-caller-identity"], // credential setup — the plan reads live state
210
+ });
211
+ ```
212
+
213
+ The widget label always reads "Terraform" (GitLab's fixed string). Counts only — `adopt`/`noop` are excluded; run the plan in the job log for the per-resource breakdown.
214
+
200
215
  ## Rules and Conditional Execution
201
216
 
202
217
  ### Branch-Based Rules
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant-lexicon-gitlab",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "GitLab CI lexicon for chant — declarative IaC in TypeScript",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://intentius.io/chant",
@@ -30,7 +30,7 @@ npm install --save-dev @intentius/chant-lexicon-gitlab
30
30
 
31
31
  {{file:docs-snippets/src/quickstart.ts}}
32
32
 
33
- The lexicon provides **3 resources** (Job, Workflow, Default), **16 property types** (Image, Cache, Artifacts, Rule, Environment, Trigger, Need, Service, and more), the \`CI\` pseudo-parameter object for predefined variables, and the \`reference()\` intrinsic for YAML \`!reference\` tags.
33
+ The lexicon provides **3 resources** (Job, Workflow, Default), **16 property types** (Image, Cache, Artifacts, Rule, Environment, Trigger, Need, Service, and more), the \`CI\` pseudo-parameter object for predefined variables, and the \`reference()\` intrinsic for YAML \`!reference\` tags. It also ships **4 lint rules** + **39 post-synth checks** (including a CI/CD supply-chain security pass, WGL029–048) and a [\`chant migrate\`](./migration) source for translating GitHub Actions workflows.
34
34
  `;
35
35
 
36
36
  const outputFormat = `The GitLab lexicon serializes resources into **\`.gitlab-ci.yml\` YAML**. Keys are
@@ -435,6 +435,90 @@ Flags jobs whose \`extends:\` references a template or hidden job not defined in
435
435
 
436
436
  Detects cycles in the \`needs:\` dependency graph. If job A needs B and B needs A (directly or transitively), GitLab rejects the pipeline. Reports the full cycle chain in the diagnostic message.
437
437
 
438
+ ### WGL016 — Secret in a variables block
439
+
440
+ **Severity:** error
441
+
442
+ Detects hardcoded passwords, tokens, or keys in a \`variables:\` block. Move them to CI/CD masked variables instead of committing them to the pipeline. The precursor to the WGL038–040 secret-scoping checks.
443
+
444
+ ### WGL017 — Insecure registry
445
+
446
+ **Severity:** warning
447
+
448
+ Flags Docker push/pull to a non-HTTPS registry in a job script. HTTP gives the registry traffic no transport integrity.
449
+
450
+ ### WGL018 — Missing timeout
451
+
452
+ **Severity:** warning
453
+
454
+ Flags jobs without an explicit \`timeout:\`. The instance default (often 1 hour) is too long for most jobs and lets a hung job hold a runner.
455
+
456
+ ### WGL019 — Missing retry on deploy jobs
457
+
458
+ **Severity:** info
459
+
460
+ Deploy-stage jobs benefit from a \`retry:\` strategy to ride out transient infrastructure failures. Informational, not required.
461
+
462
+ ### WGL020 — Duplicate job names
463
+
464
+ **Severity:** error
465
+
466
+ Detects multiple jobs that resolve to the same kebab-case key in the serialized YAML. GitLab silently merges duplicate keys, so one job's config quietly overwrites the other.
467
+
468
+ ### WGL021 — Unused variables
469
+
470
+ **Severity:** warning
471
+
472
+ Flags global \`variables:\` not referenced by any job script — usually stale configuration adding noise.
473
+
474
+ ### WGL022 — Missing artifacts expiry
475
+
476
+ **Severity:** warning
477
+
478
+ Flags \`artifacts:\` without \`expire_in:\`. Depending on instance config the default is "never expire," which bloats storage.
479
+
480
+ ### WGL023 — Overly broad rules
481
+
482
+ **Severity:** info
483
+
484
+ Flags a job whose only rule is \`when: always\` with no conditions (\`if:\`, \`changes:\`, …). That disables all pipeline filtering for the job, which is usually unintended.
485
+
486
+ ### WGL024 — Manual without allow_failure
487
+
488
+ **Severity:** warning
489
+
490
+ Flags \`when: manual\` jobs that don't set \`allow_failure: true\`. Without it the manual job blocks the pipeline from progressing past its stage until someone triggers it.
491
+
492
+ ### WGL025 — Missing cache key
493
+
494
+ **Severity:** warning
495
+
496
+ Flags \`cache:\` without a \`key:\`. GitLab falls back to the \`default\` key, causing cache collisions between unrelated jobs on the same runner.
497
+
498
+ ### WGL026 — Privileged services without TLS
499
+
500
+ **Severity:** warning
501
+
502
+ Flags Docker-in-Docker (DinD) services that don't set \`DOCKER_TLS_CERTDIR\`, leaving the Docker daemon on an unencrypted socket. Extended by WGL036 for the merge-request-reachable case.
503
+
504
+ ### WGL027 — Empty script
505
+
506
+ **Severity:** error
507
+
508
+ Detects jobs with \`script: []\` or only empty strings. GitLab rejects empty scripts at pipeline validation time.
509
+
510
+ ### WGL028 — Redundant needs
511
+
512
+ **Severity:** info
513
+
514
+ Detects \`needs:\` entries already implied by stage ordering. Not incorrect, but redundant needs add noise and make the pipeline harder to maintain.
515
+
516
+ ## Supply-chain security pass (WGL029–048)
517
+
518
+ WGL029 onward are a CI/CD supply-chain security pass, the GitLab counterpart to the github lexicon's GHA029–058: pin & vet includes/components/images, scope \`CI_JOB_TOKEN\` and OIDC, guard trust boundaries against untrusted CI input, mask/protect/scope secrets, reject unsound \`rules:\` expressions, and keep artifacts/caches honest. They run statically on the emitted \`.gitlab-ci.yml\`.
519
+
520
+ The checks that need a *moving external truth* — whether a pinned component/include ref still resolves, whether an upstream was archived or moved, whether a new advisory covers a component in use — live in the operational layer instead. Schedule the [\`PipelineAuditOp\`](/chant/guide/ops/#audit-supply-chain-drift) (temporal lexicon) for that live half; it reads the emitted \`include:\` / \`component:\` / \`image:\` references and reports drift via \`report | issue | merge-request\`.
521
+
438
522
  ### WGL029 — Unpinned include:project / component
439
523
 
440
524
  **Severity:** warning
@@ -705,6 +789,29 @@ The \`DockerBuild\` composite expands to a job with Docker-in-Docker service, re
705
789
 
706
790
  {{file:review-app/src/pipeline.ts}}
707
791
 
792
+ ## Merge-request plan widget
793
+
794
+ The \`MrPlanReport\` composite turns \`chant lifecycle plan\` into the GitLab merge-request plan widget — the "N to add, M to change, K to delete" summary GitLab renders from an \`artifacts:reports:terraform\` artifact:
795
+
796
+ \`\`\`typescript
797
+ import { MrPlanReport } from "@intentius/chant-lexicon-gitlab";
798
+
799
+ export const plan = MrPlanReport({
800
+ environment: "prod",
801
+ // credential setup — the plan queries the live system to classify drift
802
+ before: ["aws sts get-caller-identity"],
803
+ });
804
+ \`\`\`
805
+
806
+ The job runs \`chant lifecycle plan prod --report gitlab-mr\`, writes the count JSON to \`tfplan.json\`, and declares it as \`artifacts:reports:terraform\`. On the merge request, GitLab shows the plan summary inline.
807
+
808
+ Caveats worth knowing:
809
+
810
+ - The widget label always reads **"Terraform"** — that is GitLab's fixed string for this report type, not a claim chant makes.
811
+ - It is **counts only** (create/update/delete). \`adopt\` and \`noop\` are excluded, since the widget has no column for live-but-undeclared or no-change. There is no per-resource breakdown — run \`chant lifecycle plan\` in the job log for that.
812
+ - It is a **GitLab-only** surface. The same plan JSON is portable, but the widget is GitLab's.
813
+ - The plan reads the live system, so the job needs cloud credentials — wire them via \`before\` or CI variables.
814
+
708
815
  ## AWS ALB Deployment
709
816
 
710
817
  A cross-lexicon example showing how to deploy AWS CloudFormation stacks from GitLab CI. Three separate pipelines mirror the separate-project AWS ALB pattern:
@@ -779,7 +886,7 @@ The \`chant-gitlab\` skill covers the full deployment lifecycle:
779
886
  - **Status** — GitLab UI or pipelines API
780
887
  - **Retry** — retry failed jobs via UI or API
781
888
  - **Cancel** — cancel running pipelines via API
782
- - **Troubleshooting** — job logs, lint rule codes (WGL001–WGL004), post-synth checks (WGL010–WGL015)
889
+ - **Troubleshooting** — job logs, lint rule codes (WGL001–WGL004), post-synth checks (WGL010–WGL048)
783
890
 
784
891
  The skill is invocable as a slash command: \`/chant-gitlab\`
785
892
 
@@ -802,6 +909,18 @@ The lexicon also provides MCP (Model Context Protocol) tools and resources that
802
909
  | \`search\` | Search available resource types |
803
910
  | \`gitlab:diff\` | Compare current build output against previous |
804
911
  | \`gitlab:migrate\` | Translate a GitHub Actions workflow into GitLab CI/CD (see [Migration](../migration)) |
912
+ | \`gitlab:checks\` | Build and return the pipeline's security/correctness findings (the WGL checks) |
913
+ | \`gitlab:pipeline\` | Build and return the pipeline's stages and jobs (name, stage, run order), as written |
914
+ | \`gitlab:references\` | Build and list what the pipeline pulls in (includes, components, images) and whether each is pinned |
915
+ | \`gitlab:affected\` | Given a job, list the jobs that would re-run because they depend on it |
916
+ | \`gitlab:pipeline-yaml\` | Build and return the generated \`.gitlab-ci.yml\` |
917
+ | \`gitlab:source\` | Given a job, where it came from in the TypeScript — the declaring file and the composite that expanded it, if any (entity-level, not a YAML-line source map) |
918
+ | \`gitlab:owns\` | Given a job, whether it is declared (owned) by chant in this project. Pipeline jobs are not taggable cloud resources, so ownership here means "declared here" — live ownership markers apply to cloud lexicons |
919
+ | \`gitlab:compare\` | Given a GitHub Actions workflow file, migrate it to GitLab CI and report which security properties survive (translated/approximated/needs-review/lost) — the migration safety view |
920
+
921
+ The \`gitlab:checks\` / \`gitlab:pipeline\` / \`gitlab:references\` / \`gitlab:affected\` / \`gitlab:pipeline-yaml\` / \`gitlab:source\` / \`gitlab:owns\` / \`gitlab:compare\` tools are **read-only**: they build (or migrate) from source and never touch the live GitLab instance. They give an agent a *before-it-runs* view of the pipeline — what it does, what it pulls in, whether it is safe, where it came from, and what survives a migration — to complement the *after-it-ran* view it gets from the instance.
922
+
923
+ > **Why no \`github:compare\`?** The GitHub → GitLab migration lives in the GitLab lexicon (the GitHub lexicon does not depend on GitLab). A \`github:compare\` would invert that dependency, so the migration safety view is exposed once, here, as \`gitlab:compare\`.
805
924
 
806
925
  | MCP resource | Description |
807
926
  |--------------|-------------|
@@ -4,6 +4,7 @@ import { DockerBuild } from "./docker-build";
4
4
  import { NodePipeline, BunPipeline, PnpmPipeline } from "./node-pipeline";
5
5
  import { PythonPipeline } from "./python-pipeline";
6
6
  import { ReviewApp } from "./review-app";
7
+ import { MrPlanReport } from "./mr-plan-report";
7
8
 
8
9
  // ---------------------------------------------------------------------------
9
10
  // DockerBuild
@@ -703,3 +704,60 @@ describe("ReviewApp", () => {
703
704
  expect(props.allow_failure).toBe(true);
704
705
  });
705
706
  });
707
+
708
+ // ---------------------------------------------------------------------------
709
+ // MrPlanReport (#329)
710
+ // ---------------------------------------------------------------------------
711
+ describe("MrPlanReport", () => {
712
+ test("returns a single plan member", () => {
713
+ const instance = MrPlanReport({ environment: "prod" });
714
+ expect(instance.plan).toBeDefined();
715
+ expect(Object.keys(instance.members)).toEqual(["plan"]);
716
+ expect(isCompositeInstance(instance)).toBe(true);
717
+ });
718
+
719
+ test("declares the terraform report artifact", () => {
720
+ const props = (MrPlanReport({ environment: "prod" }).plan as any).props;
721
+ const artifacts = (props.artifacts as any).props;
722
+ expect(artifacts.reports.terraform).toBe("tfplan.json");
723
+ });
724
+
725
+ test("runs the plan with --report gitlab-mr and redirects to the report file", () => {
726
+ const props = (MrPlanReport({ environment: "prod" }).plan as any).props;
727
+ expect(props.script).toContain("npx chant build");
728
+ expect(props.script.some((s: string) => s.includes("lifecycle plan prod --report gitlab-mr > tfplan.json"))).toBe(true);
729
+ });
730
+
731
+ test("default stage is plan", () => {
732
+ const props = (MrPlanReport({ environment: "prod" }).plan as any).props;
733
+ expect(props.stage).toBe("plan");
734
+ });
735
+
736
+ test("lexicon, owned, and custom report file flow into the command", () => {
737
+ const props = (MrPlanReport({
738
+ environment: "staging",
739
+ lexicon: "gitlab",
740
+ ownedOnly: true,
741
+ reportFile: "plan.json",
742
+ }).plan as any).props;
743
+ const cmd = props.script.find((s: string) => s.includes("lifecycle plan"));
744
+ expect(cmd).toContain("lifecycle plan staging gitlab --owned --report gitlab-mr > plan.json");
745
+ expect((props.artifacts as any).props.reports.terraform).toBe("plan.json");
746
+ });
747
+
748
+ test("before commands become before_script (credential setup)", () => {
749
+ const props = (MrPlanReport({
750
+ environment: "prod",
751
+ before: ["gcloud auth ..."],
752
+ }).plan as any).props;
753
+ expect(props.before_script).toEqual(["gcloud auth ..."]);
754
+ });
755
+
756
+ test("per-member defaults override the plan job", () => {
757
+ const props = (MrPlanReport({
758
+ environment: "prod",
759
+ defaults: { plan: { tags: ["plan-runner"] } },
760
+ }).plan as any).props;
761
+ expect(props.tags).toEqual(["plan-runner"]);
762
+ });
763
+ });
@@ -6,3 +6,5 @@ export { PythonPipeline } from "./python-pipeline";
6
6
  export type { PythonPipelineProps } from "./python-pipeline";
7
7
  export { ReviewApp } from "./review-app";
8
8
  export type { ReviewAppProps } from "./review-app";
9
+ export { MrPlanReport } from "./mr-plan-report";
10
+ export type { MrPlanReportProps } from "./mr-plan-report";
@@ -0,0 +1,82 @@
1
+ import { Composite, mergeDefaults } from "@intentius/chant";
2
+ import { Job, Image, Artifacts } from "../generated";
3
+
4
+ export interface MrPlanReportProps {
5
+ /**
6
+ * Lifecycle environment to plan against — the `<environment>` argument of
7
+ * `chant lifecycle plan`.
8
+ */
9
+ environment: string;
10
+ /** Restrict the plan to one lexicon (the optional `[lexicon]` argument). */
11
+ lexicon?: string;
12
+ /** Stage for the plan job. Default: `"plan"` */
13
+ stage?: string;
14
+ /** Image to run chant in. Default: `"node:22-alpine"` */
15
+ image?: string;
16
+ /** Plan against chant-owned resources only (passes `--owned`). */
17
+ ownedOnly?: boolean;
18
+ /** Artifact filename the report is written to. Default: `"tfplan.json"` */
19
+ reportFile?: string;
20
+ /**
21
+ * Commands to run before the plan — typically cloud-credential setup, since
22
+ * the plan queries the live system to classify create/update/delete.
23
+ */
24
+ before?: string[];
25
+ /** Per-member defaults for customizing the generated job. */
26
+ defaults?: {
27
+ plan?: Partial<ConstructorParameters<typeof Job>[0]>;
28
+ };
29
+ }
30
+
31
+ /**
32
+ * A CI job that publishes `chant lifecycle plan` as the GitLab merge-request
33
+ * plan widget.
34
+ *
35
+ * The job runs the plan with `--report gitlab-mr`, writes the count JSON to a
36
+ * file, and declares it as `artifacts:reports:terraform`. GitLab then renders
37
+ * "N to add, M to change, K to delete" on the MR — the same widget Terraform
38
+ * uses. The label reads "Terraform" regardless of producer; that is GitLab's
39
+ * fixed string. Counts are create/update/delete only — `adopt` and `noop` do
40
+ * not appear.
41
+ *
42
+ * The plan reads the live system to classify drift, so wire cloud credentials
43
+ * via `before` or CI variables.
44
+ */
45
+ export const MrPlanReport = Composite<MrPlanReportProps>((props) => {
46
+ const {
47
+ environment,
48
+ lexicon,
49
+ stage = "plan",
50
+ image = "node:22-alpine",
51
+ ownedOnly = false,
52
+ reportFile = "tfplan.json",
53
+ before,
54
+ defaults: defs,
55
+ } = props;
56
+
57
+ const planArgs = [
58
+ "lifecycle",
59
+ "plan",
60
+ environment,
61
+ ...(lexicon ? [lexicon] : []),
62
+ ...(ownedOnly ? ["--owned"] : []),
63
+ "--report",
64
+ "gitlab-mr",
65
+ ].join(" ");
66
+
67
+ const plan = new Job(mergeDefaults({
68
+ stage,
69
+ image: new Image({ name: image }),
70
+ ...(before ? { before_script: before } : {}),
71
+ script: [
72
+ "npx chant build",
73
+ `npx chant ${planArgs} > ${reportFile}`,
74
+ ],
75
+ artifacts: new Artifacts({
76
+ reports: { terraform: reportFile },
77
+ when: "always",
78
+ }),
79
+ }, defs?.plan));
80
+
81
+ return { plan };
82
+ }, "MrPlanReport");
package/src/index.ts CHANGED
@@ -15,8 +15,8 @@ export { CI } from "./variables";
15
15
  export * from "./generated/index";
16
16
 
17
17
  // Composites
18
- export { DockerBuild, NodePipeline, BunPipeline, PnpmPipeline, PythonPipeline, ReviewApp } from "./composites/index";
19
- export type { DockerBuildProps, NodePipelineProps, PythonPipelineProps, ReviewAppProps } from "./composites/index";
18
+ export { DockerBuild, NodePipeline, BunPipeline, PnpmPipeline, PythonPipeline, ReviewApp, MrPlanReport } from "./composites/index";
19
+ export type { DockerBuildProps, NodePipelineProps, PythonPipelineProps, ReviewAppProps, MrPlanReportProps } from "./composites/index";
20
20
 
21
21
  // Spec utilities (for tooling)
22
22
  export { fetchCISchema, fetchSchemas, GITLAB_SCHEMA_VERSION } from "./codegen/fetch";
@@ -0,0 +1,178 @@
1
+ import { describe, test, expect, beforeEach, afterEach } from "vitest";
2
+ import { mkdir, rm, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { gitlabContextTools, downstreamJobs, componentPinned } from "./context-tools";
6
+
7
+ const tools = Object.fromEntries(gitlabContextTools().map((t) => [t.name, t]));
8
+
9
+ async function call(name: string, params: Record<string, unknown>): Promise<unknown> {
10
+ return tools[name].handler(params);
11
+ }
12
+
13
+ describe("downstreamJobs / componentPinned (#327)", () => {
14
+ test("downstreamJobs follows the needs chain transitively", () => {
15
+ const yaml = `build:
16
+ stage: build
17
+ script:
18
+ - echo build
19
+
20
+ test:
21
+ stage: test
22
+ needs:
23
+ - build
24
+ script:
25
+ - echo test
26
+
27
+ deploy:
28
+ stage: deploy
29
+ needs:
30
+ - test
31
+ script:
32
+ - echo deploy
33
+ `;
34
+ expect(downstreamJobs(yaml, "build").sort()).toEqual(["deploy", "test"]);
35
+ expect(downstreamJobs(yaml, "test")).toEqual(["deploy"]);
36
+ expect(downstreamJobs(yaml, "deploy")).toEqual([]);
37
+ });
38
+
39
+ test("componentPinned recognises a fixed version", () => {
40
+ expect(componentPinned("gitlab.com/g/comp@1.2.3")).toBe(true);
41
+ expect(componentPinned("gitlab.com/g/comp@main")).toBe(false);
42
+ });
43
+ });
44
+
45
+ describe("gitlab context tools — end to end (#327/#328)", () => {
46
+ let dir: string;
47
+ beforeEach(async () => {
48
+ dir = join(tmpdir(), `chant-mcp-${Date.now()}-${Math.random().toString(36).slice(2)}`);
49
+ await mkdir(dir, { recursive: true });
50
+ await writeFile(
51
+ join(dir, "pipeline.infra.ts"),
52
+ `const M = Symbol.for("chant.declarable");
53
+ export const buildApp = { [M]: true, lexicon: "gitlab", entityType: "GitLab::CI::Job", kind: "resource",
54
+ props: { stage: "build", image: { name: "node:20" }, script: ["echo build"] } };
55
+ export const testApp = { [M]: true, lexicon: "gitlab", entityType: "GitLab::CI::Job", kind: "resource",
56
+ props: { stage: "test", script: ["npm test"], needs: ["build-app"] } };
57
+ `,
58
+ );
59
+ });
60
+ afterEach(async () => {
61
+ await rm(dir, { recursive: true, force: true });
62
+ });
63
+
64
+ test("gitlab:pipeline returns stages and jobs", async () => {
65
+ const out = (await call("gitlab:pipeline", { path: dir })) as {
66
+ stages: string[];
67
+ jobs: Array<{ name: string; stage: string | null; runsAfter: string[] }>;
68
+ };
69
+ expect(out.stages).toEqual(expect.arrayContaining(["build", "test"]));
70
+ const names = out.jobs.map((j) => j.name);
71
+ expect(names).toEqual(expect.arrayContaining(["build-app", "test-app"]));
72
+ expect(out.jobs.find((j) => j.name === "test-app")?.runsAfter).toContain("build-app");
73
+ });
74
+
75
+ test("gitlab:references reports the unpinned image", async () => {
76
+ const out = (await call("gitlab:references", { path: dir })) as Array<{ kind: string; source: string; pinned: boolean | null }>;
77
+ const img = out.find((r) => r.kind === "image" && r.source === "node:20");
78
+ expect(img).toBeDefined();
79
+ expect(img?.pinned).toBe(false);
80
+ });
81
+
82
+ test("gitlab:checks returns the WGL findings (unpinned image trips WGL031)", async () => {
83
+ const out = (await call("gitlab:checks", { path: dir })) as { findings: Array<{ id: string; severity: string; job: string | null; message: string }> };
84
+ expect(Array.isArray(out.findings)).toBe(true);
85
+ expect(out.findings.some((f) => f.id === "WGL031")).toBe(true);
86
+ for (const f of out.findings) {
87
+ expect(typeof f.id).toBe("string");
88
+ expect(typeof f.message).toBe("string");
89
+ }
90
+ });
91
+
92
+ test("gitlab:affected lists downstream jobs", async () => {
93
+ const out = (await call("gitlab:affected", { path: dir, job: "build-app" })) as { job: string; wouldRerun: string[] };
94
+ expect(out.wouldRerun).toContain("test-app");
95
+ });
96
+
97
+ test("gitlab:pipeline-yaml returns the generated YAML for a path", async () => {
98
+ const out = (await call("gitlab:pipeline-yaml", { path: dir })) as { yaml: string };
99
+ expect(typeof out.yaml).toBe("string");
100
+ expect(out.yaml).toContain("stages:");
101
+ });
102
+
103
+ test("gitlab:source traces a job back to its declaring file", async () => {
104
+ const out = (await call("gitlab:source", { path: dir, job: "build-app" })) as {
105
+ job: string; found: boolean; entity: string; from: string | null; via: string | null;
106
+ };
107
+ expect(out.found).toBe(true);
108
+ expect(out.entity).toBe("buildApp");
109
+ expect(out.from).toMatch(/pipeline\.infra\.ts$/);
110
+ expect(out.via).toBeNull(); // plain declarable, not from a composite
111
+ });
112
+
113
+ test("gitlab:source reports not-found for an unknown job", async () => {
114
+ const out = (await call("gitlab:source", { path: dir, job: "nope" })) as { job: string; found: boolean };
115
+ expect(out.found).toBe(false);
116
+ });
117
+
118
+ test("gitlab:owns reports a declared job as owned, an unknown one as not", async () => {
119
+ const owned = (await call("gitlab:owns", { path: dir, job: "test-app" })) as { owned: boolean; basis: string };
120
+ expect(owned.owned).toBe(true);
121
+ expect(owned.basis).toBe("declared-in-source");
122
+ const foreign = (await call("gitlab:owns", { path: dir, job: "not-here" })) as { owned: boolean };
123
+ expect(foreign.owned).toBe(false);
124
+ });
125
+ });
126
+
127
+ describe("gitlab:compare — migration safety view (#327)", () => {
128
+ let dir: string;
129
+ let wf: string;
130
+ beforeEach(async () => {
131
+ dir = join(tmpdir(), `chant-mcp-cmp-${Date.now()}-${Math.random().toString(36).slice(2)}`);
132
+ await mkdir(dir, { recursive: true });
133
+ wf = join(dir, "ci.yml");
134
+ await writeFile(
135
+ wf,
136
+ `name: CI
137
+ on:
138
+ push:
139
+ branches: [main]
140
+ permissions:
141
+ contents: read
142
+ jobs:
143
+ build:
144
+ runs-on: ubuntu-latest
145
+ steps:
146
+ - uses: actions/checkout@${"a".repeat(40)}
147
+ - run: npm ci
148
+ `,
149
+ );
150
+ });
151
+ afterEach(async () => {
152
+ await rm(dir, { recursive: true, force: true });
153
+ });
154
+
155
+ test("reports the SHA pin as lost across the migration edge", async () => {
156
+ const out = (await call("gitlab:compare", { file: wf })) as {
157
+ found: boolean;
158
+ properties: Array<{ property: string; fate: string }>;
159
+ summary: Record<string, number>;
160
+ };
161
+ expect(out.found).toBe(true);
162
+ const pin = out.properties.find((p) => p.property === "Pinned action SHA");
163
+ expect(pin?.fate).toBe("lost");
164
+ expect(out.summary.lost).toBeGreaterThanOrEqual(1);
165
+ });
166
+
167
+ test("not-found for a missing file", async () => {
168
+ const out = (await call("gitlab:compare", { file: join(dir, "nope.yml") })) as { found: boolean };
169
+ expect(out.found).toBe(false);
170
+ });
171
+
172
+ test("not-found for a non-workflow file", async () => {
173
+ const notWf = join(dir, "plain.yml");
174
+ await writeFile(notWf, "hello: world\n");
175
+ const out = (await call("gitlab:compare", { file: notWf })) as { found: boolean };
176
+ expect(out.found).toBe(false);
177
+ });
178
+ });
@@ -0,0 +1,282 @@
1
+ /**
2
+ * Read-only MCP tools that expose what `chant build` already computes about a
3
+ * GitLab pipeline — its stages and jobs, what it pulls in from outside, its
4
+ * security findings — so an agent can ask about a change *before it runs or
5
+ * merges* (#327, #328).
6
+ *
7
+ * Every tool here builds from source and returns data. None of them touch the
8
+ * live GitLab instance, read run history, or write anything — that boundary is
9
+ * what keeps chant a context producer and not a copy of the live tooling.
10
+ */
11
+
12
+ import { build, type BuildResult } from "@intentius/chant/build";
13
+ import { runPostSynthChecks, getPrimaryOutput } from "@intentius/chant/lint/post-synth";
14
+ import { discoverPostSynthChecks } from "@intentius/chant/lint/discover";
15
+ import { getProvenance } from "@intentius/chant/provenance";
16
+ import type { McpToolContribution } from "@intentius/chant/mcp/types";
17
+ import { dirname, join, relative, isAbsolute, resolve } from "path";
18
+ import { readFile } from "fs/promises";
19
+ import { fileURLToPath } from "url";
20
+ import { gitlabSerializer } from "../serializer";
21
+ import { transform, detectGitHubWorkflow } from "../migrate/from-github";
22
+ import {
23
+ extractStages,
24
+ extractJobs,
25
+ extractIncludes,
26
+ extractImageRefs,
27
+ isPinnedRef,
28
+ } from "../lint/post-synth/yaml-helpers";
29
+
30
+ /** Build the project and return the emitted GitLab YAML plus the raw result. */
31
+ async function buildGitlab(path: string): Promise<{ yaml: string; result: BuildResult }> {
32
+ const result = await build(path, [gitlabSerializer]);
33
+ const out = result.outputs.get("gitlab");
34
+ return { yaml: out ? getPrimaryOutput(out) : "", result };
35
+ }
36
+
37
+ /** Discover the lexicon's post-synth checks without depending on the plugin. */
38
+ function gitlabPostSynthChecks() {
39
+ const dir = join(dirname(fileURLToPath(import.meta.url)), "..", "lint", "post-synth");
40
+ return discoverPostSynthChecks(dir, import.meta.url);
41
+ }
42
+
43
+ /** Is a component address (`host/group/comp@version`) pinned to a fixed version? */
44
+ export function componentPinned(value: string): boolean {
45
+ const at = value.lastIndexOf("@");
46
+ return at !== -1 && isPinnedRef(value.slice(at + 1));
47
+ }
48
+
49
+ /** The YAML job name a build entity serializes to (camelCase → kebab-case). */
50
+ export function jobNameOf(entityName: string): string {
51
+ return entityName.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
52
+ }
53
+
54
+ /** Find the build entity whose serialized job name matches `job`. */
55
+ function entityForJob(result: BuildResult, job: string): { name: string; entity: object } | undefined {
56
+ for (const [name, entity] of result.entities) {
57
+ if (name === job || jobNameOf(name) === job) return { name, entity };
58
+ }
59
+ return undefined;
60
+ }
61
+
62
+ /** Render a source-file path relative to the project root, when possible. */
63
+ function relSource(root: string, file: string | undefined): string | undefined {
64
+ if (!file) return undefined;
65
+ if (!isAbsolute(file)) return file;
66
+ const rel = relative(root, file);
67
+ return rel.startsWith("..") ? file : rel;
68
+ }
69
+
70
+ /** Jobs that would re-run because they depend (transitively) on `job`. */
71
+ export function downstreamJobs(yaml: string, job: string): string[] {
72
+ const jobs = extractJobs(yaml);
73
+ // edge: upstream -> downstream (J depends on each of its needs)
74
+ const downstreamOf = new Map<string, string[]>();
75
+ for (const [name, j] of jobs) {
76
+ for (const dep of j.needs ?? []) {
77
+ const arr = downstreamOf.get(dep) ?? [];
78
+ arr.push(name);
79
+ downstreamOf.set(dep, arr);
80
+ }
81
+ }
82
+ const out = new Set<string>();
83
+ const queue = [job];
84
+ while (queue.length) {
85
+ const cur = queue.shift()!;
86
+ for (const next of downstreamOf.get(cur) ?? []) {
87
+ if (!out.has(next)) {
88
+ out.add(next);
89
+ queue.push(next);
90
+ }
91
+ }
92
+ }
93
+ return [...out];
94
+ }
95
+
96
+ const PATH_INPUT = {
97
+ type: "object" as const,
98
+ properties: {
99
+ path: { type: "string", description: "Path to the chant project directory (default: current directory)" },
100
+ },
101
+ };
102
+
103
+ /** The read-only context tools for the GitLab lexicon. */
104
+ export function gitlabContextTools(): McpToolContribution[] {
105
+ return [
106
+ {
107
+ name: "gitlab:checks",
108
+ description:
109
+ "Build the pipeline and return its security and correctness findings (the WGL checks) as JSON. Read-only — does not touch the live GitLab instance.",
110
+ inputSchema: PATH_INPUT,
111
+ async handler(params: Record<string, unknown>): Promise<unknown> {
112
+ const { yaml, result } = await buildGitlab((params.path as string) ?? ".");
113
+ if (!yaml) return { findings: [], note: "no GitLab pipeline produced from this project" };
114
+ const scoped = { ...result, outputs: new Map([["gitlab", result.outputs.get("gitlab")!]]) };
115
+ const diags = runPostSynthChecks(gitlabPostSynthChecks(), scoped);
116
+ return {
117
+ findings: diags.map((d) => ({
118
+ id: d.checkId,
119
+ severity: d.severity,
120
+ job: d.entity ?? null,
121
+ message: d.message,
122
+ })),
123
+ };
124
+ },
125
+ },
126
+ {
127
+ name: "gitlab:pipeline",
128
+ description:
129
+ "Build the pipeline and return its stages and jobs as written (name, stage, and what each job runs after) — before anything runs. Read-only.",
130
+ inputSchema: PATH_INPUT,
131
+ async handler(params: Record<string, unknown>): Promise<unknown> {
132
+ const { yaml } = await buildGitlab((params.path as string) ?? ".");
133
+ const jobs = [...extractJobs(yaml).values()].map((j) => ({
134
+ name: j.name,
135
+ stage: j.stage ?? null,
136
+ runsAfter: j.needs ?? [],
137
+ }));
138
+ return { stages: extractStages(yaml), jobs };
139
+ },
140
+ },
141
+ {
142
+ name: "gitlab:references",
143
+ description:
144
+ "Build the pipeline and list everything it pulls in from outside (includes, components, images) and whether each is pinned to a fixed version. Read-only.",
145
+ inputSchema: PATH_INPUT,
146
+ async handler(params: Record<string, unknown>): Promise<unknown> {
147
+ const { yaml } = await buildGitlab((params.path as string) ?? ".");
148
+ const includes = extractIncludes(yaml).map((e) => ({
149
+ kind: e.kind,
150
+ source: e.value,
151
+ ref: e.ref ?? null,
152
+ pinned:
153
+ e.kind === "project" ? (e.ref ? isPinnedRef(e.ref) : false) : e.kind === "component" ? componentPinned(e.value) : null,
154
+ }));
155
+ const images = extractImageRefs(yaml).map((i) => ({
156
+ kind: "image" as const,
157
+ source: i.image,
158
+ ref: null,
159
+ pinned: i.image.includes("@sha256:"),
160
+ }));
161
+ return [...includes, ...images];
162
+ },
163
+ },
164
+ {
165
+ name: "gitlab:affected",
166
+ description:
167
+ "Build the pipeline and, given a job name, list the jobs that would re-run because they depend on it (the needs chain). Read-only.",
168
+ inputSchema: {
169
+ type: "object" as const,
170
+ properties: {
171
+ path: { type: "string", description: "Path to the chant project directory (default: current directory)" },
172
+ job: { type: "string", description: "Job name to trace downstream from" },
173
+ },
174
+ required: ["job"],
175
+ },
176
+ async handler(params: Record<string, unknown>): Promise<unknown> {
177
+ const { yaml } = await buildGitlab((params.path as string) ?? ".");
178
+ const job = params.job as string;
179
+ return { job, wouldRerun: downstreamJobs(yaml, job) };
180
+ },
181
+ },
182
+ {
183
+ name: "gitlab:pipeline-yaml",
184
+ description: "Build the project and return the generated .gitlab-ci.yml as a string. Read-only.",
185
+ inputSchema: PATH_INPUT,
186
+ async handler(params: Record<string, unknown>): Promise<unknown> {
187
+ const { yaml } = await buildGitlab((params.path as string) ?? ".");
188
+ return { yaml };
189
+ },
190
+ },
191
+ {
192
+ name: "gitlab:source",
193
+ description:
194
+ "Build the project and, given a job name, say where it came from in the TypeScript source — the file that declared it and the composite that expanded it, if any. Entity-level provenance (file + composite), not a YAML-line source map. Read-only.",
195
+ inputSchema: {
196
+ type: "object" as const,
197
+ properties: {
198
+ path: { type: "string", description: "Path to the chant project directory (default: current directory)" },
199
+ job: { type: "string", description: "Job name (as it appears in the generated YAML) to trace back to source" },
200
+ },
201
+ required: ["job"],
202
+ },
203
+ async handler(params: Record<string, unknown>): Promise<unknown> {
204
+ const root = (params.path as string) ?? ".";
205
+ const { result } = await buildGitlab(root);
206
+ const job = params.job as string;
207
+ const found = entityForJob(result, job);
208
+ if (!found) return { job, found: false, note: "no build entity serializes to that job name" };
209
+ const prov = getProvenance(found.entity);
210
+ return {
211
+ job,
212
+ found: true,
213
+ entity: found.name,
214
+ from: relSource(root, prov?.sourceFile) ?? null,
215
+ via: prov?.composite ?? null,
216
+ };
217
+ },
218
+ },
219
+ {
220
+ name: "gitlab:owns",
221
+ description:
222
+ "Build the project and report whether a job is declared (owned) by chant in this project's source. For pipeline config, ownership means \"declared here\" — GitLab CI jobs are not taggable cloud resources, so there is no live ownership marker as there is for cloud lexicons. Read-only.",
223
+ inputSchema: {
224
+ type: "object" as const,
225
+ properties: {
226
+ path: { type: "string", description: "Path to the chant project directory (default: current directory)" },
227
+ job: { type: "string", description: "Job name (as it appears in the generated YAML) to check" },
228
+ },
229
+ required: ["job"],
230
+ },
231
+ async handler(params: Record<string, unknown>): Promise<unknown> {
232
+ const root = (params.path as string) ?? ".";
233
+ const { result } = await buildGitlab(root);
234
+ const job = params.job as string;
235
+ const found = entityForJob(result, job);
236
+ return {
237
+ job,
238
+ owned: Boolean(found),
239
+ basis: "declared-in-source",
240
+ note: "GitLab CI jobs are not taggable cloud resources; ownership here means the job is declared by chant in this project. Live ownership markers apply to cloud lexicons (aws/azure/k8s).",
241
+ };
242
+ },
243
+ },
244
+ {
245
+ name: "gitlab:compare",
246
+ description:
247
+ "Given a GitHub Actions workflow file, migrate it to GitLab CI and report which security properties survive the move and which weaken or are lost (the migration safety view). Returns a per-property fate (translated/approximated/needs-review/lost). Read-only — builds and analyzes, never writes.",
248
+ inputSchema: {
249
+ type: "object" as const,
250
+ properties: {
251
+ file: { type: "string", description: "Path to a .github/workflows/*.yml workflow file to migrate and compare" },
252
+ },
253
+ required: ["file"],
254
+ },
255
+ async handler(params: Record<string, unknown>): Promise<unknown> {
256
+ const file = resolve((params.file as string) ?? "");
257
+ let content: string;
258
+ try {
259
+ content = await readFile(file, "utf8");
260
+ } catch {
261
+ return { file: params.file ?? null, found: false, note: "could not read the workflow file" };
262
+ }
263
+ if (!detectGitHubWorkflow(content)) {
264
+ return { file: params.file ?? null, found: false, note: "file does not look like a GitHub Actions workflow" };
265
+ }
266
+ const migration = await transform(content, { security: true, sourceFile: file });
267
+ const properties = migration.provenance
268
+ .filter((r) => r.security)
269
+ .map((r) => ({
270
+ property: r.security!.property,
271
+ fate: r.security!.fate,
272
+ severity: r.security!.severity,
273
+ reestablish: r.security!.reestablish ?? null,
274
+ note: r.note ?? null,
275
+ }));
276
+ const summary = { translated: 0, approximated: 0, "needs-review": 0, lost: 0 } as Record<string, number>;
277
+ for (const p of properties) summary[p.fate] = (summary[p.fate] ?? 0) + 1;
278
+ return { found: true, properties, summary };
279
+ },
280
+ },
281
+ ];
282
+ }
@@ -238,6 +238,15 @@ describe("gitlabPlugin", () => {
238
238
  const names = tools.map((t) => t.name);
239
239
  expect(names).toContain("gitlab:diff");
240
240
  expect(names).toContain("migrate");
241
+ // Read-only context tools (#327/#328)
242
+ expect(names).toContain("gitlab:checks");
243
+ expect(names).toContain("gitlab:pipeline");
244
+ expect(names).toContain("gitlab:references");
245
+ expect(names).toContain("gitlab:affected");
246
+ expect(names).toContain("gitlab:pipeline-yaml");
247
+ expect(names).toContain("gitlab:source");
248
+ expect(names).toContain("gitlab:owns");
249
+ expect(names).toContain("gitlab:compare");
241
250
  for (const t of tools) {
242
251
  expect(typeof t.handler).toBe("function");
243
252
  }
package/src/plugin.ts CHANGED
@@ -12,6 +12,7 @@ import { createSkillsLoader, createDiffTool, createCatalogResource } from "@inte
12
12
  import { join, dirname } from "path";
13
13
  import { fileURLToPath } from "url";
14
14
  import { gitlabSerializer } from "./serializer";
15
+ import { gitlabContextTools } from "./mcp/context-tools";
15
16
  import { deprecatedOnlyExceptRule } from "./lint/rules/deprecated-only-except";
16
17
  import { missingScriptRule } from "./lint/rules/missing-script";
17
18
  import { missingStageRule } from "./lint/rules/missing-stage";
@@ -254,6 +255,9 @@ export const test = new Job({
254
255
  mcpTools() {
255
256
  return [
256
257
  createDiffTool(gitlabSerializer, "Compare current build output against previous output for GitLab CI", "gitlab"),
258
+ // Read-only context tools: what the pipeline does, what it pulls in, and
259
+ // its security findings — before anything runs (#327/#328).
260
+ ...gitlabContextTools(),
257
261
  {
258
262
  name: "migrate",
259
263
  description: "Translate a GitHub Actions workflow YAML into a GitLab CI/CD pipeline. Returns the rendered output plus diagnostic + provenance arrays.",
@@ -197,6 +197,21 @@ export const app = NodePipeline({
197
197
  });
198
198
  ```
199
199
 
200
+ ### Publish the plan to the merge request
201
+
202
+ `MrPlanReport` turns `chant lifecycle plan` into the MR plan widget (the `terraform` artifact type above). It runs the plan with `--report gitlab-mr`, writes the `{create,update,delete}` counts to `tfplan.json`, and declares it as `artifacts:reports:terraform`:
203
+
204
+ ```typescript
205
+ import { MrPlanReport } from "@intentius/chant-lexicon-gitlab";
206
+
207
+ export const plan = MrPlanReport({
208
+ environment: "prod",
209
+ before: ["aws sts get-caller-identity"], // credential setup — the plan reads live state
210
+ });
211
+ ```
212
+
213
+ The widget label always reads "Terraform" (GitLab's fixed string). Counts only — `adopt`/`noop` are excluded; run the plan in the job log for the per-resource breakdown.
214
+
200
215
  ## Rules and Conditional Execution
201
216
 
202
217
  ### Branch-Based Rules