Compare commits

...

3 Commits

Author SHA1 Message Date
k3nny 8dc30d9207 feat(linter): rules:needs: validation (GL044)
ci / vet, staticcheck, test, build (push) Failing after 2m4s
release / Build and publish release (push) Successful in 1m12s
Add GL044 to validate jobs listed in rules:needs: overrides (GitLab CI
16.4+). rules:needs: lets a specific rule override the job's top-level
needs: list; any referenced job must exist in the pipeline. Unknown jobs
produce an error; optional: true entries produce a warning, matching the
GL027 behaviour for top-level needs:. Cross-pipeline needs and skipped
jobs (when a context is active) are excluded from checking.

Implementation:
- model.Rule gains a Needs []any field (yaml:"needs")
- checkRulesNeeds(p, skipped) added to needs.go; wired into Lint
- GL044 / RuleRulesNeedsUnknown added to rules.go and explain.go
- 6 unit tests + 2 testdata fixtures; task validate updated

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 00:33:52 +02:00
k3nny 416659ffd8 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>
2026-06-25 00:25:09 +02:00
k3nny 3b0bcb72d3 feat(cli): multi-context simulation via --context flag
ci / vet, staticcheck, test, build (push) Failing after 2m39s
release / Build and publish release (push) Successful in 1m22s
Add --context KEY=VALUE[,...] (repeatable) to glint check. When two or
more --context flags are given, glint evaluates every pipeline job across
all contexts and prints a side-by-side comparison table:

  glint check --context branch=main --context branch=develop .yml

Each column shows active / manual / skipped / blocked per job. Known
context keys are branch, tag, source (case-insensitive); any other
KEY=VALUE pair is treated as a CI variable override. The --changes /
--changes-from changed-file list is shared across all contexts. Implicit
branch=main / source=push defaults are skipped when --context is used.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 00:12:38 +02:00
18 changed files with 986 additions and 48 deletions
+20
View File
@@ -5,6 +5,26 @@ 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 (GL027GL031). 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
- **Multi-context simulation** — `glint check` now accepts a repeatable `--context KEY=VALUE[,...]` flag. Each `--context` invocation defines one simulation context; when two or more are given, glint evaluates every pipeline job across all contexts and prints a side-by-side comparison table with `active`, `manual`, `skipped`, or `blocked` (when `workflow:rules:` would prevent the pipeline from starting) per column. Known context keys are `branch`, `tag`, and `source` (case-insensitive); any other `KEY=VALUE` pair is treated as a CI variable override. The `--changes` / `--changes-from` flags work alongside `--context` and the changed-file list is shared across all contexts. Implicit `--branch main --source push` defaults are skipped when `--context` is given.
## [0.2.21] - 2026-06-21 ## [0.2.21] - 2026-06-21
### Added ### Added
+30
View File
@@ -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,35 @@ 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 (GL027GL031). 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.
Known context keys: `branch`, `tag`, `source` (case-insensitive). Any other `KEY=VALUE` pair is injected as a CI variable override. The `--changes`/`--changes-from` file list is shared across all contexts.
```
glint check --context branch=main --context branch=develop .gitlab-ci.yml
Context comparison:
JOB branch=main branch=develop
---------- ----------- -------------
build-job active active
deploy-job active skipped
test-job active active
```
--- ---
## Output formats ## Output formats
+2 -2
View File
@@ -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.21-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.
@@ -17,7 +17,7 @@ A local tool to validate and lint `.gitlab-ci.yml` pipelines without needing a G
- **Lints** — 43 rules covering pipeline structure, keyword constraints, `needs:`/`dependencies:` graphs, expression reachability, and deprecations (GL001GL043); run `glint explain <ID>` for any rule - **Lints** — 43 rules covering pipeline structure, keyword constraints, `needs:`/`dependencies:` graphs, expression reachability, and deprecations (GL001GL043); run `glint explain <ID>` for any rule
- **Resolves includes** — local files, HTTPS URLs, GitLab project templates, and CI/CD Catalog components, with offline cache support - **Resolves includes** — local files, HTTPS URLs, GitLab project templates, and CI/CD Catalog components, with offline cache support
- **Simulates context** — `--branch`, `--tag`, `--source` flags evaluate `rules:if:` and `only`/`except` to show which jobs would be active, manual, or skipped - **Simulates context** — `--branch`, `--tag`, `--source` flags evaluate `rules:if:` and `only`/`except` to show which jobs would be active, manual, or skipped; `--context branch=main --context branch=develop` prints a multi-column comparison table across multiple contexts in one run
- **Multiple output formats** — `--format text` (default, ruff-style), `json`, `sarif` (GitHub Code Scanning / GitLab SAST), `junit`, `github` (PR annotations) - **Multiple output formats** — `--format text` (default, ruff-style), `json`, `sarif` (GitHub Code Scanning / GitLab SAST), `junit`, `github` (PR annotations)
- **Project config** — `.glint.yml` for rule suppression, severity overrides, token/URL defaults; `# glint: ignore RULE` for per-job inline suppression - **Project config** — `.glint.yml` for rule suppression, severity overrides, token/URL defaults; `# glint: ignore RULE` for per-job inline suppression
- **Graph visualization** — `glint graph` prints a terminal job tree; `glint graph pipeline` renders a GitLab CI-style SVG/PNG - **Graph visualization** — `glint graph` prints a terminal job tree; `glint graph pipeline` renders a GitLab CI-style SVG/PNG
+3 -3
View File
@@ -23,8 +23,8 @@ Pass `--branch`, `--tag`, `--source`, or `--var` to `glint check` or `glint grap
- ~~**Workflow rule strict evaluation**~~ — ✓ shipped v0.2.14; unparseable `if:` skips the rule instead of matching everything; prevents wrong variables being injected - ~~**Workflow rule strict evaluation**~~ — ✓ shipped v0.2.14; unparseable `if:` skips the rule instead of matching everything; prevents wrong variables being injected
- ~~**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** — run multiple contexts in one invocation and print a comparison table (`--context branch=main --context branch=develop --context tag=v1.0.0`) - ~~**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 (GL027GL031) 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; GL001GL031 assigned; GL032 added v0.2.11; GL033 added v0.2.15; GL034GL041 added v0.2.16; output formats (--format json/sarif/junit/github) added v0.2.18; GL042GL043 added v0.2.20 - ~~**Structured rule IDs**~~ — ✓ shipped post-v0.2.0; GL001GL031 assigned; GL032 added v0.2.11; GL033 added v0.2.15; GL034GL041 added v0.2.16; output formats (--format json/sarif/junit/github) added v0.2.18; GL042GL043 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`
+6
View File
@@ -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
+192 -11
View File
@@ -126,6 +126,8 @@ func cmdCheck(args []string) {
var changesFiles multiFlag var changesFiles multiFlag
fs.Var(&changesFiles, "changes", "mark a file path as changed for rules:changes: evaluation; repeatable") fs.Var(&changesFiles, "changes", "mark a file path as changed for rules:changes: evaluation; repeatable")
changesFrom := fs.String("changes-from", "", "git ref to diff against for rules:changes: evaluation (e.g. HEAD~1, origin/main)") changesFrom := fs.String("changes-from", "", "git ref to diff against for rules:changes: evaluation (e.g. HEAD~1, origin/main)")
var contexts multiFlag
fs.Var(&contexts, "context", "simulation context as KEY=VALUE[,...]; repeatable for multi-context comparison table")
fs.Usage = func() { fs.Usage = func() {
fmt.Fprintf(os.Stderr, "glint %s\n\n", version) fmt.Fprintf(os.Stderr, "glint %s\n\n", version)
fmt.Fprint(os.Stderr, `Lint a GitLab CI pipeline file. fmt.Fprint(os.Stderr, `Lint a GitLab CI pipeline file.
@@ -190,6 +192,15 @@ Options:
Run "git diff --name-only <REF>" to determine changed files for Run "git diff --name-only <REF>" to determine changed files for
rules:changes: evaluation. Combined with --changes if both are given. rules:changes: evaluation. Combined with --changes if both are given.
--context <KEY=VALUE[,...]>
Define a simulation context. Repeatable: each --context flag adds one
column to a comparison table showing every job's state across contexts.
Known keys: branch, tag, source. Any other KEY=VALUE is treated as a
CI variable override. Examples:
--context branch=main --context branch=develop
--context tag=v1.0.0
--context branch=main,DEPLOY_ENV=prod
--list-vars --list-vars
Print all pipeline-level variables collected from the root file and Print all pipeline-level variables collected from the root file and
every included file (sorted KEY=VALUE) to stderr, then continue every included file (sorted KEY=VALUE) to stderr, then continue
@@ -219,12 +230,14 @@ Examples:
glint check --offline --cache-dir ~/.cache/glint .gitlab-ci.yml glint check --offline --cache-dir ~/.cache/glint .gitlab-ci.yml
glint check --changes src/main.go --changes Dockerfile .gitlab-ci.yml glint check --changes src/main.go --changes Dockerfile .gitlab-ci.yml
glint check --changes-from origin/main .gitlab-ci.yml glint check --changes-from origin/main .gitlab-ci.yml
glint check --context branch=main --context branch=develop .gitlab-ci.yml
glint check --context branch=main --context tag=v1.0.0 --context source=schedule .gitlab-ci.yml
`) `)
} }
_ = fs.Parse(args) _ = fs.Parse(args)
// Apply implicit defaults when no context flag is given at all. // Apply implicit defaults only in single-context mode when no flags are given.
if *branch == "" && *tag == "" && *source == "" && len(vars) == 0 { if len(contexts) == 0 && *branch == "" && *tag == "" && *source == "" && len(vars) == 0 {
*branch = "main" *branch = "main"
*source = "push" *source = "push"
} }
@@ -308,32 +321,66 @@ Examples:
fmt.Fprintf(os.Stderr, "%s: [warning] job %q extends unknown job %q; extends chain skipped\n", path, w.Job, w.Base) fmt.Fprintf(os.Stderr, "%s: [warning] job %q extends unknown job %q; extends chain skipped\n", path, w.Job, w.Base)
} }
ctx := cicontext.New(*branch, *tag, *source, vars) // Compute changed files once; shared across single and multi-context modes.
// Wire up rules:changes: evaluation when file-change data is provided.
if *changesFrom != "" || len(changesFiles) > 0 {
var allChanged []string var allChanged []string
reliable := len(changesFiles) > 0 var changesReliable bool
if *changesFrom != "" || len(changesFiles) > 0 {
changesReliable = len(changesFiles) > 0
if *changesFrom != "" { if *changesFrom != "" {
files, err := gitDiffFiles(*changesFrom) files, err := gitDiffFiles(*changesFrom)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "%s: [warning] --changes-from: %v\n", path, err) fmt.Fprintf(os.Stderr, "%s: [warning] --changes-from: %v\n", path, err)
} else { } else {
allChanged = append(allChanged, files...) allChanged = append(allChanged, files...)
reliable = true changesReliable = true
} }
} }
allChanged = append(allChanged, changesFiles...) allChanged = append(allChanged, changesFiles...)
if reliable { if changesReliable && allChanged == nil {
if allChanged == nil {
allChanged = []string{} allChanged = []string{}
} }
ctx.SetChangedFiles(allChanged)
} }
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))
runs := make([]bool, 0, len(contexts))
for _, spec := range contexts {
cb, ct, cs, cv := parseContextSpec(spec)
c := cicontext.New(cb, ct, cs, cv)
if changesReliable {
c.SetChangedFiles(allChanged)
}
ran := enrichContext(c, p)
ctxList = append(ctxList, c)
runs = append(runs, ran)
}
if *format == "text" {
printContextTable(p, ctxList, contexts, runs)
}
} else {
// Single-context mode: existing flow.
ctx := cicontext.New(*branch, *tag, *source, vars)
if changesReliable {
ctx.SetChangedFiles(allChanged)
} }
if !ctx.IsEmpty() { if !ctx.IsEmpty() {
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)
@@ -343,8 +390,9 @@ Examples:
if !ctx.IsEmpty() && *format == "text" { if !ctx.IsEmpty() && *format == "text" {
printContext(p, ctx) printContext(p, ctx)
} }
}
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)
@@ -679,3 +727,136 @@ func printJobGroup(label string, jobs []string) {
} }
fmt.Printf("%s (%d): %s\n", label, len(jobs), strings.Join(jobs, ", ")) fmt.Printf("%s (%d): %s\n", label, len(jobs), strings.Join(jobs, ", "))
} }
// parseContextSpec parses "branch=main,DEPLOY_ENV=prod" into its parts.
// Known keys (branch, tag, source) are extracted; everything else goes into extraVars.
func parseContextSpec(spec string) (branch, tag, source string, extraVars []string) {
for _, kv := range strings.Split(spec, ",") {
kv = strings.TrimSpace(kv)
if kv == "" {
continue
}
parts := strings.SplitN(kv, "=", 2)
key := parts[0]
val := ""
if len(parts) == 2 {
val = parts[1]
}
switch strings.ToLower(key) {
case "branch":
branch = val
case "tag":
tag = val
case "source":
source = val
default:
extraVars = append(extraVars, kv)
}
}
return
}
// sortedJobNames returns non-hidden job names ordered by stage position, then alphabetically.
func sortedJobNames(p *model.Pipeline) []string {
stageIdx := make(map[string]int, len(p.Stages))
for i, s := range p.Stages {
stageIdx[s] = i
}
type entry struct {
name string
stage int
}
entries := make([]entry, 0, len(p.Jobs))
for name, job := range p.Jobs {
if strings.HasPrefix(name, ".") {
continue
}
idx, ok := stageIdx[job.Stage]
if !ok {
idx = len(p.Stages)
}
entries = append(entries, entry{name: name, stage: idx})
}
sort.Slice(entries, func(i, j int) bool {
if entries[i].stage != entries[j].stage {
return entries[i].stage < entries[j].stage
}
return entries[i].name < entries[j].name
})
names := make([]string, len(entries))
for i, e := range entries {
names[i] = e.name
}
return names
}
// printContextTable prints a side-by-side comparison of job states across contexts.
func printContextTable(p *model.Pipeline, ctxs []*cicontext.Context, labels []string, runs []bool) {
jobs := sortedJobNames(p)
if len(jobs) == 0 {
return
}
// Evaluate all jobs for all contexts up front so we can compute column widths.
states := make([][]string, len(jobs))
for r, name := range jobs {
states[r] = make([]string, len(ctxs))
for c, ctx := range ctxs {
if !runs[c] {
states[r][c] = "blocked"
} else {
switch cicontext.EvalJob(p.Jobs[name], ctx) {
case cicontext.JobActive:
states[r][c] = "active"
case cicontext.JobManual:
states[r][c] = "manual"
default:
states[r][c] = "skipped"
}
}
}
}
// Compute column widths.
jobCol := len("JOB")
for _, name := range jobs {
if len(name) > jobCol {
jobCol = len(name)
}
}
ctxCols := make([]int, len(labels))
for i, lbl := range labels {
ctxCols[i] = len(lbl)
}
for r := range jobs {
for c, s := range states[r] {
if len(s) > ctxCols[c] {
ctxCols[c] = len(s)
}
}
}
// Print header.
fmt.Println("Context comparison:")
fmt.Println()
fmt.Printf("%-*s", jobCol, "JOB")
for i, lbl := range labels {
fmt.Printf(" %-*s", ctxCols[i], lbl)
}
fmt.Println()
fmt.Print(strings.Repeat("-", jobCol))
for _, w := range ctxCols {
fmt.Print(" " + strings.Repeat("-", w))
}
fmt.Println()
// Print rows.
for r, name := range jobs {
fmt.Printf("%-*s", jobCol, name)
for c, s := range states[r] {
fmt.Printf(" %-*s", ctxCols[c], s)
}
fmt.Println()
}
fmt.Println()
}
+414
View File
@@ -823,6 +823,420 @@ 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) {
cases := []struct {
spec string
branch string
tag string
source string
extraVars []string
}{
{"branch=main", "main", "", "", nil},
{"tag=v1.0.0", "", "v1.0.0", "", nil},
{"source=schedule", "", "", "schedule", nil},
{"branch=main,source=push", "main", "", "push", nil},
{"branch=main,DEPLOY=prod", "main", "", "", []string{"DEPLOY=prod"}},
{"DEPLOY=prod,ENV=staging", "", "", "", []string{"DEPLOY=prod", "ENV=staging"}},
{"branch=main,tag=v1,source=push,X=y", "main", "v1", "push", []string{"X=y"}},
{"branch=main, ,source=push", "main", "", "push", nil}, // spaces and empty segments
{"", "", "", "", nil},
{"noequals", "", "", "", []string{"noequals"}}, // no = → whole token as extraVar
{"BRANCH=develop", "develop", "", "", nil}, // case-insensitive key matching
}
for _, tc := range cases {
t.Run(tc.spec, func(t *testing.T) {
b, tg, s, ev := parseContextSpec(tc.spec)
if b != tc.branch {
t.Errorf("branch: want %q got %q", tc.branch, b)
}
if tg != tc.tag {
t.Errorf("tag: want %q got %q", tc.tag, tg)
}
if s != tc.source {
t.Errorf("source: want %q got %q", tc.source, s)
}
if len(ev) != len(tc.extraVars) {
t.Errorf("extraVars len: want %d got %d (%v)", len(tc.extraVars), len(ev), ev)
return
}
for i := range ev {
if ev[i] != tc.extraVars[i] {
t.Errorf("extraVars[%d]: want %q got %q", i, tc.extraVars[i], ev[i])
}
}
})
}
}
// ── sortedJobNames ────────────────────────────────────────────────────────────
func TestSortedJobNames_Order(t *testing.T) {
p := &model.Pipeline{
Stages: []string{"build", "test", "deploy"},
Jobs: map[string]model.Job{
"deploy-job": {Stage: "deploy"},
"test-b": {Stage: "test"},
"test-a": {Stage: "test"},
"build-job": {Stage: "build"},
".hidden": {Stage: "build"}, // excluded
},
}
got := sortedJobNames(p)
want := []string{"build-job", "test-a", "test-b", "deploy-job"}
if len(got) != len(want) {
t.Fatalf("want %v got %v", want, got)
}
for i := range want {
if got[i] != want[i] {
t.Errorf("[%d]: want %q got %q", i, want[i], got[i])
}
}
}
func TestSortedJobNames_UnknownStage(t *testing.T) {
p := &model.Pipeline{
Stages: []string{"build"},
Jobs: map[string]model.Job{
"build-job": {Stage: "build"},
"orphan-job": {Stage: "nonexistent"},
},
}
got := sortedJobNames(p)
if len(got) != 2 {
t.Fatalf("want 2 jobs, got %v", got)
}
if got[0] != "build-job" {
t.Errorf("expected build-job first, got %q", got[0])
}
if got[1] != "orphan-job" {
t.Errorf("expected orphan-job last, got %q", got[1])
}
}
func TestSortedJobNames_Empty(t *testing.T) {
p := &model.Pipeline{
Stages: []string{"build"},
Jobs: map[string]model.Job{".hidden": {Stage: "build"}},
}
got := sortedJobNames(p)
if len(got) != 0 {
t.Errorf("want empty, got %v", got)
}
}
// ── printContextTable ─────────────────────────────────────────────────────────
func TestPrintContextTable_Empty(t *testing.T) {
// Pipeline with no visible jobs: should return without printing.
p := &model.Pipeline{
Stages: []string{"build"},
Jobs: map[string]model.Job{".hidden": {Stage: "build"}},
}
// No panic expected, no output to verify.
printContextTable(p, nil, nil, nil)
}
func TestPrintContextTable_ActiveSkipped(t *testing.T) {
// Job always active.
content := `
stages: [build, deploy]
build-job:
stage: build
script: make
deploy-job:
stage: deploy
script: make deploy
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
`
path := writePipeline(t, content)
p, err := model.Parse(path)
if err != nil {
t.Fatal(err)
}
ctx1 := cicontext.New("main", "", "push", nil)
ctx2 := cicontext.New("develop", "", "push", nil)
enrichContext(ctx1, p)
enrichContext(ctx2, p)
// Just ensure it doesn't panic; output goes to real stdout in tests.
printContextTable(p, []*cicontext.Context{ctx1, ctx2},
[]string{"branch=main", "branch=develop"}, []bool{true, true})
}
func TestPrintContextTable_Blocked(t *testing.T) {
// A context where workflow:rules: blocks the pipeline.
content := `
stages: [build]
workflow:
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
build-job:
stage: build
script: make
`
path := writePipeline(t, content)
p, err := model.Parse(path)
if err != nil {
t.Fatal(err)
}
ctx1 := cicontext.New("main", "", "push", nil)
ctx2 := cicontext.New("develop", "", "push", nil)
r1 := enrichContext(ctx1, p)
r2 := enrichContext(ctx2, p)
printContextTable(p, []*cicontext.Context{ctx1, ctx2},
[]string{"branch=main", "branch=develop"}, []bool{r1, r2})
}
func TestPrintContextTable_ManualState(t *testing.T) {
content := `
stages: [build]
build-job:
stage: build
script: make
rules:
- when: manual
`
path := writePipeline(t, content)
p, err := model.Parse(path)
if err != nil {
t.Fatal(err)
}
ctx := cicontext.New("main", "", "push", nil)
enrichContext(ctx, p)
printContextTable(p, []*cicontext.Context{ctx}, []string{"branch=main"}, []bool{true})
}
func TestPrintContextTable_ShortLabel(t *testing.T) {
// Short label "x" (1 char) ensures a state string ("skipped", 7 chars) triggers
// the ctxCols[c] = len(s) branch in printContextTable.
content := `
stages: [build]
build-job:
stage: build
script: make
rules:
- if: '$CI_COMMIT_TAG != ""'
when: on_success
- when: never
`
path := writePipeline(t, content)
p, err := model.Parse(path)
if err != nil {
t.Fatal(err)
}
ctx := cicontext.New("main", "", "push", nil)
enrichContext(ctx, p)
// Label "x" is shorter than "skipped" (7 chars), covering ctxCols width-expansion.
printContextTable(p, []*cicontext.Context{ctx}, []string{"x"}, []bool{true})
}
// ── cmdCheck multi-context ───────────────────────────────────────────────────
func TestCmdCheck_MultiContext_Single(t *testing.T) {
code := captureExit(t)
path := writePipeline(t, minimalPipeline)
cmdCheck([]string{"--context", "branch=main", path})
if *code != -1 {
t.Errorf("multi-context single: want no exit, got %d", *code)
}
}
func TestCmdCheck_MultiContext_Multiple(t *testing.T) {
code := captureExit(t)
path := writePipeline(t, minimalPipeline)
cmdCheck([]string{"--context", "branch=main", "--context", "branch=develop", path})
if *code != -1 {
t.Errorf("multi-context multiple: want no exit, got %d", *code)
}
}
func TestCmdCheck_MultiContext_WithExtraVar(t *testing.T) {
code := captureExit(t)
path := writePipeline(t, minimalPipeline)
cmdCheck([]string{"--context", "branch=main,DEPLOY=prod", path})
if *code != -1 {
t.Errorf("multi-context with extra var: want no exit, got %d", *code)
}
}
func TestCmdCheck_MultiContext_ErrorPipeline(t *testing.T) {
code := captureExit(t)
content := `
stages: [build]
test-job:
stage: nonexistent
script: echo
`
path := writePipeline(t, content)
cmdCheck([]string{"--context", "branch=main", path})
if *code != 1 {
t.Errorf("multi-context error pipeline: want exit(1), got %d", *code)
}
}
func TestCmdCheck_MultiContext_SkipsImplicitDefaults(t *testing.T) {
// When --context is given, implicit defaults (branch=main, source=push) must not be set.
// This is a smoke test: the command must complete without panicking.
code := captureExit(t)
path := writePipeline(t, minimalPipeline)
cmdCheck([]string{"--context", "tag=v1.0.0", path})
if *code == 2 {
t.Errorf("multi-context tag: unexpected exit(2)")
}
}
func TestCmdCheck_MultiContext_WithChanges(t *testing.T) {
code := captureExit(t)
content := `
stages: [build]
build-job:
stage: build
script: make
rules:
- changes: [src/**]
when: on_success
`
path := writePipeline(t, content)
cmdCheck([]string{
"--context", "branch=main",
"--changes", "src/app.go",
path,
})
if *code != -1 {
t.Errorf("multi-context+changes: want no exit, got %d", *code)
}
}
func TestCmdCheck_MultiContext_WithChangesFrom(t *testing.T) {
orig := execCommandOutput
execCommandOutput = func(name string, args ...string) ([]byte, error) {
return []byte("src/app.go\n"), nil
}
t.Cleanup(func() { execCommandOutput = orig })
code := captureExit(t)
path := writePipeline(t, minimalPipeline)
cmdCheck([]string{"--context", "branch=main", "--changes-from", "origin/main", path})
if *code != -1 {
t.Errorf("multi-context+changes-from: want no exit, got %d", *code)
}
}
func TestCmdCheck_MultiContext_ChangesFrom_EmptyDiff(t *testing.T) {
orig := execCommandOutput
execCommandOutput = func(name string, args ...string) ([]byte, error) {
return []byte(""), nil
}
t.Cleanup(func() { execCommandOutput = orig })
code := captureExit(t)
path := writePipeline(t, minimalPipeline)
// reliable=true, allChanged nil → allChanged = []string{} guard hit in multi-context path
cmdCheck([]string{"--context", "branch=main", "--changes-from", "origin/main", path})
if *code != -1 {
t.Errorf("multi-context+changes-from empty diff: want no exit, got %d", *code)
}
}
func TestCmdCheck_MultiContext_ChangesFrom_Fails(t *testing.T) {
orig := execCommandOutput
execCommandOutput = func(name string, args ...string) ([]byte, error) {
return nil, errors.New("not a git repository")
}
t.Cleanup(func() { execCommandOutput = orig })
code := captureExit(t)
path := writePipeline(t, minimalPipeline)
// git fails → changesReliable stays false → SetChangedFiles not called
cmdCheck([]string{"--context", "branch=main", "--changes-from", "origin/main", path})
if *code != -1 {
t.Errorf("multi-context+changes-from fail: want no exit, got %d", *code)
}
}
func TestCmdCheck_MultiContext_FormatJSON(t *testing.T) {
// --context + non-text format: printContextTable should NOT be called.
code := captureExit(t)
path := writePipeline(t, minimalPipeline)
cmdCheck([]string{"--context", "branch=main", "--format", "json", path})
if *code != -1 {
t.Errorf("multi-context json: want no exit, got %d", *code)
}
}
// ── isSuppressed ───────────────────────────────────────────────────────────── // ── isSuppressed ─────────────────────────────────────────────────────────────
func TestIsSuppressed(t *testing.T) { func TestIsSuppressed(t *testing.T) {
+4 -1
View File
@@ -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
} }
+37
View File
@@ -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]`,
},
} }
+25 -9
View File
@@ -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 ────────────────────────────────────────────────────────
+6 -3
View File
@@ -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)...)
+42 -1
View File
@@ -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.
+149 -1
View File
@@ -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 {
+4
View File
@@ -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"
) )
+2 -2
View File
@@ -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)
+1
View File
@@ -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.
+15
View File
@@ -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
+19
View File
@@ -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