@intentius/chant-lexicon-gitlab 0.1.23 → 0.3.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 (86) hide show
  1. package/dist/integrity.json +25 -5
  2. package/dist/manifest.json +1 -1
  3. package/dist/rules/wgl029.ts +59 -0
  4. package/dist/rules/wgl030.ts +48 -0
  5. package/dist/rules/wgl031.ts +37 -0
  6. package/dist/rules/wgl032.ts +68 -0
  7. package/dist/rules/wgl033.ts +38 -0
  8. package/dist/rules/wgl034.ts +42 -0
  9. package/dist/rules/wgl035.ts +44 -0
  10. package/dist/rules/wgl036.ts +44 -0
  11. package/dist/rules/wgl037.ts +47 -0
  12. package/dist/rules/wgl038.ts +48 -0
  13. package/dist/rules/wgl039.ts +41 -0
  14. package/dist/rules/wgl040.ts +42 -0
  15. package/dist/rules/wgl041.ts +57 -0
  16. package/dist/rules/wgl042.ts +46 -0
  17. package/dist/rules/wgl043.ts +45 -0
  18. package/dist/rules/wgl044.ts +41 -0
  19. package/dist/rules/wgl045.ts +57 -0
  20. package/dist/rules/wgl046.ts +43 -0
  21. package/dist/rules/wgl047.ts +41 -0
  22. package/dist/rules/wgl048.ts +37 -0
  23. package/dist/rules/yaml-helpers.ts +214 -0
  24. package/dist/skills/chant-gitlab-migrate.md +10 -0
  25. package/dist/skills/chant-gitlab-patterns.md +15 -0
  26. package/package.json +1 -1
  27. package/src/codegen/docs.ts +251 -2
  28. package/src/composites/composites.test.ts +58 -0
  29. package/src/composites/index.ts +2 -0
  30. package/src/composites/mr-plan-report.ts +82 -0
  31. package/src/index.ts +2 -2
  32. package/src/lint/post-synth/wgl029.test.ts +77 -0
  33. package/src/lint/post-synth/wgl029.ts +59 -0
  34. package/src/lint/post-synth/wgl030.test.ts +57 -0
  35. package/src/lint/post-synth/wgl030.ts +48 -0
  36. package/src/lint/post-synth/wgl031.test.ts +66 -0
  37. package/src/lint/post-synth/wgl031.ts +37 -0
  38. package/src/lint/post-synth/wgl032.test.ts +53 -0
  39. package/src/lint/post-synth/wgl032.ts +68 -0
  40. package/src/lint/post-synth/wgl033.test.ts +58 -0
  41. package/src/lint/post-synth/wgl033.ts +38 -0
  42. package/src/lint/post-synth/wgl034.test.ts +60 -0
  43. package/src/lint/post-synth/wgl034.ts +42 -0
  44. package/src/lint/post-synth/wgl035.test.ts +42 -0
  45. package/src/lint/post-synth/wgl035.ts +44 -0
  46. package/src/lint/post-synth/wgl036.test.ts +49 -0
  47. package/src/lint/post-synth/wgl036.ts +44 -0
  48. package/src/lint/post-synth/wgl037.test.ts +45 -0
  49. package/src/lint/post-synth/wgl037.ts +47 -0
  50. package/src/lint/post-synth/wgl038.test.ts +54 -0
  51. package/src/lint/post-synth/wgl038.ts +48 -0
  52. package/src/lint/post-synth/wgl039.test.ts +39 -0
  53. package/src/lint/post-synth/wgl039.ts +41 -0
  54. package/src/lint/post-synth/wgl040.test.ts +48 -0
  55. package/src/lint/post-synth/wgl040.ts +42 -0
  56. package/src/lint/post-synth/wgl041.test.ts +49 -0
  57. package/src/lint/post-synth/wgl041.ts +57 -0
  58. package/src/lint/post-synth/wgl042.test.ts +45 -0
  59. package/src/lint/post-synth/wgl042.ts +46 -0
  60. package/src/lint/post-synth/wgl043.test.ts +43 -0
  61. package/src/lint/post-synth/wgl043.ts +45 -0
  62. package/src/lint/post-synth/wgl044.test.ts +46 -0
  63. package/src/lint/post-synth/wgl044.ts +41 -0
  64. package/src/lint/post-synth/wgl045.test.ts +48 -0
  65. package/src/lint/post-synth/wgl045.ts +57 -0
  66. package/src/lint/post-synth/wgl046.test.ts +67 -0
  67. package/src/lint/post-synth/wgl046.ts +43 -0
  68. package/src/lint/post-synth/wgl047.test.ts +40 -0
  69. package/src/lint/post-synth/wgl047.ts +41 -0
  70. package/src/lint/post-synth/wgl048.test.ts +56 -0
  71. package/src/lint/post-synth/wgl048.ts +37 -0
  72. package/src/lint/post-synth/yaml-helpers.ts +214 -0
  73. package/src/lint/rules/data/known-include-sources.ts +41 -0
  74. package/src/lint/rules/data/untrusted-variables.ts +31 -0
  75. package/src/mcp/context-tools.test.ts +178 -0
  76. package/src/mcp/context-tools.ts +282 -0
  77. package/src/migrate/from-github/diagnostics.ts +18 -0
  78. package/src/migrate/from-github/index.ts +20 -1
  79. package/src/migrate/from-github/provenance.ts +28 -0
  80. package/src/migrate/from-github/rules.ts +7 -0
  81. package/src/migrate/from-github/security.test.ts +98 -0
  82. package/src/migrate/from-github/security.ts +208 -0
  83. package/src/plugin.test.ts +30 -1
  84. package/src/plugin.ts +4 -0
  85. package/src/skills/chant-gitlab-migrate.md +10 -0
  86. package/src/skills/chant-gitlab-patterns.md +15 -0
@@ -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,220 @@ 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
+
522
+ ### WGL029 — Unpinned include:project / component
523
+
524
+ **Severity:** warning
525
+
526
+ Flags an \`include:project\` or CI/CD \`component:\` resolved by a moving ref — a branch, a missing \`ref:\` (defaults to the default branch), or a floating component version — instead of a pinned tag or commit SHA.
527
+
528
+ ### WGL030 — Mutable or insecure include:remote
529
+
530
+ **Severity:** error (HTTP) / warning (HTTPS)
531
+
532
+ Flags \`include:remote\` URLs fetched over HTTP (no transport integrity) or over HTTPS but inherently mutable. Prefer a pinned \`include:project\` or component. Generalizes WGL017 to includes.
533
+
534
+ ### WGL031 — Container image without a digest
535
+
536
+ **Severity:** warning
537
+
538
+ Flags \`image:\` and \`services:\` references not pinned to an immutable \`@sha256:\` digest. Variable-based references (e.g. \`$CI_REGISTRY_IMAGE:tag\`) are skipped.
539
+
540
+ ### WGL032 — Look-alike include/component source
541
+
542
+ **Severity:** warning
543
+
544
+ Flags an \`include:project\` / \`component:\` source that is a near-miss (edit distance 1–2) of a well-known GitLab CI source but not an exact match — a likely typo or impersonation. Backed by a vendored reference list.
545
+
546
+ ### WGL033 — OIDC id_token without a scoped audience
547
+
548
+ **Severity:** warning
549
+
550
+ Flags an \`id_tokens:\` declaration with no \`aud:\` or a wildcard audience. The audience binds the minted OIDC token to a relying party; without it a leaked token is accepted anywhere.
551
+
552
+ ### WGL034 — OIDC id_token mintable from a merge-request pipeline
553
+
554
+ **Severity:** warning
555
+
556
+ Flags a job that declares \`id_tokens:\` and is reachable from merge-request pipelines, which outside contributors can trigger. Restrict OIDC jobs to protected refs or require approval.
557
+
558
+ > The project-level \`CI_JOB_TOKEN\` allowlist and a variable's protected status are project settings, not emitted pipeline YAML, so they are out of scope for these post-synth checks (see issue #298's caveat).
559
+
560
+ ### WGL035 — Untrusted CI variable in a script
561
+
562
+ **Severity:** warning
563
+
564
+ Flags an attacker-controllable predefined variable (branch/tag name, commit or MR title/description, author) referenced in a \`script:\` command, where a crafted value can inject shell commands. Quote it and avoid using it in sensitive commands.
565
+
566
+ ### WGL036 — Privileged DinD reachable from merge requests
567
+
568
+ **Severity:** warning
569
+
570
+ Flags a Docker-in-Docker (privileged) job reachable from merge-request pipelines, which outside contributors can trigger. Restrict privileged services to protected refs. Complements WGL026.
571
+
572
+ ### WGL037 — Regex gate on an untrusted ref
573
+
574
+ **Severity:** warning
575
+
576
+ Flags a \`rules:if\` that gates on a regex match (\`=~\`) over an attacker-controllable ref variable — a crafted branch/tag name can satisfy the pattern. Match the full ref with \`==\` or gate on a protected condition.
577
+
578
+ ### WGL038 — Secret reachable from a merge-request pipeline
579
+
580
+ **Severity:** warning
581
+
582
+ Flags a user-defined secret-like variable read by a job reachable from merge-request pipelines, which can run untrusted code. Gate the job to protected refs or mark the variable protected. (Built-in \`CI_*\` variables are excluded.)
583
+
584
+ ### WGL039 — Secret echoed to job logs
585
+
586
+ **Severity:** warning
587
+
588
+ Flags a \`script:\` command that prints a secret-like variable (\`echo\`/\`printf\`/\`cat\`). Logs are broadly readable and masking can be defeated by transforms.
589
+
590
+ ### WGL040 — Hardcoded registry credential
591
+
592
+ **Severity:** error
593
+
594
+ Flags a \`docker login\` (or compatible) passing a literal password via \`-p\`/\`--password\` instead of a variable or \`--password-stdin\`. Extends WGL016 to scripts.
595
+
596
+ > Variable *masking* and *protected* status are GitLab project/CI-settings, not emitted YAML — WGL016 already nudges toward masked variables; this group covers the exposure paths visible in the pipeline.
597
+
598
+ ### WGL041 — Unsound rules:if condition
599
+
600
+ **Severity:** warning
601
+
602
+ Flags a tautological \`rules:if\` where both sides of \`==\`/\`!=\` are identical, so the condition is always true or always false. Generalizes WGL011 to conditions presented as gates.
603
+
604
+ ### WGL042 — Unreachable rules after an unconditional match
605
+
606
+ **Severity:** warning
607
+
608
+ Flags rules listed after an unconditional rule (no \`if:\`, not \`when: never\`). GitLab takes the first matching rule, so a catch-all makes everything after it dead. Put specific rules first.
609
+
610
+ ### WGL043 — Match-anything regex gate
611
+
612
+ **Severity:** warning
613
+
614
+ Flags a \`rules:if\` whose \`=~\` regex matches every value (empty, dot-star, anchored dot-star) — a filter that admits everything. Tighten the pattern or remove the gate.
615
+
616
+ > The auto-fix acceptance item is N/A at the post-synth layer (PostSynthDiagnostic has no fix channel); it belongs to the declarative source-lint rules.
617
+
618
+ ### WGL044 — Public artifacts
619
+
620
+ **Severity:** warning
621
+
622
+ Flags \`artifacts:public: true\`, which makes build output downloadable by anyone. Keep artifacts private unless they are meant to be world-readable.
623
+
624
+ ### WGL045 — Credential-bearing artifact path
625
+
626
+ **Severity:** error
627
+
628
+ Flags an \`artifacts:paths:\` entry that looks like a credential or sensitive file (\`.env\`, \`*.pem\`, \`id_rsa\`, \`*.key\`, \`.npmrc\`, \`.netrc\`, \`credentials\`). Artifacts flow downstream and are downloadable — exclude the file.
629
+
630
+ ### WGL046 — Cache poisoning from a merge-request pipeline
631
+
632
+ **Severity:** warning
633
+
634
+ Flags a job that writes a cache (push policy) and is reachable from merge-request pipelines. An MR can poison the cache for a later protected run that restores the same key. Restrict cache writes to protected refs or scope the key.
635
+
636
+ > Artifact / \`dependencies:\` flow across a *protected* boundary depends on a ref's protected status, a project setting not present in emitted YAML — out of scope here.
637
+
638
+ ### WGL047 — Software fetched and piped to a shell
639
+
640
+ **Severity:** warning
641
+
642
+ Flags a \`script:\` command that pipes a network download straight into a shell (\`curl ... | bash\`). The fetched code is unpinned and unverified — download to a file, verify a checksum/signature, then run it.
643
+
644
+ ### WGL048 — Pipeline without a name
645
+
646
+ **Severity:** info
647
+
648
+ Flags a pipeline that defines a \`workflow:\` block but no \`workflow:name\`. A pipeline name aids identification in the GitLab UI and audit output.
649
+
650
+ > Two #304 items stay out of scope: a broad privileged-service / DinD check (already covered by WGL026 for DinD-TLS and WGL036 for MR-reachable DinD; a runner's privileged isolation is not in emitted YAML) and an optional include/component allowlist policy (configuration, overlapping WGL032's vendored source list).
651
+
438
652
  ## Running lint
439
653
 
440
654
  \`\`\`bash
@@ -575,6 +789,29 @@ The \`DockerBuild\` composite expands to a job with Docker-in-Docker service, re
575
789
 
576
790
  {{file:review-app/src/pipeline.ts}}
577
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
+
578
815
  ## AWS ALB Deployment
579
816
 
580
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:
@@ -649,7 +886,7 @@ The \`chant-gitlab\` skill covers the full deployment lifecycle:
649
886
  - **Status** — GitLab UI or pipelines API
650
887
  - **Retry** — retry failed jobs via UI or API
651
888
  - **Cancel** — cancel running pipelines via API
652
- - **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)
653
890
 
654
891
  The skill is invocable as a slash command: \`/chant-gitlab\`
655
892
 
@@ -672,6 +909,18 @@ The lexicon also provides MCP (Model Context Protocol) tools and resources that
672
909
  | \`search\` | Search available resource types |
673
910
  | \`gitlab:diff\` | Compare current build output against previous |
674
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\`.
675
924
 
676
925
  | MCP resource | Description |
677
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,77 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
3
+ import { wgl029 } from "./wgl029";
4
+
5
+ function makeCtx(yaml: string): PostSynthContext {
6
+ return {
7
+ outputs: new Map([["gitlab", yaml]]),
8
+ entities: new Map(),
9
+ buildResult: {
10
+ outputs: new Map([["gitlab", yaml]]),
11
+ entities: new Map(),
12
+ warnings: [],
13
+ errors: [],
14
+ sourceFileCount: 1,
15
+ },
16
+ };
17
+ }
18
+
19
+ describe("WGL029: unpinned include:project / component", () => {
20
+ test("flags include:project on a branch ref", () => {
21
+ const yaml = `include:
22
+ - project: my-group/ci-templates
23
+ ref: main
24
+ file: /templates/build.yml
25
+
26
+ stages:
27
+ - build
28
+ `;
29
+ const diags = wgl029.check(makeCtx(yaml));
30
+ expect(diags).toHaveLength(1);
31
+ expect(diags[0].checkId).toBe("WGL029");
32
+ expect(diags[0].message).toContain("main");
33
+ });
34
+
35
+ test("flags include:project with no ref", () => {
36
+ const yaml = `include:
37
+ - project: my-group/ci-templates
38
+ file: /templates/build.yml
39
+
40
+ stages:
41
+ - build
42
+ `;
43
+ const diags = wgl029.check(makeCtx(yaml));
44
+ expect(diags).toHaveLength(1);
45
+ expect(diags[0].message).toContain("default branch");
46
+ });
47
+
48
+ test("does not flag a pinned tag ref", () => {
49
+ const yaml = `include:
50
+ - project: my-group/ci-templates
51
+ ref: v1.2.3
52
+ file: /templates/build.yml
53
+
54
+ stages:
55
+ - build
56
+ `;
57
+ const diags = wgl029.check(makeCtx(yaml));
58
+ expect(diags).toHaveLength(0);
59
+ });
60
+
61
+ test("flags a floating component version and not a pinned one", () => {
62
+ const floating = `include:
63
+ - component: gitlab.example.com/my-group/my-comp@main
64
+
65
+ stages:
66
+ - build
67
+ `;
68
+ const pinned = `include:
69
+ - component: gitlab.example.com/my-group/my-comp@1.0.0
70
+
71
+ stages:
72
+ - build
73
+ `;
74
+ expect(wgl029.check(makeCtx(floating))).toHaveLength(1);
75
+ expect(wgl029.check(makeCtx(pinned))).toHaveLength(0);
76
+ });
77
+ });
@@ -0,0 +1,59 @@
1
+ /**
2
+ * WGL029: Unpinned include:project / component Reference
3
+ *
4
+ * Flags an `include:project` or CI/CD `component:` resolved by a moving ref — a
5
+ * branch, no `ref:` at all (defaults to the project's default branch), or a
6
+ * floating component version — instead of a pinned tag or commit SHA. A
7
+ * repointed ref turns the included configuration into a supply-chain entry point.
8
+ */
9
+
10
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
11
+ import { getPrimaryOutput, extractIncludes, isPinnedRef } from "./yaml-helpers";
12
+
13
+ export const wgl029: PostSynthCheck = {
14
+ id: "WGL029",
15
+ description: "include:project / component resolved by a moving ref instead of a pinned tag or SHA",
16
+
17
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
18
+ const diagnostics: PostSynthDiagnostic[] = [];
19
+
20
+ for (const [, output] of ctx.outputs) {
21
+ const yaml = getPrimaryOutput(output);
22
+ for (const entry of extractIncludes(yaml)) {
23
+ if (entry.kind === "project") {
24
+ if (!entry.ref) {
25
+ diagnostics.push({
26
+ checkId: "WGL029",
27
+ severity: "warning",
28
+ message: `include:project "${entry.value}" has no ref: and resolves to the project's default branch (a moving target). Pin it to a tag or commit SHA.`,
29
+ entity: entry.value,
30
+ lexicon: "gitlab",
31
+ });
32
+ } else if (!isPinnedRef(entry.ref)) {
33
+ diagnostics.push({
34
+ checkId: "WGL029",
35
+ severity: "warning",
36
+ message: `include:project "${entry.value}" is pinned to the moving ref "${entry.ref}". Pin it to a tag or commit SHA.`,
37
+ entity: entry.value,
38
+ lexicon: "gitlab",
39
+ });
40
+ }
41
+ } else if (entry.kind === "component") {
42
+ const at = entry.value.lastIndexOf("@");
43
+ const version = at === -1 ? "" : entry.value.slice(at + 1);
44
+ if (!version || !isPinnedRef(version)) {
45
+ diagnostics.push({
46
+ checkId: "WGL029",
47
+ severity: "warning",
48
+ message: `component "${entry.value}" uses ${version ? `the moving version "${version}"` : "no pinned version"}. Pin it to a released version tag or commit SHA.`,
49
+ entity: entry.value,
50
+ lexicon: "gitlab",
51
+ });
52
+ }
53
+ }
54
+ }
55
+ }
56
+
57
+ return diagnostics;
58
+ },
59
+ };
@@ -0,0 +1,57 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
3
+ import { wgl030 } from "./wgl030";
4
+
5
+ function makeCtx(yaml: string): PostSynthContext {
6
+ return {
7
+ outputs: new Map([["gitlab", yaml]]),
8
+ entities: new Map(),
9
+ buildResult: {
10
+ outputs: new Map([["gitlab", yaml]]),
11
+ entities: new Map(),
12
+ warnings: [],
13
+ errors: [],
14
+ sourceFileCount: 1,
15
+ },
16
+ };
17
+ }
18
+
19
+ describe("WGL030: mutable or insecure include:remote", () => {
20
+ test("errors on an HTTP remote include", () => {
21
+ const yaml = `include:
22
+ - remote: http://example.com/ci.yml
23
+
24
+ stages:
25
+ - build
26
+ `;
27
+ const diags = wgl030.check(makeCtx(yaml));
28
+ expect(diags).toHaveLength(1);
29
+ expect(diags[0].checkId).toBe("WGL030");
30
+ expect(diags[0].severity).toBe("error");
31
+ });
32
+
33
+ test("warns on an HTTPS remote include", () => {
34
+ const yaml = `include:
35
+ - remote: https://example.com/ci.yml
36
+
37
+ stages:
38
+ - build
39
+ `;
40
+ const diags = wgl030.check(makeCtx(yaml));
41
+ expect(diags).toHaveLength(1);
42
+ expect(diags[0].severity).toBe("warning");
43
+ });
44
+
45
+ test("does not flag a project include", () => {
46
+ const yaml = `include:
47
+ - project: my-group/ci-templates
48
+ ref: v1.0.0
49
+ file: /x.yml
50
+
51
+ stages:
52
+ - build
53
+ `;
54
+ const diags = wgl030.check(makeCtx(yaml));
55
+ expect(diags).toHaveLength(0);
56
+ });
57
+ });