feat(linter): context-scoped needs:/dependencies: cross-checks
ci / vet, staticcheck, test, build (push) Failing after 1m54s
release / Build and publish release (push) Successful in 1m6s

When glint check runs in single-context mode (--branch, --tag, --source,
or --var), jobs that resolve to JobSkipped against that context are now
excluded from needs: and dependencies: cross-job checks (GL027-GL031).
This eliminates false-positive errors for jobs intentionally gated to
specific pipeline events (e.g. a deploy job with rules:if: CI_COMMIT_TAG
no longer triggers GL027 on branch pipelines).

linter.Lint now accepts a skipped map[string]bool (nil = check all jobs);
checkNeeds and checkDependencies skip jobs present in the map. Multi-context
mode (--context) passes nil, so all jobs are checked regardless of context.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-25 00:25:09 +02:00
parent 3b0bcb72d3
commit 416659ffd8
12 changed files with 164 additions and 20 deletions
+14 -1
View File
@@ -341,6 +341,8 @@ Examples:
}
}
var skipped map[string]bool // jobs excluded from cross-job lint checks
if len(contexts) > 0 {
// Multi-context mode: build one context per --context flag, then print a comparison table.
ctxList := make([]*cicontext.Context, 0, len(contexts))
@@ -368,6 +370,17 @@ Examples:
if !enrichContext(ctx, p) {
fmt.Fprintf(os.Stderr, "%s: [warning] workflow:rules: pipeline would not start for this context\n", path)
}
// Build skipped set: jobs statically unreachable in this context are
// excluded from needs:/dependencies: cross-checks to avoid false positives.
skipped = make(map[string]bool)
for name, job := range p.Jobs {
if cicontext.EvalJob(job, ctx) == cicontext.JobSkipped {
skipped[name] = true
}
}
if len(skipped) == 0 {
skipped = nil
}
}
if *listVars {
printVars(p, ctx)
@@ -379,7 +392,7 @@ Examples:
}
}
findings := linter.Lint(p)
findings := linter.Lint(p, skipped)
findings = applyConfig(findings, glintCfg, p.Suppressions)
errCount, _ := countSeverities(findings)
+65
View File
@@ -823,6 +823,71 @@ build-job:
}
}
// ── context-scoped linting ───────────────────────────────────────────────────
func TestCmdCheck_ContextScopedLinting_SuppressesSkippedJob(t *testing.T) {
// deploy-job has needs: [nonexistent] but is gated to tag pipelines only.
// With --branch main the job is skipped → GL027 should be suppressed.
content := `
stages: [build, deploy]
build-job:
stage: build
script: make
deploy-job:
stage: deploy
script: make deploy
needs: [nonexistent-job]
rules:
- if: '$CI_COMMIT_TAG != ""'
when: on_success
- when: never
`
path := writePipeline(t, content)
// Without context (permissive default branch=main): deploy-job IS skipped
// by rules evaluation → needs cross-check suppressed → exit 0.
code := captureExit(t)
cmdCheck([]string{"--branch", "main", path})
if *code == 1 {
t.Error("skipped job's needs: error should be suppressed in context-scoped lint")
}
}
func TestCmdCheck_ContextScopedLinting_ActiveJobStillErrors(t *testing.T) {
// Same pipeline but with --tag set: deploy-job is active → GL027 fires.
content := `
stages: [build, deploy]
build-job:
stage: build
script: make
deploy-job:
stage: deploy
script: make deploy
needs: [nonexistent-job]
rules:
- if: '$CI_COMMIT_TAG != ""'
when: on_success
- when: never
`
path := writePipeline(t, content)
code := captureExit(t)
cmdCheck([]string{"--tag", "v1.0.0", path})
if *code != 1 {
t.Error("active job's bad needs: should still produce GL027 error")
}
}
func TestCmdCheck_ContextScopedLinting_SkippedSet_IsNilWhenAllActive(t *testing.T) {
// All jobs active → skipped set is nil → no regression in normal behaviour.
code := captureExit(t)
path := writePipeline(t, minimalPipeline)
cmdCheck([]string{"--branch", "main", path})
if *code == 1 {
t.Error("valid pipeline with all-active jobs should not produce errors")
}
}
// ── parseContextSpec ─────────────────────────────────────────────────────────
func TestParseContextSpec(t *testing.T) {