Files
glint/internal/linter/needs.go
T
k3nny 416659ffd8
ci / vet, staticcheck, test, build (push) Failing after 1m54s
release / Build and publish release (push) Successful in 1m6s
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>
2026-06-25 00:25:09 +02:00

153 lines
3.9 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, skipped map[string]bool) []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 skipped[name] {
continue
}
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,
Rule: RuleNeedsUnknown,
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,
Rule: RuleNeedsStageOrder,
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,
Rule: RuleNeedsCycle,
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
}