fix(linter): downgrade needs optional:true missing-job to warning
release / Build and publish release (push) Successful in 1m12s

parseNeedJobNames is replaced by parseNeedEntries which preserves the
optional flag from each needs: entry. When a referenced job does not
exist and optional:true is set, the finding is now WARNING instead of
ERROR, matching GitLab CI runtime behavior (the dependency is silently
skipped when the job is absent from a conditional include).

Optional missing deps are also excluded from the cycle-detection graph
since there is no real dependency edge to trace.

Adds a fixture case in testdata/needs.yml to prevent regression.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 21:27:16 +02:00
parent a303f63a5e
commit d34c39927d
3 changed files with 44 additions and 17 deletions
+2
View File
@@ -15,6 +15,8 @@ This project uses [Semantic Versioning](https://semver.org).
### Fixed ### Fixed
- **`needs: optional: true` downgraded to warning** — a `needs:` entry that carries `optional: true` and references a job not present in the pipeline now emits `[WARNING]` instead of `[ERROR]`. GitLab CI silently skips such dependencies at runtime (the job is absent when its include was not triggered), so the finding was a false positive. Non-optional missing needs remain errors. Optional missing deps are also excluded from the cycle-detection graph.
- **`extends:` jobs with missing script downgraded to warning** — a job that declares `extends:` but has no `script` after resolution now emits `[WARNING]` instead of `[ERROR]`. The script may legitimately come from a base job in a remote include that could not be fetched at lint time (e.g. no token configured). - **`extends:` jobs with missing script downgraded to warning** — a job that declares `extends:` but has no `script` after resolution now emits `[WARNING]` instead of `[ERROR]`. The script may legitimately come from a base job in a remote include that could not be fetched at lint time (e.g. no token configured).
- **Variable map form now parses correctly** — `variables:` entries that use the extended `{value, description, options}` form (GitLab CI 13.7+) no longer cause `yaml: cannot unmarshal !!map into string`. Both `Pipeline.Variables` and per-job `Variables` now accept either plain strings or map-form declarations. - **Variable map form now parses correctly** — `variables:` entries that use the extended `{value, description, options}` form (GitLab CI 13.7+) no longer cause `yaml: cannot unmarshal !!map into string`. Both `Pipeline.Variables` and per-job `Variables` now accept either plain strings or map-form declarations.
+33 -17
View File
@@ -6,6 +6,12 @@ import (
"git.k3nny.fr/glint/internal/model" "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 { func checkNeeds(p *model.Pipeline) []Finding {
var findings []Finding var findings []Finding
@@ -15,7 +21,7 @@ func checkNeeds(p *model.Pipeline) []Finding {
stageIndex[s] = i stageIndex[s] = i
} }
// needsGraph maps each job to the list of jobs it depends on. // needsGraph maps each job to the jobs it depends on (existing jobs only).
// Used for cycle detection after individual checks. // Used for cycle detection after individual checks.
needsGraph := make(map[string][]string) needsGraph := make(map[string][]string)
@@ -24,24 +30,33 @@ func checkNeeds(p *model.Pipeline) []Finding {
continue continue
} }
neededNames := parseNeedJobNames(job.Needs) entries := parseNeedEntries(job.Needs)
needsGraph[name] = neededNames
jobStageIdx, jobHasStage := stageIndex[job.Stage] jobStageIdx, jobHasStage := stageIndex[job.Stage]
for _, needed := range neededNames { for _, entry := range entries {
neededJob, exists := p.Jobs[needed] neededJob, exists := p.Jobs[entry.job]
if !exists { 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{ findings = append(findings, Finding{
Severity: Error, Severity: sev,
Job: name, Job: name,
File: job.File, File: job.File,
Line: job.Line, Line: job.Line,
Message: fmt.Sprintf("needs unknown job %q", needed), Message: fmt.Sprintf("needs unknown job %q", entry.job),
}) })
continue 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. // A job cannot need a job in a later stage.
if len(p.Stages) > 0 && jobHasStage && neededJob.Stage != "" { if len(p.Stages) > 0 && jobHasStage && neededJob.Stage != "" {
neededStageIdx, neededHasStage := stageIndex[neededJob.Stage] neededStageIdx, neededHasStage := stageIndex[neededJob.Stage]
@@ -53,7 +68,7 @@ func checkNeeds(p *model.Pipeline) []Finding {
Line: job.Line, Line: job.Line,
Message: fmt.Sprintf( Message: fmt.Sprintf(
"needs %q which is in a later stage (%q after %q)", "needs %q which is in a later stage (%q after %q)",
needed, neededJob.Stage, job.Stage, entry.job, neededJob.Stage, job.Stage,
), ),
}) })
} }
@@ -65,25 +80,26 @@ func checkNeeds(p *model.Pipeline) []Finding {
return findings return findings
} }
// parseNeedJobNames extracts job names from a needs: list. // parseNeedEntries extracts needs entries from a needs: list, preserving the
// Each element is either a plain string or a map with a "job" key. // optional flag. Each element is a plain string (job name) or a map with a
// Cross-pipeline needs (maps with a "pipeline" key) are skipped. // "job" key. Cross-pipeline needs (maps with a "pipeline" key) are skipped.
func parseNeedJobNames(needs []any) []string { func parseNeedEntries(needs []any) []needEntry {
var names []string var entries []needEntry
for _, n := range needs { for _, n := range needs {
switch v := n.(type) { switch v := n.(type) {
case string: case string:
names = append(names, v) entries = append(entries, needEntry{job: v})
case map[string]any: case map[string]any:
if _, crossPipeline := v["pipeline"]; crossPipeline { if _, crossPipeline := v["pipeline"]; crossPipeline {
continue continue
} }
if job, ok := v["job"].(string); ok { if job, ok := v["job"].(string); ok {
names = append(names, job) optional, _ := v["optional"].(bool)
entries = append(entries, needEntry{job: job, optional: optional})
} }
} }
} }
return names return entries
} }
func detectNeedsCycles(graph map[string][]string, jobs map[string]model.Job) []Finding { func detectNeedsCycles(graph map[string][]string, jobs map[string]model.Job) []Finding {
+9
View File
@@ -38,3 +38,12 @@ missing-needs-job:
- echo "bad" - echo "bad"
needs: needs:
- nonexistent-job # ERROR: job doesn't exist - nonexistent-job # ERROR: job doesn't exist
# optional: true — missing dep should be WARNING not ERROR
optional-needs-job:
stage: build
script:
- echo "I depend on something that may not exist"
needs:
- job: nonexistent-optional-job
optional: true