f48bf02152
Every Finding now carries a stable Rule string field with a GL### code.
The ID appears in output between the source location and the message:
[ERROR] job "deploy" (ci.yml:14) GL003: missing required field 'script'
[WARNING] (ci.yml) GL001: no stages defined
Rules:
GL001 no-stages GL002 workflow-when GL003 missing-script
GL004 unknown-stage GL005 only-rules-conflict GL006 except-rules-conflict
GL007 deprecated-only GL008 invalid-when GL009 delayed-no-start-in
GL010 start-in-no-delayed GL011 invalid-parallel GL012 invalid-retry
GL013 invalid-retry-when GL014 invalid-allow-failure GL015 invalid-interruptible
GL016 trigger-with-script GL017 invalid-trigger GL018 invalid-coverage
GL019 invalid-release GL020 invalid-environment GL021 invalid-artifacts
GL022 pages-public GL023 invalid-cache GL024 invalid-rules-when
GL025 invalid-image GL026 invalid-inherit GL027 needs-unknown
GL028 needs-stage-order GL029 needs-cycle GL030 unknown-dependency
GL031 dependency-stage
Changes:
- internal/linter/rules.go: new file with all 31 constants + doc comments
- linter.Finding: add Rule string field; String() inserts it before the
message colon when non-empty; format unchanged when Rule == ""
- All Finding{} literals in linter.go, keywords.go, needs.go,
dependencies.go updated with the correct Rule: constant
- README.md lint rules table: new ID column added to all four sections
- CHANGELOG.md: entry in [Unreleased]
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
150 lines
3.8 KiB
Go
150 lines
3.8 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,
|
|
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
|
|
}
|