feat(linter): context-scoped needs:/dependencies: cross-checks
ci / vet, staticcheck, test, build (push) Failing after 1m54s
release / Build and publish release (push) Successful in 1m6s

When glint check runs in single-context mode (--branch, --tag, --source,
or --var), jobs that resolve to JobSkipped against that context are now
excluded from needs: and dependencies: cross-job checks (GL027-GL031).
This eliminates false-positive errors for jobs intentionally gated to
specific pipeline events (e.g. a deploy job with rules:if: CI_COMMIT_TAG
no longer triggers GL027 on branch pipelines).

linter.Lint now accepts a skipped map[string]bool (nil = check all jobs);
checkNeeds and checkDependencies skip jobs present in the map. Multi-context
mode (--context) passes nil, so all jobs are checked regardless of context.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-25 00:25:09 +02:00
parent 3b0bcb72d3
commit 416659ffd8
12 changed files with 164 additions and 20 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.23] - 2026-06-25
### Added
- **Context-scoped linting** — when a context is supplied via `--branch`, `--tag`, `--source`, or `--var`, `glint check` now evaluates every job against that context before running lint rules. Jobs that are statically unreachable in the given context (i.e. their `rules:` block resolves to `JobSkipped`) are excluded from `needs:` and `dependencies:` cross-job checks (GL027GL031). This eliminates false-positive errors for jobs that are intentionally gated to specific pipeline events (e.g. a deploy job with `rules: [{if: '$CI_COMMIT_TAG != ""'}]` does not generate a GL027 error on branch pipelines). Filtering applies only in single-context mode; multi-context mode (`--context`) always checks all jobs.
## [0.2.22] - 2026-06-25
### Added
+11
View File
@@ -151,6 +151,17 @@ Use `--list-vars` to print the resolved variable table to stderr.
| `--changes PATH` | Mark PATH as changed; repeatable |
| `--changes-from REF` | Run `git diff --name-only REF` to auto-detect changed files |
**Context-scoped linting** (single-context mode):
When a context is given, jobs evaluated as `skipped` are excluded from `needs:` and `dependencies:` cross-job checks (GL027GL031). This eliminates false positives for jobs that are intentionally gated to specific pipeline events:
```yaml
deploy-job:
needs: [build-job] # GL027 suppressed on branch pipelines; only checked on tag pipelines
rules:
- if: '$CI_COMMIT_TAG != ""'
```
**Multi-context comparison** (`--context`):
Pass `--context KEY=VALUE[,...]` (repeatable) instead of `--branch`/`--tag`/`--source` to evaluate every job across multiple contexts simultaneously. Each `--context` flag defines one column; glint prints a table showing `active`, `manual`, `skipped`, or `blocked` per job per context.
+1 -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.22-blue.svg" alt="Release"></a>
<a href="CHANGELOG.md"><img src="https://img.shields.io/badge/release-v0.2.23-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.
+1 -1
View File
@@ -24,7 +24,7 @@ Pass `--branch`, `--tag`, `--source`, or `--var` to `glint check` or `glint grap
- ~~**Single `=` operator**~~ — ✓ shipped v0.2.14; bare `=` accepted as alias for `==` in `rules:if:` expressions
- ~~**`rules:changes:` evaluation**~~ — ✓ shipped v0.2.21; `--changes PATH` and `--changes-from REF` flags; doublestar glob matching (`*` within segment, `**` across segments); permissive when no file list provided
- ~~**Multi-context simulation**~~ — ✓ shipped v0.2.22; `--context KEY=VALUE[,...]`; repeatable; prints a comparison table of `active`/`manual`/`skipped`/`blocked` per job across all contexts
- **Context-scoped linting** — skip `needs:`/`dependencies:` cross-checks for jobs that are statically unreachable in the given context
- ~~**Context-scoped linting**~~✓ shipped v0.2.23; jobs evaluated as `JobSkipped` in the supplied context are excluded from `needs:`/`dependencies:` cross-checks (GL027GL031) to eliminate false-positive errors for conditionally-gated jobs
---
+14 -1
View File
@@ -341,6 +341,8 @@ Examples:
}
}
var skipped map[string]bool // jobs excluded from cross-job lint checks
if len(contexts) > 0 {
// Multi-context mode: build one context per --context flag, then print a comparison table.
ctxList := make([]*cicontext.Context, 0, len(contexts))
@@ -368,6 +370,17 @@ Examples:
if !enrichContext(ctx, p) {
fmt.Fprintf(os.Stderr, "%s: [warning] workflow:rules: pipeline would not start for this context\n", path)
}
// Build skipped set: jobs statically unreachable in this context are
// excluded from needs:/dependencies: cross-checks to avoid false positives.
skipped = make(map[string]bool)
for name, job := range p.Jobs {
if cicontext.EvalJob(job, ctx) == cicontext.JobSkipped {
skipped[name] = true
}
}
if len(skipped) == 0 {
skipped = nil
}
}
if *listVars {
printVars(p, ctx)
@@ -379,7 +392,7 @@ Examples:
}
}
findings := linter.Lint(p)
findings := linter.Lint(p, skipped)
findings = applyConfig(findings, glintCfg, p.Suppressions)
errCount, _ := countSeverities(findings)
+65
View File
@@ -823,6 +823,71 @@ build-job:
}
}
// ── context-scoped linting ───────────────────────────────────────────────────
func TestCmdCheck_ContextScopedLinting_SuppressesSkippedJob(t *testing.T) {
// deploy-job has needs: [nonexistent] but is gated to tag pipelines only.
// With --branch main the job is skipped → GL027 should be suppressed.
content := `
stages: [build, deploy]
build-job:
stage: build
script: make
deploy-job:
stage: deploy
script: make deploy
needs: [nonexistent-job]
rules:
- if: '$CI_COMMIT_TAG != ""'
when: on_success
- when: never
`
path := writePipeline(t, content)
// Without context (permissive default branch=main): deploy-job IS skipped
// by rules evaluation → needs cross-check suppressed → exit 0.
code := captureExit(t)
cmdCheck([]string{"--branch", "main", path})
if *code == 1 {
t.Error("skipped job's needs: error should be suppressed in context-scoped lint")
}
}
func TestCmdCheck_ContextScopedLinting_ActiveJobStillErrors(t *testing.T) {
// Same pipeline but with --tag set: deploy-job is active → GL027 fires.
content := `
stages: [build, deploy]
build-job:
stage: build
script: make
deploy-job:
stage: deploy
script: make deploy
needs: [nonexistent-job]
rules:
- if: '$CI_COMMIT_TAG != ""'
when: on_success
- when: never
`
path := writePipeline(t, content)
code := captureExit(t)
cmdCheck([]string{"--tag", "v1.0.0", path})
if *code != 1 {
t.Error("active job's bad needs: should still produce GL027 error")
}
}
func TestCmdCheck_ContextScopedLinting_SkippedSet_IsNilWhenAllActive(t *testing.T) {
// All jobs active → skipped set is nil → no regression in normal behaviour.
code := captureExit(t)
path := writePipeline(t, minimalPipeline)
cmdCheck([]string{"--branch", "main", path})
if *code == 1 {
t.Error("valid pipeline with all-active jobs should not produce errors")
}
}
// ── parseContextSpec ─────────────────────────────────────────────────────────
func TestParseContextSpec(t *testing.T) {
+4 -1
View File
@@ -6,7 +6,7 @@ import (
"git.k3nny.fr/glint/internal/model"
)
func checkDependencies(p *model.Pipeline) []Finding {
func checkDependencies(p *model.Pipeline, skipped map[string]bool) []Finding {
stageIndex := make(map[string]int, len(p.Stages))
for i, s := range p.Stages {
stageIndex[s] = i
@@ -14,6 +14,9 @@ func checkDependencies(p *model.Pipeline) []Finding {
var findings []Finding
for name, job := range p.Jobs {
if skipped[name] {
continue
}
if len(job.Dependencies) == 0 {
continue
}
+25 -9
View File
@@ -458,7 +458,7 @@ func TestCheckNeeds(t *testing.T) {
Needs: []any{"build-job"}},
},
}
if len(checkNeeds(p)) != 0 { t.Error("valid needs: clean") }
if len(checkNeeds(p, nil)) != 0 { t.Error("valid needs: clean") }
// needs unknown job
p2 := &model.Pipeline{
@@ -466,7 +466,7 @@ func TestCheckNeeds(t *testing.T) {
"test-job": {Name: "test-job", Stage: "test", Needs: []any{"nonexistent"}},
},
}
if len(checkNeeds(p2)) != 1 { t.Error("unknown needs: error") }
if len(checkNeeds(p2, nil)) != 1 { t.Error("unknown needs: error") }
// optional: true for unknown job → warning not error
p3 := &model.Pipeline{
@@ -475,7 +475,7 @@ func TestCheckNeeds(t *testing.T) {
Needs: []any{map[string]any{"job": "ghost", "optional": true}}},
},
}
findings := checkNeeds(p3)
findings := checkNeeds(p3, nil)
if len(findings) != 1 || findings[0].Severity != Warning {
t.Error("optional unknown needs: warning")
}
@@ -487,7 +487,7 @@ func TestCheckNeeds(t *testing.T) {
Needs: []any{map[string]any{"pipeline": "other", "job": "j"}}},
},
}
if len(checkNeeds(p4)) != 0 { t.Error("cross-pipeline: clean") }
if len(checkNeeds(p4, nil)) != 0 { t.Error("cross-pipeline: clean") }
// needs later stage → error
p5 := &model.Pipeline{
@@ -501,7 +501,7 @@ func TestCheckNeeds(t *testing.T) {
},
}
// Only cross-stage ordering violations should be found
_ = checkNeeds(p5)
_ = checkNeeds(p5, nil)
}
func TestCheckNeeds_Cycle(t *testing.T) {
@@ -511,7 +511,7 @@ func TestCheckNeeds_Cycle(t *testing.T) {
"b": {Name: "b", Needs: []any{"a"}},
},
}
findings := checkNeeds(p)
findings := checkNeeds(p, nil)
for _, f := range findings {
if f.Rule == RuleNeedsCycle { return }
}
@@ -529,7 +529,7 @@ func TestCheckDependencies(t *testing.T) {
Dependencies: []string{"build-job"}},
},
}
if len(checkDependencies(p)) != 0 { t.Error("valid deps: clean") }
if len(checkDependencies(p, nil)) != 0 { t.Error("valid deps: clean") }
// unknown dep
p2 := &model.Pipeline{
@@ -537,7 +537,7 @@ func TestCheckDependencies(t *testing.T) {
"test-job": {Name: "test-job", Stage: "test", Dependencies: []string{"ghost"}},
},
}
if len(checkDependencies(p2)) != 1 { t.Error("unknown dep: error") }
if len(checkDependencies(p2, nil)) != 1 { t.Error("unknown dep: error") }
// dep in same or later stage → error
p3 := &model.Pipeline{
@@ -547,7 +547,23 @@ func TestCheckDependencies(t *testing.T) {
"test-job2": {Name: "test-job2", Stage: "test", Dependencies: []string{"test-job1"}},
},
}
if len(checkDependencies(p3)) != 1 { t.Error("same stage dep: error") }
if len(checkDependencies(p3, nil)) != 1 { t.Error("same stage dep: error") }
}
func TestCheckDependencies_SkippedJob(t *testing.T) {
p := &model.Pipeline{
Jobs: map[string]model.Job{
"skipped-job": {Name: "skipped-job", Stage: "test", Dependencies: []string{"ghost"}},
},
}
// Without skipping: reports unknown dependency.
if len(checkDependencies(p, nil)) == 0 {
t.Fatal("expected GL030 without skipped set, got none")
}
// With job skipped: suppressed.
if len(checkDependencies(p, map[string]bool{"skipped-job": true})) != 0 {
t.Error("expected no findings for skipped job")
}
}
// ── checkCacheKeyFiles ────────────────────────────────────────────────────────
+5 -3
View File
@@ -52,15 +52,17 @@ func (f Finding) String() string {
// Lint runs all rules against p and returns findings sorted by (File, Line, Rule).
// Findings with no File (pipeline-level) sort before file-scoped ones.
func Lint(p *model.Pipeline) []Finding {
// skipped is an optional set of job names to exclude from cross-job checks
// (needs:, dependencies:); pass nil to check all jobs.
func Lint(p *model.Pipeline, skipped map[string]bool) []Finding {
var findings []Finding
findings = append(findings, checkStages(p)...)
findings = append(findings, checkDuplicateStages(p)...)
findings = append(findings, checkDefault(p)...)
findings = append(findings, checkWorkflow(p)...)
findings = append(findings, checkJobs(p)...)
findings = append(findings, checkNeeds(p)...)
findings = append(findings, checkDependencies(p)...)
findings = append(findings, checkNeeds(p, skipped)...)
findings = append(findings, checkDependencies(p, skipped)...)
findings = append(findings, checkVariableRefs(p)...)
findings = append(findings, checkRulesIfReachability(p)...)
findings = append(findings, checkInheritCompleteness(p)...)
+4 -1
View File
@@ -12,7 +12,7 @@ type needEntry struct {
optional bool // true when the needs entry carries optional: true
}
func checkNeeds(p *model.Pipeline) []Finding {
func checkNeeds(p *model.Pipeline, skipped map[string]bool) []Finding {
var findings []Finding
// Build a stage-index map for ordering checks.
@@ -26,6 +26,9 @@ func checkNeeds(p *model.Pipeline) []Finding {
needsGraph := make(map[string][]string)
for name, job := range p.Jobs {
if skipped[name] {
continue
}
if len(job.Needs) == 0 {
continue
}
+26 -1
View File
@@ -6,6 +6,31 @@ import (
"git.k3nny.fr/glint/internal/model"
)
// TestCheckNeeds_SkippedJob verifies that a skipped job's needs: violations are suppressed.
func TestCheckNeeds_SkippedJob(t *testing.T) {
p := &model.Pipeline{
Stages: []string{"build", "deploy"},
Jobs: map[string]model.Job{
"skipped-job": {
Name: "skipped-job",
Stage: "build",
Script: []any{"echo"},
Needs: []any{"nonexistent-job"},
},
},
}
// Without skipping: should report unknown needs.
withoutSkip := checkNeeds(p, nil)
if len(withoutSkip) == 0 {
t.Fatal("expected GL027 without skipped set, got none")
}
// With job skipped: no findings.
withSkip := checkNeeds(p, map[string]bool{"skipped-job": true})
if len(withSkip) != 0 {
t.Errorf("expected no findings for skipped job, got %v", withSkip)
}
}
// TestCheckNeeds_StageOrder verifies that a job needing a job in a later stage
// produces RuleNeedsStageOrder (line 64-76 in needs.go).
func TestCheckNeeds_StageOrder(t *testing.T) {
@@ -25,7 +50,7 @@ func TestCheckNeeds_StageOrder(t *testing.T) {
},
},
}
findings := checkNeeds(p)
findings := checkNeeds(p, nil)
var gotStageOrder bool
for _, f := range findings {
if f.Rule == RuleNeedsStageOrder {
+2 -2
View File
@@ -35,7 +35,7 @@ func TestSambaCI(t *testing.T) {
t.Logf("extends warning: job %q extends unknown %q", w.Job, w.Base)
}
findings := linter.Lint(p)
findings := linter.Lint(p, nil)
for _, f := range findings {
if f.Severity == linter.Error {
@@ -78,7 +78,7 @@ func TestSambaCIEntryFiles(t *testing.T) {
t.Logf("extends warning: job %q extends unknown %q", w.Job, w.Base)
}
findings := linter.Lint(p)
findings := linter.Lint(p, nil)
for _, f := range findings {
if f.Severity == linter.Error {
t.Errorf("unexpected error finding: %s", f)