Files
glint/internal/linter/needs.go
T
k3nny d34c39927d
release / Build and publish release (push) Successful in 1m12s
fix(linter): downgrade needs optional:true missing-job to warning
parseNeedJobNames is replaced by parseNeedEntries which preserves the
optional flag from each needs: entry. When a referenced job does not
exist and optional:true is set, the finding is now WARNING instead of
ERROR, matching GitLab CI runtime behavior (the dependency is silently
skipped when the job is absent from a conditional include).

Optional missing deps are also excluded from the cycle-detection graph
since there is no real dependency edge to trace.

Adds a fixture case in testdata/needs.yml to prevent regression.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 21:27:16 +02:00

147 lines
3.7 KiB
Go

package linter
import (
"fmt"
"git.k3nny.fr/glint/internal/model"
)
// needEntry is a parsed element from a job's needs: list.
type needEntry struct {
job string
optional bool // true when the needs entry carries optional: true
}
func checkNeeds(p *model.Pipeline) []Finding {
var findings []Finding
// Build a stage-index map for ordering checks.
stageIndex := make(map[string]int, len(p.Stages))
for i, s := range p.Stages {
stageIndex[s] = i
}
// needsGraph maps each job to the jobs it depends on (existing jobs only).
// Used for cycle detection after individual checks.
needsGraph := make(map[string][]string)
for name, job := range p.Jobs {
if len(job.Needs) == 0 {
continue
}
entries := parseNeedEntries(job.Needs)
jobStageIdx, jobHasStage := stageIndex[job.Stage]
for _, entry := range entries {
neededJob, exists := p.Jobs[entry.job]
if !exists {
// optional: true means GitLab CI will silently skip the
// dependency when the job is absent (e.g. from a conditional
// include). Downgrade to warning so users are informed without
// failing the lint.
sev := Error
if entry.optional {
sev = Warning
}
findings = append(findings, Finding{
Severity: sev,
Job: name,
File: job.File,
Line: job.Line,
Message: fmt.Sprintf("needs unknown job %q", entry.job),
})
continue
}
// Add to the cycle-detection graph only when the dep exists.
needsGraph[name] = append(needsGraph[name], entry.job)
// A job cannot need a job in a later stage.
if len(p.Stages) > 0 && jobHasStage && neededJob.Stage != "" {
neededStageIdx, neededHasStage := stageIndex[neededJob.Stage]
if neededHasStage && neededStageIdx > jobStageIdx {
findings = append(findings, Finding{
Severity: Error,
Job: name,
File: job.File,
Line: job.Line,
Message: fmt.Sprintf(
"needs %q which is in a later stage (%q after %q)",
entry.job, neededJob.Stage, job.Stage,
),
})
}
}
}
}
findings = append(findings, detectNeedsCycles(needsGraph, p.Jobs)...)
return findings
}
// parseNeedEntries extracts needs entries from a needs: list, preserving the
// optional flag. Each element is a plain string (job name) or a map with a
// "job" key. Cross-pipeline needs (maps with a "pipeline" key) are skipped.
func parseNeedEntries(needs []any) []needEntry {
var entries []needEntry
for _, n := range needs {
switch v := n.(type) {
case string:
entries = append(entries, needEntry{job: v})
case map[string]any:
if _, crossPipeline := v["pipeline"]; crossPipeline {
continue
}
if job, ok := v["job"].(string); ok {
optional, _ := v["optional"].(bool)
entries = append(entries, needEntry{job: job, optional: optional})
}
}
}
return entries
}
func detectNeedsCycles(graph map[string][]string, jobs map[string]model.Job) []Finding {
const (
unvisited = 0
visiting = 1
visited = 2
)
state := make(map[string]int, len(graph))
var findings []Finding
reported := make(map[string]bool)
var visit func(name string, path []string)
visit = func(name string, path []string) {
switch state[name] {
case visited:
return
case visiting:
if !reported[name] {
reported[name] = true
j := jobs[name]
findings = append(findings, Finding{
Severity: Error,
Job: name,
File: j.File,
Line: j.Line,
Message: fmt.Sprintf("circular dependency in needs: %v → %s", path, name),
})
}
return
}
state[name] = visiting
for _, dep := range graph[name] {
visit(dep, append(path, name))
}
state[name] = visited
}
for name := range graph {
visit(name, nil)
}
return findings
}