feat(linter): add file/line to findings; downgrade extends missing-script to warning
Every finding now carries the source file and exact line number of the job key in its YAML file. Format: [ERROR] job "name" (file.yml:12): message. Pipeline-level findings (workflow rules, no stages) reference p.SourceFile. Cross-file include jobs (local, project, component) carry the include source as their File, set via Pipeline.SetJobOrigin after each ParseBytes call in the resolver. Line numbers come from the yaml.Node key node (exact job-name line) in a new document-level first pass in ParseBytes, replacing the previous map[string]yaml.Node approach which only gave value-node lines. Also: jobs that declare extends: but have no script after resolution now emit WARNING instead of ERROR. The script may come from a base in a remote include that was not fetched (no token, offline), making the error a false positive in common project setups. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -24,6 +24,8 @@ func checkDependencies(p *model.Pipeline) []Finding {
|
||||
findings = append(findings, Finding{
|
||||
Severity: Error,
|
||||
Job: name,
|
||||
File: job.File,
|
||||
Line: job.Line,
|
||||
Message: fmt.Sprintf("'dependencies' references unknown job %q", dep),
|
||||
})
|
||||
continue
|
||||
@@ -34,6 +36,8 @@ func checkDependencies(p *model.Pipeline) []Finding {
|
||||
findings = append(findings, Finding{
|
||||
Severity: Error,
|
||||
Job: name,
|
||||
File: job.File,
|
||||
Line: job.Line,
|
||||
Message: fmt.Sprintf("'dependencies' job %q must be in an earlier stage (in %q, current job is in %q)", dep, depJob.Stage, job.Stage),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -17,12 +17,25 @@ const (
|
||||
type Finding struct {
|
||||
Severity Severity
|
||||
Job string // empty for pipeline-level findings
|
||||
File string // source file where the finding originates
|
||||
Line int // line number in File (0 = unknown)
|
||||
Message string
|
||||
}
|
||||
|
||||
func (f Finding) String() string {
|
||||
loc := ""
|
||||
if f.File != "" {
|
||||
if f.Line > 0 {
|
||||
loc = fmt.Sprintf(" (%s:%d)", f.File, f.Line)
|
||||
} else {
|
||||
loc = fmt.Sprintf(" (%s)", f.File)
|
||||
}
|
||||
}
|
||||
if f.Job != "" {
|
||||
return fmt.Sprintf("[%s] job %q: %s", f.Severity, f.Job, f.Message)
|
||||
return fmt.Sprintf("[%s] job %q%s: %s", f.Severity, f.Job, loc, f.Message)
|
||||
}
|
||||
if loc != "" {
|
||||
return fmt.Sprintf("[%s]%s: %s", f.Severity, loc, f.Message)
|
||||
}
|
||||
return fmt.Sprintf("[%s] %s", f.Severity, f.Message)
|
||||
}
|
||||
@@ -43,6 +56,7 @@ func checkStages(p *model.Pipeline) []Finding {
|
||||
if len(p.Stages) == 0 {
|
||||
findings = append(findings, Finding{
|
||||
Severity: Warning,
|
||||
File: p.SourceFile,
|
||||
Message: "no stages defined; GitLab will use default stages (build, test, deploy)",
|
||||
})
|
||||
}
|
||||
@@ -58,6 +72,7 @@ func checkWorkflow(p *model.Pipeline) []Finding {
|
||||
if rule.When != "" && !validWorkflowRuleWhen[rule.When] {
|
||||
findings = append(findings, Finding{
|
||||
Severity: Error,
|
||||
File: p.SourceFile,
|
||||
Message: fmt.Sprintf("workflow.rules[%d].when has invalid value %q; valid: always, never", i, rule.When),
|
||||
})
|
||||
}
|
||||
@@ -88,10 +103,16 @@ func checkJob(name string, job model.Job, stageSet map[string]bool) []Finding {
|
||||
|
||||
// After extends resolution, a job with no script/run is an error.
|
||||
// Exceptions: trigger jobs, pages jobs (use pages: keyword), and template jobs.
|
||||
// When the job has extends:, the script may come from a base that couldn't be
|
||||
// fetched (e.g. a remote include without a token), so downgrade to warning.
|
||||
hasScript := scriptNonEmpty(job.Script) || job.Run != nil
|
||||
if !isTemplate && !isTrigger && job.Pages == nil && !hasScript {
|
||||
sev := Error
|
||||
if job.Extends != nil {
|
||||
sev = Warning
|
||||
}
|
||||
findings = append(findings, Finding{
|
||||
Severity: Error,
|
||||
Severity: sev,
|
||||
Job: name,
|
||||
Message: "missing required field 'script' (or 'run')",
|
||||
})
|
||||
@@ -137,6 +158,13 @@ func checkJob(name string, job model.Job, stageSet map[string]bool) []Finding {
|
||||
|
||||
findings = append(findings, checkJobKeywords(name, job)...)
|
||||
|
||||
// Attach source location to every job-scoped finding collected above.
|
||||
for i := range findings {
|
||||
if findings[i].Job != "" && findings[i].File == "" {
|
||||
findings[i].File = job.File
|
||||
findings[i].Line = job.Line
|
||||
}
|
||||
}
|
||||
return findings
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,8 @@ func checkNeeds(p *model.Pipeline) []Finding {
|
||||
findings = append(findings, Finding{
|
||||
Severity: Error,
|
||||
Job: name,
|
||||
File: job.File,
|
||||
Line: job.Line,
|
||||
Message: fmt.Sprintf("needs unknown job %q", needed),
|
||||
})
|
||||
continue
|
||||
@@ -47,6 +49,8 @@ func checkNeeds(p *model.Pipeline) []Finding {
|
||||
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)",
|
||||
needed, neededJob.Stage, job.Stage,
|
||||
@@ -57,7 +61,7 @@ func checkNeeds(p *model.Pipeline) []Finding {
|
||||
}
|
||||
}
|
||||
|
||||
findings = append(findings, detectNeedsCycles(needsGraph)...)
|
||||
findings = append(findings, detectNeedsCycles(needsGraph, p.Jobs)...)
|
||||
return findings
|
||||
}
|
||||
|
||||
@@ -82,7 +86,7 @@ func parseNeedJobNames(needs []any) []string {
|
||||
return names
|
||||
}
|
||||
|
||||
func detectNeedsCycles(graph map[string][]string) []Finding {
|
||||
func detectNeedsCycles(graph map[string][]string, jobs map[string]model.Job) []Finding {
|
||||
const (
|
||||
unvisited = 0
|
||||
visiting = 1
|
||||
@@ -101,9 +105,12 @@ func detectNeedsCycles(graph map[string][]string) []Finding {
|
||||
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),
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user