feat(linter): context-scoped needs:/dependencies: cross-checks
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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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)...)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user