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
+20 -6
View File
@@ -3,16 +3,28 @@ package model
// Pipeline represents the top-level structure of a .gitlab-ci.yml file.
// Unknown top-level keys are collected into Jobs.
type Pipeline struct {
Stages []string `yaml:"stages"`
Variables map[string]any `yaml:"variables"` // string or {value,description,options} map
Default *DefaultConfig `yaml:"default"`
Include []any `yaml:"include"`
Workflow *Workflow `yaml:"workflow"`
SourceFile string // path of the root pipeline file; set by Parse
Stages []string `yaml:"stages"`
Variables map[string]any `yaml:"variables"` // string or {value,description,options} map
Default *DefaultConfig `yaml:"default"`
Include []any `yaml:"include"`
Workflow *Workflow `yaml:"workflow"`
// Jobs holds every non-reserved top-level key (i.e. job definitions).
Jobs map[string]Job `yaml:"-"`
RawJobs map[string]map[string]any `yaml:"-"` // pre-resolution raw maps, used by the resolver
}
// SetJobOrigin sets the File field on all jobs that don't already have one.
// Called after ParseBytes to record which file each job came from.
func (p *Pipeline) SetJobOrigin(file string) {
for name, j := range p.Jobs {
if j.File == "" {
j.File = file
}
p.Jobs[name] = j
}
}
type DefaultConfig struct {
Image any `yaml:"image"` // string or {name,pull_policy,...} map
BeforeScript any `yaml:"before_script"` // []string or string (block scalar)
@@ -29,7 +41,9 @@ type Workflow struct {
}
type Job struct {
Name string // set by parser, not from YAML
Name string // set by parser, not from YAML
File string // source file; set by Parse / resolver
Line int // line of the job key in its source file; set by parser
Stage string `yaml:"stage"`
Script any `yaml:"script"` // []string or string (block scalar)
Run any `yaml:"run"` // alternative to script (CI steps)