diff --git a/CHANGELOG.md b/CHANGELOG.md index bfd0517..1c44827 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This project uses [Semantic Versioning](https://semver.org). +## [0.2.23] - 2026-06-25 + +### Added + +- **Context-scoped linting** — when a context is supplied via `--branch`, `--tag`, `--source`, or `--var`, `glint check` now evaluates every job against that context before running lint rules. Jobs that are statically unreachable in the given context (i.e. their `rules:` block resolves to `JobSkipped`) are excluded from `needs:` and `dependencies:` cross-job checks (GL027–GL031). This eliminates false-positive errors for jobs that are intentionally gated to specific pipeline events (e.g. a deploy job with `rules: [{if: '$CI_COMMIT_TAG != ""'}]` does not generate a GL027 error on branch pipelines). Filtering applies only in single-context mode; multi-context mode (`--context`) always checks all jobs. + ## [0.2.22] - 2026-06-25 ### Added diff --git a/FEATURES.md b/FEATURES.md index 1d23102..4915f23 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -151,6 +151,17 @@ Use `--list-vars` to print the resolved variable table to stderr. | `--changes PATH` | Mark PATH as changed; repeatable | | `--changes-from REF` | Run `git diff --name-only REF` to auto-detect changed files | +**Context-scoped linting** (single-context mode): + +When a context is given, jobs evaluated as `skipped` are excluded from `needs:` and `dependencies:` cross-job checks (GL027–GL031). This eliminates false positives for jobs that are intentionally gated to specific pipeline events: + +```yaml +deploy-job: + needs: [build-job] # GL027 suppressed on branch pipelines; only checked on tag pipelines + rules: + - if: '$CI_COMMIT_TAG != ""' +``` + **Multi-context comparison** (`--context`): Pass `--context KEY=VALUE[,...]` (repeatable) instead of `--branch`/`--tag`/`--source` to evaluate every job across multiple contexts simultaneously. Each `--context` flag defines one column; glint prints a table showing `active`, `manual`, `skipped`, or `blocked` per job per context. diff --git a/README.md b/README.md index 81bfbe3..4c3bf30 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@
> **Disclaimer:** This tool was built through iterative AI-assisted development with [Claude](https://claude.ai). It is experimental, incomplete, and not intended for production use. Coverage of GitLab CI keywords is best-effort and may lag behind GitLab's evolving spec. Use it at your own discretion — no correctness guarantees are made. Contributions and bug reports are welcome. diff --git a/ROADMAP.md b/ROADMAP.md index b4edb88..5574dcf 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -24,7 +24,7 @@ Pass `--branch`, `--tag`, `--source`, or `--var` to `glint check` or `glint grap - ~~**Single `=` operator**~~ — ✓ shipped v0.2.14; bare `=` accepted as alias for `==` in `rules:if:` expressions - ~~**`rules:changes:` evaluation**~~ — ✓ shipped v0.2.21; `--changes PATH` and `--changes-from REF` flags; doublestar glob matching (`*` within segment, `**` across segments); permissive when no file list provided - ~~**Multi-context simulation**~~ — ✓ shipped v0.2.22; `--context KEY=VALUE[,...]`; repeatable; prints a comparison table of `active`/`manual`/`skipped`/`blocked` per job across all contexts -- **Context-scoped linting** — skip `needs:`/`dependencies:` cross-checks for jobs that are statically unreachable in the given context +- ~~**Context-scoped linting**~~ — ✓ shipped v0.2.23; jobs evaluated as `JobSkipped` in the supplied context are excluded from `needs:`/`dependencies:` cross-checks (GL027–GL031) to eliminate false-positive errors for conditionally-gated jobs --- diff --git a/cmd/glint/main.go b/cmd/glint/main.go index 40d7c19..2c16b12 100644 --- a/cmd/glint/main.go +++ b/cmd/glint/main.go @@ -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) diff --git a/cmd/glint/main_test.go b/cmd/glint/main_test.go index d279cd4..07d9510 100644 --- a/cmd/glint/main_test.go +++ b/cmd/glint/main_test.go @@ -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) { diff --git a/internal/linter/dependencies.go b/internal/linter/dependencies.go index dfa00de..888d664 100644 --- a/internal/linter/dependencies.go +++ b/internal/linter/dependencies.go @@ -6,7 +6,7 @@ import ( "git.k3nny.fr/glint/internal/model" ) -func checkDependencies(p *model.Pipeline) []Finding { +func checkDependencies(p *model.Pipeline, skipped map[string]bool) []Finding { stageIndex := make(map[string]int, len(p.Stages)) for i, s := range p.Stages { stageIndex[s] = i @@ -14,6 +14,9 @@ func checkDependencies(p *model.Pipeline) []Finding { var findings []Finding for name, job := range p.Jobs { + if skipped[name] { + continue + } if len(job.Dependencies) == 0 { continue } diff --git a/internal/linter/keywords_test.go b/internal/linter/keywords_test.go index d51b603..947dc5a 100644 --- a/internal/linter/keywords_test.go +++ b/internal/linter/keywords_test.go @@ -458,7 +458,7 @@ func TestCheckNeeds(t *testing.T) { Needs: []any{"build-job"}}, }, } - if len(checkNeeds(p)) != 0 { t.Error("valid needs: clean") } + if len(checkNeeds(p, nil)) != 0 { t.Error("valid needs: clean") } // needs unknown job p2 := &model.Pipeline{ @@ -466,7 +466,7 @@ func TestCheckNeeds(t *testing.T) { "test-job": {Name: "test-job", Stage: "test", Needs: []any{"nonexistent"}}, }, } - if len(checkNeeds(p2)) != 1 { t.Error("unknown needs: error") } + if len(checkNeeds(p2, nil)) != 1 { t.Error("unknown needs: error") } // optional: true for unknown job → warning not error p3 := &model.Pipeline{ @@ -475,7 +475,7 @@ func TestCheckNeeds(t *testing.T) { Needs: []any{map[string]any{"job": "ghost", "optional": true}}}, }, } - findings := checkNeeds(p3) + findings := checkNeeds(p3, nil) if len(findings) != 1 || findings[0].Severity != Warning { t.Error("optional unknown needs: warning") } @@ -487,7 +487,7 @@ func TestCheckNeeds(t *testing.T) { Needs: []any{map[string]any{"pipeline": "other", "job": "j"}}}, }, } - if len(checkNeeds(p4)) != 0 { t.Error("cross-pipeline: clean") } + if len(checkNeeds(p4, nil)) != 0 { t.Error("cross-pipeline: clean") } // needs later stage → error p5 := &model.Pipeline{ @@ -501,7 +501,7 @@ func TestCheckNeeds(t *testing.T) { }, } // Only cross-stage ordering violations should be found - _ = checkNeeds(p5) + _ = checkNeeds(p5, nil) } func TestCheckNeeds_Cycle(t *testing.T) { @@ -511,7 +511,7 @@ func TestCheckNeeds_Cycle(t *testing.T) { "b": {Name: "b", Needs: []any{"a"}}, }, } - findings := checkNeeds(p) + findings := checkNeeds(p, nil) for _, f := range findings { if f.Rule == RuleNeedsCycle { return } } @@ -529,7 +529,7 @@ func TestCheckDependencies(t *testing.T) { Dependencies: []string{"build-job"}}, }, } - if len(checkDependencies(p)) != 0 { t.Error("valid deps: clean") } + if len(checkDependencies(p, nil)) != 0 { t.Error("valid deps: clean") } // unknown dep p2 := &model.Pipeline{ @@ -537,7 +537,7 @@ func TestCheckDependencies(t *testing.T) { "test-job": {Name: "test-job", Stage: "test", Dependencies: []string{"ghost"}}, }, } - if len(checkDependencies(p2)) != 1 { t.Error("unknown dep: error") } + if len(checkDependencies(p2, nil)) != 1 { t.Error("unknown dep: error") } // dep in same or later stage → error p3 := &model.Pipeline{ @@ -547,7 +547,23 @@ func TestCheckDependencies(t *testing.T) { "test-job2": {Name: "test-job2", Stage: "test", Dependencies: []string{"test-job1"}}, }, } - if len(checkDependencies(p3)) != 1 { t.Error("same stage dep: error") } + if len(checkDependencies(p3, nil)) != 1 { t.Error("same stage dep: error") } +} + +func TestCheckDependencies_SkippedJob(t *testing.T) { + p := &model.Pipeline{ + Jobs: map[string]model.Job{ + "skipped-job": {Name: "skipped-job", Stage: "test", Dependencies: []string{"ghost"}}, + }, + } + // Without skipping: reports unknown dependency. + if len(checkDependencies(p, nil)) == 0 { + t.Fatal("expected GL030 without skipped set, got none") + } + // With job skipped: suppressed. + if len(checkDependencies(p, map[string]bool{"skipped-job": true})) != 0 { + t.Error("expected no findings for skipped job") + } } // ── checkCacheKeyFiles ──────────────────────────────────────────────────────── diff --git a/internal/linter/linter.go b/internal/linter/linter.go index b1aa2a2..ce101ca 100644 --- a/internal/linter/linter.go +++ b/internal/linter/linter.go @@ -52,15 +52,17 @@ func (f Finding) String() string { // Lint runs all rules against p and returns findings sorted by (File, Line, Rule). // Findings with no File (pipeline-level) sort before file-scoped ones. -func Lint(p *model.Pipeline) []Finding { +// skipped is an optional set of job names to exclude from cross-job checks +// (needs:, dependencies:); pass nil to check all jobs. +func Lint(p *model.Pipeline, skipped map[string]bool) []Finding { var findings []Finding findings = append(findings, checkStages(p)...) findings = append(findings, checkDuplicateStages(p)...) findings = append(findings, checkDefault(p)...) findings = append(findings, checkWorkflow(p)...) findings = append(findings, checkJobs(p)...) - findings = append(findings, checkNeeds(p)...) - findings = append(findings, checkDependencies(p)...) + findings = append(findings, checkNeeds(p, skipped)...) + findings = append(findings, checkDependencies(p, skipped)...) findings = append(findings, checkVariableRefs(p)...) findings = append(findings, checkRulesIfReachability(p)...) findings = append(findings, checkInheritCompleteness(p)...) diff --git a/internal/linter/needs.go b/internal/linter/needs.go index e6260f4..737a96e 100644 --- a/internal/linter/needs.go +++ b/internal/linter/needs.go @@ -12,7 +12,7 @@ type needEntry struct { optional bool // true when the needs entry carries optional: true } -func checkNeeds(p *model.Pipeline) []Finding { +func checkNeeds(p *model.Pipeline, skipped map[string]bool) []Finding { var findings []Finding // Build a stage-index map for ordering checks. @@ -26,6 +26,9 @@ func checkNeeds(p *model.Pipeline) []Finding { needsGraph := make(map[string][]string) for name, job := range p.Jobs { + if skipped[name] { + continue + } if len(job.Needs) == 0 { continue } diff --git a/internal/linter/needs_test.go b/internal/linter/needs_test.go index efceeaf..631e59e 100644 --- a/internal/linter/needs_test.go +++ b/internal/linter/needs_test.go @@ -6,6 +6,31 @@ import ( "git.k3nny.fr/glint/internal/model" ) +// TestCheckNeeds_SkippedJob verifies that a skipped job's needs: violations are suppressed. +func TestCheckNeeds_SkippedJob(t *testing.T) { + p := &model.Pipeline{ + Stages: []string{"build", "deploy"}, + Jobs: map[string]model.Job{ + "skipped-job": { + Name: "skipped-job", + Stage: "build", + Script: []any{"echo"}, + Needs: []any{"nonexistent-job"}, + }, + }, + } + // Without skipping: should report unknown needs. + withoutSkip := checkNeeds(p, nil) + if len(withoutSkip) == 0 { + t.Fatal("expected GL027 without skipped set, got none") + } + // With job skipped: no findings. + withSkip := checkNeeds(p, map[string]bool{"skipped-job": true}) + if len(withSkip) != 0 { + t.Errorf("expected no findings for skipped job, got %v", withSkip) + } +} + // TestCheckNeeds_StageOrder verifies that a job needing a job in a later stage // produces RuleNeedsStageOrder (line 64-76 in needs.go). func TestCheckNeeds_StageOrder(t *testing.T) { @@ -25,7 +50,7 @@ func TestCheckNeeds_StageOrder(t *testing.T) { }, }, } - findings := checkNeeds(p) + findings := checkNeeds(p, nil) var gotStageOrder bool for _, f := range findings { if f.Rule == RuleNeedsStageOrder { diff --git a/internal/linter/samba_test.go b/internal/linter/samba_test.go index ec974fe..b16c118 100644 --- a/internal/linter/samba_test.go +++ b/internal/linter/samba_test.go @@ -35,7 +35,7 @@ func TestSambaCI(t *testing.T) { t.Logf("extends warning: job %q extends unknown %q", w.Job, w.Base) } - findings := linter.Lint(p) + findings := linter.Lint(p, nil) for _, f := range findings { if f.Severity == linter.Error { @@ -78,7 +78,7 @@ func TestSambaCIEntryFiles(t *testing.T) { t.Logf("extends warning: job %q extends unknown %q", w.Job, w.Base) } - findings := linter.Lint(p) + findings := linter.Lint(p, nil) for _, f := range findings { if f.Severity == linter.Error { t.Errorf("unexpected error finding: %s", f)