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:
2026-06-11 21:24:18 +02:00
parent a962c996c1
commit a303f63a5e
7 changed files with 100 additions and 17 deletions
+4
View File
@@ -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),
})
}
+30 -2
View File
@@ -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
}
+9 -2
View File
@@ -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),
})
}