Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8dc30d9207 | |||
| 416659ffd8 |
@@ -5,6 +5,20 @@ 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/).
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||||
This project uses [Semantic Versioning](https://semver.org).
|
This project uses [Semantic Versioning](https://semver.org).
|
||||||
|
|
||||||
|
## [0.2.24] - 2026-06-25
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **`rules:needs:` validation (GL044)** — validates jobs listed in `rules:needs:` overrides (GitLab CI 16.4+). Each entry must reference a job that exists in the pipeline; `optional: true` entries that reference missing jobs are downgraded to warnings (same behaviour as GL027 for top-level `needs:`). Cross-pipeline needs (maps with a `pipeline:` key) are ignored. Skipped jobs are excluded when a context is provided. `glint explain GL044` documents the rule with a bad-YAML example and fix.
|
||||||
|
|
||||||
|
- **`Rule.Needs` model field** — `rules:` entries now parse their `needs:` key into `model.Rule.Needs []any`, making the per-rule needs list available for validation and future evaluation.
|
||||||
|
|
||||||
|
## [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
|
## [0.2.22] - 2026-06-25
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+12
@@ -69,6 +69,7 @@ description, bad-YAML example, and fix.
|
|||||||
| GL029 | ERR | Circular dependency in `needs:` graph |
|
| GL029 | ERR | Circular dependency in `needs:` graph |
|
||||||
| GL030 | ERR | `dependencies:` references a job that doesn't exist |
|
| GL030 | ERR | `dependencies:` references a job that doesn't exist |
|
||||||
| GL031 | ERR | `dependencies:` references a job in the same or a later stage |
|
| GL031 | ERR | `dependencies:` references a job in the same or a later stage |
|
||||||
|
| GL044 | ERR/WARN | `rules:needs:` references a job that doesn't exist (WARN when `optional: true`) |
|
||||||
|
|
||||||
### Expression & reachability
|
### Expression & reachability
|
||||||
|
|
||||||
@@ -151,6 +152,17 @@ Use `--list-vars` to print the resolved variable table to stderr.
|
|||||||
| `--changes PATH` | Mark PATH as changed; repeatable |
|
| `--changes PATH` | Mark PATH as changed; repeatable |
|
||||||
| `--changes-from REF` | Run `git diff --name-only REF` to auto-detect changed files |
|
| `--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`):
|
**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.
|
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.
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache%202.0-blue.svg" alt="License"></a>
|
<a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache%202.0-blue.svg" alt="License"></a>
|
||||||
<a href="CHANGELOG.md"><img src="https://img.shields.io/badge/release-v0.2.22-blue.svg" alt="Release"></a>
|
<a href="CHANGELOG.md"><img src="https://img.shields.io/badge/release-v0.2.24-blue.svg" alt="Release"></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
> **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.
|
> **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.
|
||||||
|
|||||||
+2
-2
@@ -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
|
- ~~**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
|
- ~~**`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
|
- ~~**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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -113,7 +113,7 @@ The SVG renderer and terminal tree cover the basic layout. These would bring it
|
|||||||
|
|
||||||
## Reliability and developer experience
|
## Reliability and developer experience
|
||||||
|
|
||||||
- ~~**Structured rule IDs**~~ — ✓ shipped post-v0.2.0; GL001–GL031 assigned; GL032 added v0.2.11; GL033 added v0.2.15; GL034–GL041 added v0.2.16; output formats (--format json/sarif/junit/github) added v0.2.18; GL042–GL043 added v0.2.20
|
- ~~**Structured rule IDs**~~ — ✓ shipped post-v0.2.0; GL001–GL031 assigned; GL032 added v0.2.11; GL033 added v0.2.15; GL034–GL041 added v0.2.16; output formats (--format json/sarif/junit/github) added v0.2.18; GL042–GL043 added v0.2.20; GL044 added v0.2.24
|
||||||
- ~~**`glint explain <rule-id>`**~~ — ✓ shipped v0.2.20; prints rule description, rationale, bad-YAML example, and fix; `glint explain` (no arg) lists all rules
|
- ~~**`glint explain <rule-id>`**~~ — ✓ shipped v0.2.20; prints rule description, rationale, bad-YAML example, and fix; `glint explain` (no arg) lists all rules
|
||||||
- ~~**Semantic versioning and first release**~~ — shipped as `v0.1.0` (2026-06-07)
|
- ~~**Semantic versioning and first release**~~ — shipped as `v0.1.0` (2026-06-07)
|
||||||
- ~~**Subcommand CLI**~~ — shipped as `v0.2.0` (2026-06-11); `glint check` / `glint graph [mode]` with ruff-style `--help`
|
- ~~**Subcommand CLI**~~ — shipped as `v0.2.0` (2026-06-11); `glint check` / `glint graph [mode]` with ruff-style `--help`
|
||||||
|
|||||||
@@ -113,10 +113,16 @@ tasks:
|
|||||||
ignore_error: false
|
ignore_error: false
|
||||||
- cmd: ./{{.BINARY}} check testdata/inherit_dead_fields.yml
|
- cmd: ./{{.BINARY}} check testdata/inherit_dead_fields.yml
|
||||||
ignore_error: false
|
ignore_error: false
|
||||||
|
- cmd: ./{{.BINARY}} check testdata/rules_needs_valid.yml
|
||||||
|
ignore_error: false
|
||||||
|
- cmd: ./{{.BINARY}} check testdata/rules_needs_invalid.yml
|
||||||
|
ignore_error: true
|
||||||
- cmd: ./{{.BINARY}} explain GL007
|
- cmd: ./{{.BINARY}} explain GL007
|
||||||
ignore_error: false
|
ignore_error: false
|
||||||
- cmd: ./{{.BINARY}} explain gl042
|
- cmd: ./{{.BINARY}} explain gl042
|
||||||
ignore_error: false
|
ignore_error: false
|
||||||
|
- cmd: ./{{.BINARY}} explain GL044
|
||||||
|
ignore_error: false
|
||||||
- cmd: ./{{.BINARY}} explain
|
- cmd: ./{{.BINARY}} explain
|
||||||
ignore_error: false
|
ignore_error: false
|
||||||
|
|
||||||
|
|||||||
+14
-1
@@ -341,6 +341,8 @@ Examples:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var skipped map[string]bool // jobs excluded from cross-job lint checks
|
||||||
|
|
||||||
if len(contexts) > 0 {
|
if len(contexts) > 0 {
|
||||||
// Multi-context mode: build one context per --context flag, then print a comparison table.
|
// Multi-context mode: build one context per --context flag, then print a comparison table.
|
||||||
ctxList := make([]*cicontext.Context, 0, len(contexts))
|
ctxList := make([]*cicontext.Context, 0, len(contexts))
|
||||||
@@ -368,6 +370,17 @@ Examples:
|
|||||||
if !enrichContext(ctx, p) {
|
if !enrichContext(ctx, p) {
|
||||||
fmt.Fprintf(os.Stderr, "%s: [warning] workflow:rules: pipeline would not start for this context\n", path)
|
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 {
|
if *listVars {
|
||||||
printVars(p, ctx)
|
printVars(p, ctx)
|
||||||
@@ -379,7 +392,7 @@ Examples:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
findings := linter.Lint(p)
|
findings := linter.Lint(p, skipped)
|
||||||
findings = applyConfig(findings, glintCfg, p.Suppressions)
|
findings = applyConfig(findings, glintCfg, p.Suppressions)
|
||||||
errCount, _ := countSeverities(findings)
|
errCount, _ := countSeverities(findings)
|
||||||
|
|
||||||
|
|||||||
@@ -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 ─────────────────────────────────────────────────────────
|
// ── parseContextSpec ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func TestParseContextSpec(t *testing.T) {
|
func TestParseContextSpec(t *testing.T) {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"git.k3nny.fr/glint/internal/model"
|
"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))
|
stageIndex := make(map[string]int, len(p.Stages))
|
||||||
for i, s := range p.Stages {
|
for i, s := range p.Stages {
|
||||||
stageIndex[s] = i
|
stageIndex[s] = i
|
||||||
@@ -14,6 +14,9 @@ func checkDependencies(p *model.Pipeline) []Finding {
|
|||||||
|
|
||||||
var findings []Finding
|
var findings []Finding
|
||||||
for name, job := range p.Jobs {
|
for name, job := range p.Jobs {
|
||||||
|
if skipped[name] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if len(job.Dependencies) == 0 {
|
if len(job.Dependencies) == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -816,4 +816,41 @@ my-job:
|
|||||||
my-job:
|
my-job:
|
||||||
script: echo hi`,
|
script: echo hi`,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
RuleRulesNeedsUnknown: {
|
||||||
|
Title: "'rules:needs:' references unknown job",
|
||||||
|
Severity: Error,
|
||||||
|
Description: "A job listed in a rule's 'needs:' override does not exist in " +
|
||||||
|
"the pipeline. 'rules:needs:' (GitLab CI 16.4+) overrides the top-level " +
|
||||||
|
"'needs:' list when that specific rule matches. GitLab will refuse to " +
|
||||||
|
"create the pipeline if any referenced job is missing.",
|
||||||
|
Example: `stages: [build, test]
|
||||||
|
|
||||||
|
build:
|
||||||
|
stage: build
|
||||||
|
script: make
|
||||||
|
|
||||||
|
test:
|
||||||
|
stage: test
|
||||||
|
script: make test
|
||||||
|
rules:
|
||||||
|
- if: $CI_COMMIT_BRANCH == "main"
|
||||||
|
needs: [build, lint] # lint does not exist`,
|
||||||
|
Fix: `stages: [build, test]
|
||||||
|
|
||||||
|
lint:
|
||||||
|
stage: build
|
||||||
|
script: make lint
|
||||||
|
|
||||||
|
build:
|
||||||
|
stage: build
|
||||||
|
script: make
|
||||||
|
|
||||||
|
test:
|
||||||
|
stage: test
|
||||||
|
script: make test
|
||||||
|
rules:
|
||||||
|
- if: $CI_COMMIT_BRANCH == "main"
|
||||||
|
needs: [build, lint]`,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -458,7 +458,7 @@ func TestCheckNeeds(t *testing.T) {
|
|||||||
Needs: []any{"build-job"}},
|
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
|
// needs unknown job
|
||||||
p2 := &model.Pipeline{
|
p2 := &model.Pipeline{
|
||||||
@@ -466,7 +466,7 @@ func TestCheckNeeds(t *testing.T) {
|
|||||||
"test-job": {Name: "test-job", Stage: "test", Needs: []any{"nonexistent"}},
|
"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
|
// optional: true for unknown job → warning not error
|
||||||
p3 := &model.Pipeline{
|
p3 := &model.Pipeline{
|
||||||
@@ -475,7 +475,7 @@ func TestCheckNeeds(t *testing.T) {
|
|||||||
Needs: []any{map[string]any{"job": "ghost", "optional": true}}},
|
Needs: []any{map[string]any{"job": "ghost", "optional": true}}},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
findings := checkNeeds(p3)
|
findings := checkNeeds(p3, nil)
|
||||||
if len(findings) != 1 || findings[0].Severity != Warning {
|
if len(findings) != 1 || findings[0].Severity != Warning {
|
||||||
t.Error("optional unknown needs: 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"}}},
|
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
|
// needs later stage → error
|
||||||
p5 := &model.Pipeline{
|
p5 := &model.Pipeline{
|
||||||
@@ -501,7 +501,7 @@ func TestCheckNeeds(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
// Only cross-stage ordering violations should be found
|
// Only cross-stage ordering violations should be found
|
||||||
_ = checkNeeds(p5)
|
_ = checkNeeds(p5, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCheckNeeds_Cycle(t *testing.T) {
|
func TestCheckNeeds_Cycle(t *testing.T) {
|
||||||
@@ -511,7 +511,7 @@ func TestCheckNeeds_Cycle(t *testing.T) {
|
|||||||
"b": {Name: "b", Needs: []any{"a"}},
|
"b": {Name: "b", Needs: []any{"a"}},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
findings := checkNeeds(p)
|
findings := checkNeeds(p, nil)
|
||||||
for _, f := range findings {
|
for _, f := range findings {
|
||||||
if f.Rule == RuleNeedsCycle { return }
|
if f.Rule == RuleNeedsCycle { return }
|
||||||
}
|
}
|
||||||
@@ -529,7 +529,7 @@ func TestCheckDependencies(t *testing.T) {
|
|||||||
Dependencies: []string{"build-job"}},
|
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
|
// unknown dep
|
||||||
p2 := &model.Pipeline{
|
p2 := &model.Pipeline{
|
||||||
@@ -537,7 +537,7 @@ func TestCheckDependencies(t *testing.T) {
|
|||||||
"test-job": {Name: "test-job", Stage: "test", Dependencies: []string{"ghost"}},
|
"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
|
// dep in same or later stage → error
|
||||||
p3 := &model.Pipeline{
|
p3 := &model.Pipeline{
|
||||||
@@ -547,7 +547,23 @@ func TestCheckDependencies(t *testing.T) {
|
|||||||
"test-job2": {Name: "test-job2", Stage: "test", Dependencies: []string{"test-job1"}},
|
"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 ────────────────────────────────────────────────────────
|
// ── checkCacheKeyFiles ────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -52,15 +52,18 @@ func (f Finding) String() string {
|
|||||||
|
|
||||||
// Lint runs all rules against p and returns findings sorted by (File, Line, Rule).
|
// 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.
|
// 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
|
var findings []Finding
|
||||||
findings = append(findings, checkStages(p)...)
|
findings = append(findings, checkStages(p)...)
|
||||||
findings = append(findings, checkDuplicateStages(p)...)
|
findings = append(findings, checkDuplicateStages(p)...)
|
||||||
findings = append(findings, checkDefault(p)...)
|
findings = append(findings, checkDefault(p)...)
|
||||||
findings = append(findings, checkWorkflow(p)...)
|
findings = append(findings, checkWorkflow(p)...)
|
||||||
findings = append(findings, checkJobs(p)...)
|
findings = append(findings, checkJobs(p)...)
|
||||||
findings = append(findings, checkNeeds(p)...)
|
findings = append(findings, checkNeeds(p, skipped)...)
|
||||||
findings = append(findings, checkDependencies(p)...)
|
findings = append(findings, checkRulesNeeds(p, skipped)...)
|
||||||
|
findings = append(findings, checkDependencies(p, skipped)...)
|
||||||
findings = append(findings, checkVariableRefs(p)...)
|
findings = append(findings, checkVariableRefs(p)...)
|
||||||
findings = append(findings, checkRulesIfReachability(p)...)
|
findings = append(findings, checkRulesIfReachability(p)...)
|
||||||
findings = append(findings, checkInheritCompleteness(p)...)
|
findings = append(findings, checkInheritCompleteness(p)...)
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ type needEntry struct {
|
|||||||
optional bool // true when the needs entry carries optional: true
|
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
|
var findings []Finding
|
||||||
|
|
||||||
// Build a stage-index map for ordering checks.
|
// Build a stage-index map for ordering checks.
|
||||||
@@ -26,6 +26,9 @@ func checkNeeds(p *model.Pipeline) []Finding {
|
|||||||
needsGraph := make(map[string][]string)
|
needsGraph := make(map[string][]string)
|
||||||
|
|
||||||
for name, job := range p.Jobs {
|
for name, job := range p.Jobs {
|
||||||
|
if skipped[name] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if len(job.Needs) == 0 {
|
if len(job.Needs) == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -82,6 +85,44 @@ func checkNeeds(p *model.Pipeline) []Finding {
|
|||||||
return findings
|
return findings
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// checkRulesNeeds validates rules:needs: entries across all jobs. Each entry
|
||||||
|
// in a rule's needs: list must reference a job that exists in the pipeline.
|
||||||
|
// Cross-pipeline needs (maps with a "pipeline" key) are ignored. Skipped jobs
|
||||||
|
// are excluded from checking (same semantics as top-level needs: via GL027).
|
||||||
|
func checkRulesNeeds(p *model.Pipeline, skipped map[string]bool) []Finding {
|
||||||
|
var findings []Finding
|
||||||
|
for name, job := range p.Jobs {
|
||||||
|
if skipped[name] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for i, rule := range job.Rules {
|
||||||
|
if len(rule.Needs) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, entry := range parseNeedEntries(rule.Needs) {
|
||||||
|
if _, exists := p.Jobs[entry.job]; !exists {
|
||||||
|
sev := Error
|
||||||
|
if entry.optional {
|
||||||
|
sev = Warning
|
||||||
|
}
|
||||||
|
findings = append(findings, Finding{
|
||||||
|
Severity: sev,
|
||||||
|
Rule: RuleRulesNeedsUnknown,
|
||||||
|
Job: name,
|
||||||
|
File: job.File,
|
||||||
|
Line: job.Line,
|
||||||
|
Message: fmt.Sprintf(
|
||||||
|
"rules[%d].needs: references unknown job %q",
|
||||||
|
i, entry.job,
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return findings
|
||||||
|
}
|
||||||
|
|
||||||
// parseNeedEntries extracts needs entries from a needs: list, preserving the
|
// parseNeedEntries extracts needs entries from a needs: list, preserving the
|
||||||
// optional flag. Each element is a plain string (job name) or a map with a
|
// optional flag. Each element is a plain string (job name) or a map with a
|
||||||
// "job" key. Cross-pipeline needs (maps with a "pipeline" key) are skipped.
|
// "job" key. Cross-pipeline needs (maps with a "pipeline" key) are skipped.
|
||||||
|
|||||||
@@ -6,6 +6,154 @@ import (
|
|||||||
"git.k3nny.fr/glint/internal/model"
|
"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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCheckRulesNeeds_UnknownJob verifies that an unknown job in rules:needs: produces GL044.
|
||||||
|
func TestCheckRulesNeeds_UnknownJob(t *testing.T) {
|
||||||
|
p := &model.Pipeline{
|
||||||
|
Stages: []string{"build", "test"},
|
||||||
|
Jobs: map[string]model.Job{
|
||||||
|
"build-job": {
|
||||||
|
Name: "build-job",
|
||||||
|
Stage: "build",
|
||||||
|
Script: []any{"make"},
|
||||||
|
},
|
||||||
|
"test-job": {
|
||||||
|
Name: "test-job",
|
||||||
|
Stage: "test",
|
||||||
|
Script: []any{"make test"},
|
||||||
|
Rules: []model.Rule{
|
||||||
|
{
|
||||||
|
If: `$CI_COMMIT_BRANCH == "main"`,
|
||||||
|
Needs: []any{"build-job", "nonexistent"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
findings := checkRulesNeeds(p, nil)
|
||||||
|
var got bool
|
||||||
|
for _, f := range findings {
|
||||||
|
if f.Rule == RuleRulesNeedsUnknown && f.Severity == Error {
|
||||||
|
got = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !got {
|
||||||
|
t.Errorf("expected GL044 error for unknown rules:needs: job; got %v", findings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCheckRulesNeeds_KnownJob verifies no finding when all rules:needs: jobs exist.
|
||||||
|
func TestCheckRulesNeeds_KnownJob(t *testing.T) {
|
||||||
|
p := &model.Pipeline{
|
||||||
|
Stages: []string{"build", "test"},
|
||||||
|
Jobs: map[string]model.Job{
|
||||||
|
"build-job": {Name: "build-job", Stage: "build", Script: []any{"make"}},
|
||||||
|
"test-job": {
|
||||||
|
Name: "test-job",
|
||||||
|
Stage: "test",
|
||||||
|
Script: []any{"make test"},
|
||||||
|
Rules: []model.Rule{{Needs: []any{"build-job"}}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if findings := checkRulesNeeds(p, nil); len(findings) != 0 {
|
||||||
|
t.Errorf("expected no findings for valid rules:needs:; got %v", findings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCheckRulesNeeds_Optional verifies that optional: true downgrades to Warning.
|
||||||
|
func TestCheckRulesNeeds_Optional(t *testing.T) {
|
||||||
|
p := &model.Pipeline{
|
||||||
|
Jobs: map[string]model.Job{
|
||||||
|
"test-job": {
|
||||||
|
Name: "test-job",
|
||||||
|
Script: []any{"make test"},
|
||||||
|
Rules: []model.Rule{
|
||||||
|
{Needs: []any{map[string]any{"job": "ghost", "optional": true}}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
findings := checkRulesNeeds(p, nil)
|
||||||
|
if len(findings) != 1 || findings[0].Severity != Warning {
|
||||||
|
t.Errorf("optional unknown rules:needs: should produce Warning; got %v", findings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCheckRulesNeeds_CrossPipeline verifies that cross-pipeline needs are ignored.
|
||||||
|
func TestCheckRulesNeeds_CrossPipeline(t *testing.T) {
|
||||||
|
p := &model.Pipeline{
|
||||||
|
Jobs: map[string]model.Job{
|
||||||
|
"test-job": {
|
||||||
|
Name: "test-job",
|
||||||
|
Script: []any{"make test"},
|
||||||
|
Rules: []model.Rule{
|
||||||
|
{Needs: []any{map[string]any{"pipeline": "other", "job": "j"}}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if findings := checkRulesNeeds(p, nil); len(findings) != 0 {
|
||||||
|
t.Errorf("cross-pipeline rules:needs: should be ignored; got %v", findings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCheckRulesNeeds_SkippedJob verifies that a skipped job's rules:needs: violations are suppressed.
|
||||||
|
func TestCheckRulesNeeds_SkippedJob(t *testing.T) {
|
||||||
|
p := &model.Pipeline{
|
||||||
|
Jobs: map[string]model.Job{
|
||||||
|
"test-job": {
|
||||||
|
Name: "test-job",
|
||||||
|
Script: []any{"make test"},
|
||||||
|
Rules: []model.Rule{{Needs: []any{"nonexistent"}}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if findings := checkRulesNeeds(p, map[string]bool{"test-job": true}); len(findings) != 0 {
|
||||||
|
t.Errorf("skipped job: expected no findings; got %v", findings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCheckRulesNeeds_NoNeeds verifies no findings when rules have no needs: override.
|
||||||
|
func TestCheckRulesNeeds_NoNeeds(t *testing.T) {
|
||||||
|
p := &model.Pipeline{
|
||||||
|
Jobs: map[string]model.Job{
|
||||||
|
"test-job": {
|
||||||
|
Name: "test-job",
|
||||||
|
Script: []any{"make test"},
|
||||||
|
Rules: []model.Rule{{When: "on_success"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if findings := checkRulesNeeds(p, nil); len(findings) != 0 {
|
||||||
|
t.Errorf("rules without needs: should produce no findings; got %v", findings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestCheckNeeds_StageOrder verifies that a job needing a job in a later stage
|
// TestCheckNeeds_StageOrder verifies that a job needing a job in a later stage
|
||||||
// produces RuleNeedsStageOrder (line 64-76 in needs.go).
|
// produces RuleNeedsStageOrder (line 64-76 in needs.go).
|
||||||
func TestCheckNeeds_StageOrder(t *testing.T) {
|
func TestCheckNeeds_StageOrder(t *testing.T) {
|
||||||
@@ -25,7 +173,7 @@ func TestCheckNeeds_StageOrder(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
findings := checkNeeds(p)
|
findings := checkNeeds(p, nil)
|
||||||
var gotStageOrder bool
|
var gotStageOrder bool
|
||||||
for _, f := range findings {
|
for _, f := range findings {
|
||||||
if f.Rule == RuleNeedsStageOrder {
|
if f.Rule == RuleNeedsStageOrder {
|
||||||
|
|||||||
@@ -154,4 +154,8 @@ const (
|
|||||||
// pipeline has no 'default:' block, making the declaration a no-op. Also fires
|
// pipeline has no 'default:' block, making the declaration a no-op. Also fires
|
||||||
// when 'inherit: default: [list]' names fields not set in the default: block.
|
// when 'inherit: default: [list]' names fields not set in the default: block.
|
||||||
RuleInheritNoDefault = "GL043"
|
RuleInheritNoDefault = "GL043"
|
||||||
|
|
||||||
|
// GL044: a rules:needs: entry references a job that does not exist in the pipeline.
|
||||||
|
// rules:needs: overrides the top-level needs: when a specific rule matches (GitLab CI 16.4+).
|
||||||
|
RuleRulesNeedsUnknown = "GL044"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ func TestSambaCI(t *testing.T) {
|
|||||||
t.Logf("extends warning: job %q extends unknown %q", w.Job, w.Base)
|
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 {
|
for _, f := range findings {
|
||||||
if f.Severity == linter.Error {
|
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)
|
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 {
|
for _, f := range findings {
|
||||||
if f.Severity == linter.Error {
|
if f.Severity == linter.Error {
|
||||||
t.Errorf("unexpected error finding: %s", f)
|
t.Errorf("unexpected error finding: %s", f)
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ type Rule struct {
|
|||||||
Changes any `yaml:"changes"` // []string or {paths,compare_to} map
|
Changes any `yaml:"changes"` // []string or {paths,compare_to} map
|
||||||
Exists any `yaml:"exists"` // []string or map form
|
Exists any `yaml:"exists"` // []string or map form
|
||||||
Variables map[string]any `yaml:"variables"` // set/override variables when rule matches (GitLab CI 15.0+)
|
Variables map[string]any `yaml:"variables"` // set/override variables when rule matches (GitLab CI 15.0+)
|
||||||
|
Needs []any `yaml:"needs"` // override needs: when this rule matches (GitLab CI 16.4+)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReservedKeys are top-level GitLab CI keys that are NOT job definitions.
|
// ReservedKeys are top-level GitLab CI keys that are NOT job definitions.
|
||||||
|
|||||||
Vendored
+15
@@ -0,0 +1,15 @@
|
|||||||
|
stages:
|
||||||
|
- build
|
||||||
|
- test
|
||||||
|
|
||||||
|
build-job:
|
||||||
|
stage: build
|
||||||
|
script: make
|
||||||
|
|
||||||
|
test-job:
|
||||||
|
stage: test
|
||||||
|
script: make test
|
||||||
|
rules:
|
||||||
|
- if: '$CI_COMMIT_BRANCH == "main"'
|
||||||
|
needs: [build-job, nonexistent-job] # nonexistent-job does not exist → GL044
|
||||||
|
- when: on_success
|
||||||
Vendored
+19
@@ -0,0 +1,19 @@
|
|||||||
|
stages:
|
||||||
|
- build
|
||||||
|
- test
|
||||||
|
|
||||||
|
build-job:
|
||||||
|
stage: build
|
||||||
|
script: make
|
||||||
|
|
||||||
|
lint-job:
|
||||||
|
stage: build
|
||||||
|
script: make lint
|
||||||
|
|
||||||
|
test-job:
|
||||||
|
stage: test
|
||||||
|
script: make test
|
||||||
|
rules:
|
||||||
|
- if: '$CI_COMMIT_BRANCH == "main"'
|
||||||
|
needs: [build-job, lint-job] # both exist → clean
|
||||||
|
- when: on_success
|
||||||
Reference in New Issue
Block a user