1 Commits

Author SHA1 Message Date
k3nny 54b5850835 feat(linter): GL033 static dead-rules detection
ci / vet, staticcheck, test, build (push) Successful in 2m12s
release / Build and publish release (push) Successful in 1m7s
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>
2026-06-14 09:13:19 +02:00
8 changed files with 188 additions and 1 deletions
+6
View File
@@ -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
+3 -1
View File
@@ -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)
+1
View File
@@ -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.)
+2
View File
@@ -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
+90
View File
@@ -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)
}
})
}
}
+23
View File
@@ -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
+4
View File
@@ -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"
)
+59
View File
@@ -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