diff --git a/CHANGELOG.md b/CHANGELOG.md index 0fec911..2266a06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This project uses [Semantic Versioning](https://semver.org). +## [0.2.15] - 2026-06-13 + +### Added + +- **Static reachability check (GL033)** — warns when every rule in a job's `rules:` block has an explicit `when: never`, making the job permanently excluded from any pipeline run. This is a purely static claim: no matter which `if:` condition evaluates to true, the outcome is always "skip"; and if no rule matches, the implicit fallback is also skip. No expression evaluation or context is required. The finding is a `WARNING` (may be intentional as a "disabled job" pattern). Only jobs where every rule has the literal `when: never` value are flagged; rules with no `when:` (default `on_success`), `when: manual`, `when: always`, or `when: on_failure` are not. + ## [0.2.14] - 2026-06-13 ### Added diff --git a/README.md b/README.md index 064d588..1b6b15b 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@

License - Release + Release

> **Disclaimer:** This tool was built through iterative AI-assisted development with [Claude](https://claude.ai). It is experimental, incomplete, and not intended for production use. Coverage of GitLab CI keywords is best-effort and may lag behind GitLab's evolving spec. Use it at your own discretion — no correctness guarantees are made. Contributions and bug reports are welcome. @@ -30,6 +30,7 @@ A local tool to validate and lint `.gitlab-ci.yml` pipelines without needing a G - **Context simulation** — pass `--branch`, `--tag`, or `--source` to `glint check` or `glint graph` to see which jobs would be active, manual, or skipped for a specific pipeline event; evaluates `rules:if:` expressions and `only`/`except` filters - **Variable expansion** — `$VAR` and `${VAR}` references inside variable values are expanded after all sources are merged (pipeline defaults → workflow-rule overrides → CLI flags); transitive chains resolve automatically; visible via `--list-vars` - **Non-string variable scalars** — `BUILD: true`, `RETRIES: 3` and other bare boolean/integer variable values are handled correctly throughout: they render in `--list-vars` output and are injected into the evaluation context as their string equivalents, matching GitLab CI's behaviour +- **Static reachability (GL033)** — warns when a job's `rules:` block can never activate: if every rule has `when: never` the job is permanently excluded from any pipeline run, provable without evaluating any `if:` expressions - **Sorted findings output** — findings are sorted by source file then line number, so all issues from the same file appear together in order; pipeline-level findings (no file) sort first - **Consistent ruff-style warnings** — all warnings (unresolvable includes, skipped extends chains, workflow non-start) use the same `path: [warning] message` format as lint findings - **`--version` / `-v` flag** — prints the compiled version string (e.g. `glint v0.2.14`); the version is also shown at the top of every `--help` output @@ -313,6 +314,7 @@ Every finding includes a stable rule ID (e.g. `GL003`) that can be used to filte | ID | Severity | Rule | |----|----------|------| | GL032 | WARNING | `rules:if:` references `$VAR` not declared in `variables:` (pipeline, job, or `workflow:rules:variables:`) — may be a false positive for variables set in GitLab CI/CD project settings | +| GL033 | WARNING | Every rule in `rules:` has `when: never` — job is permanently excluded from the pipeline (statically provable without context) | ### Hidden jobs (templates) diff --git a/ROADMAP.md b/ROADMAP.md index 45e786a..2813788 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -61,6 +61,7 @@ glint graph tree --branch main .gitlab-ci.yml # tree annotated with [skipped] The current rule set covers the most common sources of broken pipelines. These are the gaps most likely to matter in practice. - ~~**Variable reference validation (GL032)**~~ — ✓ shipped v0.2.11; warns when a `rules:if:` expression references `$VAR` / `${VAR}` not declared anywhere in pipeline YAML; predefined GitLab namespaces (`CI_*`, `GITLAB_*`, …) exempt; variables from included files are also considered +- ~~**`rules:if:` static reachability (GL033)**~~ — ✓ shipped v0.2.15; warns when every rule in a job's `rules:` block has `when: never`, making the job permanently excluded from any pipeline run; no `if:` evaluation required - **`services:` validation** — map form requires `name`; `alias` must be a valid DNS label - **`rules:changes` / `rules:exists`** — warn on glob patterns that can never match (e.g. absolute paths, double `**` on unsupported versions) - **`timeout` format** — must be a duration string GitLab understands (`1h 30m`, `90 minutes`, etc.) diff --git a/Taskfile.yml b/Taskfile.yml index 9c84f96..1a3dbb1 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -81,6 +81,8 @@ tasks: ignore_error: false - cmd: ./{{.BINARY}} check testdata/variable_refs_included.yml ignore_error: false + - cmd: ./{{.BINARY}} check testdata/dead_rules.yml + ignore_error: false - cmd: ./{{.BINARY}} check testdata/samba/.gitlab-ci.yml ignore_error: false - cmd: ./{{.BINARY}} check testdata/samba/.gitlab-ci-coverage.yml diff --git a/internal/linter/dead_rules_test.go b/internal/linter/dead_rules_test.go new file mode 100644 index 0000000..eeef8ac --- /dev/null +++ b/internal/linter/dead_rules_test.go @@ -0,0 +1,90 @@ +package linter + +import ( + "testing" + + "git.k3nny.fr/glint/internal/model" +) + +func TestCheckDeadRules(t *testing.T) { + cases := []struct { + name string + rules []model.Rule + wantHit bool // whether GL033 should fire + }{ + { + name: "no rules — not dead", + rules: nil, + wantHit: false, + }, + { + name: "single bare when:never — dead", + rules: []model.Rule{{When: "never"}}, + wantHit: true, + }, + { + name: "all rules when:never with if — dead", + rules: []model.Rule{ + {If: `$CI_COMMIT_BRANCH == "main"`, When: "never"}, + {If: `$CI_COMMIT_BRANCH == "develop"`, When: "never"}, + {When: "never"}, + }, + wantHit: true, + }, + { + name: "first rule on_success — not dead", + rules: []model.Rule{ + {If: `$CI_COMMIT_BRANCH == "main"`, When: "on_success"}, + {When: "never"}, + }, + wantHit: false, + }, + { + name: "rule with empty when (defaults to on_success) — not dead", + rules: []model.Rule{ + {If: `$CI_COMMIT_BRANCH == "main"`}, + {When: "never"}, + }, + wantHit: false, + }, + { + name: "when:manual — not dead", + rules: []model.Rule{{When: "manual"}}, + wantHit: false, + }, + { + name: "when:always — not dead", + rules: []model.Rule{{When: "always"}}, + wantHit: false, + }, + { + name: "when:on_failure — not dead", + rules: []model.Rule{{When: "on_failure"}}, + wantHit: false, + }, + { + name: "mixed never and manual — not dead", + rules: []model.Rule{ + {If: `$CI_COMMIT_BRANCH == "main"`, When: "never"}, + {When: "manual"}, + }, + wantHit: false, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + job := model.Job{Rules: tc.rules} + findings := checkDeadRules("test-job", job) + hit := false + for _, f := range findings { + if f.Rule == RuleDeadRules { + hit = true + } + } + if hit != tc.wantHit { + t.Errorf("checkDeadRules: got hit=%v, want hit=%v; findings=%v", hit, tc.wantHit, findings) + } + }) + } +} diff --git a/internal/linter/keywords.go b/internal/linter/keywords.go index 4db14dd..b94c175 100644 --- a/internal/linter/keywords.go +++ b/internal/linter/keywords.go @@ -95,6 +95,7 @@ func checkJobKeywords(name string, job model.Job) []Finding { findings = append(findings, checkArtifacts(name, job)...) findings = append(findings, checkCache(name, job)...) findings = append(findings, checkRules(name, job)...) + findings = append(findings, checkDeadRules(name, job)...) findings = append(findings, checkImage(name, job)...) findings = append(findings, checkInherit(name, job)...) return findings @@ -476,6 +477,28 @@ func checkRules(name string, job model.Job) []Finding { return findings } +// checkDeadRules reports when every rule in a job's rules: block has an +// explicit when: never, making the job permanently unreachable. This is a +// provably-correct static claim: no matter which if: condition matches, the +// outcome is always "never"; and if no rule matches, the implicit fallback is +// also skip. No if: evaluation is required. +func checkDeadRules(name string, job model.Job) []Finding { + if len(job.Rules) == 0 { + return nil + } + for _, r := range job.Rules { + if r.When != "never" { + return nil + } + } + return []Finding{{ + Severity: Warning, + Rule: RuleDeadRules, + Job: name, + Message: "rules: block can never activate; every rule has 'when: never' — job is permanently excluded from the pipeline", + }} +} + func checkImage(name string, job model.Job) []Finding { if job.Image == nil { return nil diff --git a/internal/linter/rules.go b/internal/linter/rules.go index 4a82245..1e786d3 100644 --- a/internal/linter/rules.go +++ b/internal/linter/rules.go @@ -114,4 +114,8 @@ const ( // the job's own variables:, or any workflow:rules:variables: block. // May be a false positive for variables set in GitLab CI/CD project settings. RuleUndeclaredVariable = "GL032" + + // GL033: every rule in a job's rules: block has when: never, so the job + // can never be included in any pipeline run. + RuleDeadRules = "GL033" ) diff --git a/testdata/dead_rules.yml b/testdata/dead_rules.yml new file mode 100644 index 0000000..c8b5e84 --- /dev/null +++ b/testdata/dead_rules.yml @@ -0,0 +1,59 @@ +--- +# dead_rules.yml +# Exercises GL033: rules: block where every rule has when: never. +# All flagged jobs produce a WARNING (exit 0). Valid jobs must not be flagged. + +stages: + - build + - test + - deploy + +# ── Jobs that should trigger GL033 ───────────────────────────────────────── + +# Single bare catch-all never — job is always excluded. +disabled-job: + stage: build + script: echo disabled + rules: + - when: never + +# Multiple rules, all when: never — no branch can activate this job. +dead-multi: + stage: test + script: echo dead + rules: + - if: '$CI_COMMIT_BRANCH == "main"' + when: never + - if: '$CI_COMMIT_BRANCH == "develop"' + when: never + - when: never + +# ── Jobs that must NOT trigger GL033 ─────────────────────────────────────── + +# First rule can activate. +main-only: + stage: build + script: echo main + rules: + - if: '$CI_COMMIT_BRANCH == "main"' + when: on_success + - when: never + +# Rule with no when: — defaults to on_success, so job is reachable. +implicit-on-success: + stage: test + script: echo implicit + rules: + - if: '$CI_COMMIT_BRANCH == "develop"' + +# Manual is not never — job is reachable (just gated). +manual-gate: + stage: deploy + script: echo deploy + rules: + - when: manual + +# No rules at all — always active. +always-active: + stage: build + script: echo always