feat(linter): GL033 static dead-rules detection
Add rule GL033 that 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 pure static check — no if: evaluation or context required. Only rules with literal when: never trigger it; rules with no when: (defaults to on_success), when: manual, when: always, or when: on_failure are treated as reachable. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
<p align="center">
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache%202.0-blue.svg" alt="License"></a>
|
||||
<a href="CHANGELOG.md"><img src="https://img.shields.io/badge/release-v0.2.14-blue.svg" alt="Release"></a>
|
||||
<a href="CHANGELOG.md"><img src="https://img.shields.io/badge/release-v0.2.15-blue.svg" alt="Release"></a>
|
||||
</p>
|
||||
|
||||
> **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)
|
||||
|
||||
|
||||
@@ -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.)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
Vendored
+59
@@ -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
|
||||
Reference in New Issue
Block a user