1 Commits

Author SHA1 Message Date
k3nny 5fee51ec7d fix(cli): consistent output format, sorted findings, version flag
ci / vet, staticcheck, test, build (push) Successful in 2m9s
release / Build and publish release (push) Successful in 1m13s
- Workflow rules now use strict if: evaluation (parse failure → skip rule,
  not match); fixes premature matching that blocked later rules and injected
  wrong variables into the context
- Single = accepted as alias for == in rules:if: expressions
- File/Line preserved through extends: resolution (lost during YAML
  encode/decode round-trip in the resolver)
- Findings sorted by (File, Line, Rule) so same-file issues group together
- All warnings use ruff-style path: [warning] message format (includes,
  extends chains, workflow non-start)
- Add --version / -v flag; version shown at top of every --help output
- Build injects version via ldflags using git describe

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 00:13:51 +02:00
11 changed files with 173 additions and 24 deletions
+18
View File
@@ -5,6 +5,24 @@ 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.14] - 2026-06-13
### Added
- **`--version` / `-v` flag** — `glint --version`, `glint -v`, and `glint version` all print the compiled version string (e.g. `glint v0.2.14`). The version is also shown at the top of every `--help` output (global, `check --help`, `graph --help`). The version is injected at build time via `-ldflags "-X main.version=..."` using `git describe --tags --always --dirty`.
- **Sorted findings output** — `Lint` now returns findings sorted by `(File, Line, Rule)`. All issues from the same source file appear together in ascending line order; pipeline-level findings with no file location sort first. Previously findings were emitted in map-iteration order (non-deterministic).
### Fixed
- **Warning format consistency** — include-resolution warnings, extends-chain warnings, and the workflow non-start warning now use the same ruff-style `path: [warning] message` format as lint findings instead of the old `[WARNING] …` prefix with no file context.
- **Workflow rule permissive evaluation** — workflow `rules:if:` expressions are now evaluated in strict mode: an expression that cannot be fully parsed returns `false` (skip this rule, try the next) instead of `true` (match everything). Previously, a complex or partially-unsupported condition on the first workflow rule would match every context, blocking all subsequent rules and injecting the wrong variables. Job rules retain permissive evaluation (`true` on parse failure) to avoid silently dropping jobs.
- **Single `=` operator in `rules:if:`** — a bare `=` not followed by `=` or `~` is now accepted as an alias for `==`. This is a common mistake in GitLab CI YAML; previously it caused a parse failure and triggered the permissive fallback.
- **Source location lost through `extends:` resolution** — when a job was resolved via `extends:`, the merged definition was re-encoded and re-decoded as a fresh `model.Job` struct, which does not carry `File` or `Line` (they are not YAML keys). Those fields are now explicitly copied back from the original job before replacing it in `p.Jobs`, so extended jobs report the correct source file and line number in findings.
## [0.2.13] - 2026-06-12 ## [0.2.13] - 2026-06-12
### Added ### Added
+4 -1
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.13-blue.svg" alt="Release"></a> <a href="CHANGELOG.md"><img src="https://img.shields.io/badge/release-v0.2.14-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.
@@ -30,6 +30,9 @@ A local tool to validate and lint `.gitlab-ci.yml` pipelines without needing a G
- **Context simulation** — pass `--branch`, `--tag`, or `--source` to `glint check` or `glint graph` to see which jobs would be active, manual, or skipped for a specific pipeline event; evaluates `rules:if:` expressions and `only`/`except` filters - **Context simulation** — pass `--branch`, `--tag`, or `--source` to `glint check` or `glint graph` to see which jobs would be active, manual, or skipped for a specific pipeline event; evaluates `rules:if:` expressions and `only`/`except` filters
- **Variable expansion** — `$VAR` and `${VAR}` references inside variable values are expanded after all sources are merged (pipeline defaults → workflow-rule overrides → CLI flags); transitive chains resolve automatically; visible via `--list-vars` - **Variable expansion** — `$VAR` and `${VAR}` references inside variable values are expanded after all sources are merged (pipeline defaults → workflow-rule overrides → CLI flags); transitive chains resolve automatically; visible via `--list-vars`
- **Non-string variable scalars** — `BUILD: true`, `RETRIES: 3` and other bare boolean/integer variable values are handled correctly throughout: they render in `--list-vars` output and are injected into the evaluation context as their string equivalents, matching GitLab CI's behaviour - **Non-string variable scalars** — `BUILD: true`, `RETRIES: 3` and other bare boolean/integer variable values are handled correctly throughout: they render in `--list-vars` output and are injected into the evaluation context as their string equivalents, matching GitLab CI's behaviour
- **Sorted findings output** — findings are sorted by source file then line number, so all issues from the same file appear together in order; pipeline-level findings (no file) sort first
- **Consistent ruff-style warnings** — all warnings (unresolvable includes, skipped extends chains, workflow non-start) use the same `path: [warning] message` format as lint findings
- **`--version` / `-v` flag** — prints the compiled version string (e.g. `glint v0.2.14`); the version is also shown at the top of every `--help` output
See [ROADMAP.md](ROADMAP.md) for planned improvements. See [ROADMAP.md](ROADMAP.md) for planned improvements.
+10 -1
View File
@@ -4,7 +4,7 @@ This document tracks planned improvements to `glint`. Items are grouped by theme
--- ---
## Context-aware validation — ✓ single-context shipped in v0.2.0; expression evaluator hardened post-v0.2.0; implicit defaults and --list-vars shipped v0.2.11; variable expansion and scalar handling shipped v0.2.13 ## Context-aware validation — ✓ single-context shipped in v0.2.0; expression evaluator hardened post-v0.2.0; implicit defaults and --list-vars shipped v0.2.11; variable expansion and scalar handling shipped v0.2.13; workflow evaluation and output fixes shipped v0.2.14
Single-context simulation is fully implemented. Pass `--branch`, `--tag`, `--source`, or `--var` to either `glint check` or `glint graph`; jobs are evaluated and shown as active / manual / skipped. Single-context simulation is fully implemented. Pass `--branch`, `--tag`, `--source`, or `--var` to either `glint check` or `glint graph`; jobs are evaluated and shown as active / manual / skipped.
@@ -36,6 +36,15 @@ glint graph tree --branch main .gitlab-ci.yml # tree annotated with [skipped]
-**Non-string scalar variables**`BUILD: true`, `RETRIES: 3` and similar bare boolean/integer values now render correctly in `--list-vars` and are injected into the evaluation context as string equivalents; previously shown as `(complex)` and silently dropped. -**Non-string scalar variables**`BUILD: true`, `RETRIES: 3` and similar bare boolean/integer values now render correctly in `--list-vars` and are injected into the evaluation context as string equivalents; previously shown as `(complex)` and silently dropped.
-**YAML `\/` escape in double-quoted strings** — regex patterns like `/^us\//` in double-quoted `if:` blocks no longer cause a parse error; the raw bytes are preprocessed before YAML unmarshalling. -**YAML `\/` escape in double-quoted strings** — regex patterns like `/^us\//` in double-quoted `if:` blocks no longer cause a parse error; the raw bytes are preprocessed before YAML unmarshalling.
**Shipped in v0.2.14**
-**Workflow rule strict evaluation** — workflow `rules:if:` now uses strict mode (parse failure → skip rule, not match); fixes premature matching that blocked later rules and injected wrong variables.
-**Single `=` operator**`=` is now accepted as an alias for `==` in `rules:if:` expressions, matching common user intent.
-**Source location through `extends:` resolution**`File` and `Line` are now preserved when a job is rebuilt via extends, so findings reference the correct source location.
-**Sorted findings output** — findings are sorted by `(File, Line, Rule)`; same-file issues group together in line order.
-**Consistent warning format** — all warnings use ruff-style `path: [warning] message` format.
-**`--version` / `-v` flag** — prints compiled version; version also shown at the top of every `--help` output.
**Remaining work** **Remaining work**
- **Multi-context simulation** — run multiple contexts in one invocation and print a comparison table: - **Multi-context simulation** — run multiple contexts in one invocation and print a comparison table:
+5 -3
View File
@@ -3,6 +3,8 @@ version: "3"
vars: vars:
BINARY: glint BINARY: glint
GO: /usr/local/go/bin/go GO: /usr/local/go/bin/go
VERSION:
sh: git describe --tags --always --dirty 2>/dev/null || echo "dev"
tasks: tasks:
default: default:
@@ -12,7 +14,7 @@ tasks:
build: build:
desc: Build the glint binary desc: Build the glint binary
cmds: cmds:
- "{{.GO}} build -o {{.BINARY}} ./cmd/glint/..." - "{{.GO}} build -ldflags \"-X main.version={{.VERSION}}\" -o {{.BINARY}} ./cmd/glint/..."
sources: sources:
- "**/*.go" - "**/*.go"
- go.mod - go.mod
@@ -112,7 +114,7 @@ tasks:
- sh: git describe --tags --exact-match - sh: git describe --tags --exact-match
msg: "Current commit is not tagged — Windows build requires a git tag" msg: "Current commit is not tagged — Windows build requires a git tag"
cmds: cmds:
- "GOOS=windows GOARCH=amd64 {{.GO}} build -o {{.BINARY}}-{{.TAG}}.exe ./cmd/glint/..." - "GOOS=windows GOARCH=amd64 {{.GO}} build -ldflags \"-X main.version={{.TAG}}\" -o {{.BINARY}}-{{.TAG}}.exe ./cmd/glint/..."
sources: sources:
- "**/*.go" - "**/*.go"
- go.mod - go.mod
@@ -128,7 +130,7 @@ tasks:
- sh: git describe --tags --exact-match - sh: git describe --tags --exact-match
msg: "Current commit is not tagged — Linux build requires a git tag" msg: "Current commit is not tagged — Linux build requires a git tag"
cmds: cmds:
- "GOOS=linux GOARCH=amd64 {{.GO}} build -o {{.BINARY}}-{{.TAG}}-linux-amd64 ./cmd/glint/..." - "GOOS=linux GOARCH=amd64 {{.GO}} build -ldflags \"-X main.version={{.TAG}}\" -o {{.BINARY}}-{{.TAG}}-linux-amd64 ./cmd/glint/..."
sources: sources:
- "**/*.go" - "**/*.go"
- go.mod - go.mod
+17 -7
View File
@@ -16,6 +16,9 @@ import (
"git.k3nny.fr/glint/internal/resolver" "git.k3nny.fr/glint/internal/resolver"
) )
// version is set at build time via -ldflags "-X main.version=vX.Y.Z".
var version = "dev"
const globalUsage = `glint: Lint and visualise GitLab CI pipelines locally. const globalUsage = `glint: Lint and visualise GitLab CI pipelines locally.
Usage: glint [OPTIONS] <COMMAND> Usage: glint [OPTIONS] <COMMAND>
@@ -26,6 +29,7 @@ Commands:
Options: Options:
-h, --help Print help -h, --help Print help
-v, --version Print version
For help with a specific command, see: ` + "`glint <command> --help`" + `. For help with a specific command, see: ` + "`glint <command> --help`" + `.
` `
@@ -41,7 +45,10 @@ func main() {
case "graph": case "graph":
cmdGraph(os.Args[2:]) cmdGraph(os.Args[2:])
case "-h", "--help", "help": case "-h", "--help", "help":
fmt.Fprintf(os.Stderr, "glint %s\n\n", version)
fmt.Fprint(os.Stderr, globalUsage) fmt.Fprint(os.Stderr, globalUsage)
case "-v", "--version", "version":
fmt.Printf("glint %s\n", version)
default: default:
fmt.Fprintf(os.Stderr, "glint: unknown command %q\n\n%s", os.Args[1], globalUsage) fmt.Fprintf(os.Stderr, "glint: unknown command %q\n\n%s", os.Args[1], globalUsage)
os.Exit(2) os.Exit(2)
@@ -68,6 +75,7 @@ func cmdCheck(args []string) {
var vars multiFlag var vars multiFlag
fs.Var(&vars, "var", "set a CI variable as KEY=VALUE; repeatable") fs.Var(&vars, "var", "set a CI variable as KEY=VALUE; repeatable")
fs.Usage = func() { fs.Usage = func() {
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.
Resolves local includes and extends chains, then runs all lint rules. Resolves local includes and extends chains, then runs all lint rules.
@@ -153,7 +161,7 @@ Examples:
rootDir := filepath.Dir(filepath.Clean(path)) rootDir := filepath.Dir(filepath.Clean(path))
warnings, _ := resolver.ResolveIncludes(p, cfg, rootDir) warnings, _ := resolver.ResolveIncludes(p, cfg, rootDir)
for _, w := range warnings { for _, w := range warnings {
fmt.Fprintf(os.Stderr, "[WARNING] include %s\n", w) fmt.Fprintf(os.Stderr, "%s: [warning] include %s\n", path, w)
} }
extWarnings, err := resolver.Resolve(p) extWarnings, err := resolver.Resolve(p)
@@ -162,12 +170,14 @@ Examples:
os.Exit(2) os.Exit(2)
} }
for _, w := range extWarnings { for _, w := range extWarnings {
fmt.Fprintf(os.Stderr, "[WARNING] job %q extends unknown job %q; extends chain skipped\n", 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) ctx := cicontext.New(*branch, *tag, *source, vars)
if !ctx.IsEmpty() { if !ctx.IsEmpty() {
enrichContext(ctx, p) if !enrichContext(ctx, p) {
fmt.Fprintf(os.Stderr, "%s: [warning] workflow:rules: pipeline would not start for this context\n", path)
}
} }
if *listVars { if *listVars {
printVars(p, ctx) printVars(p, ctx)
@@ -219,6 +229,7 @@ func cmdGraph(args []string) {
gitlabURL := fs.String("gitlab-url", "", "GitLab instance URL (overrides CI_SERVER_URL / GITLAB_URL)") gitlabURL := fs.String("gitlab-url", "", "GitLab instance URL (overrides CI_SERVER_URL / GITLAB_URL)")
out := fs.String("out", "glint-out", "output directory for Mermaid graph files (pipeline mode)") out := fs.String("out", "glint-out", "output directory for Mermaid graph files (pipeline mode)")
fs.Usage = func() { fs.Usage = func() {
fmt.Fprintf(os.Stderr, "glint %s\n\n", version)
fmt.Fprint(os.Stderr, `Visualise the pipeline as a job tree and/or Mermaid graph. fmt.Fprint(os.Stderr, `Visualise the pipeline as a job tree and/or Mermaid graph.
Usage: glint graph [MODE] [OPTIONS] <PIPELINE> Usage: glint graph [MODE] [OPTIONS] <PIPELINE>
@@ -413,22 +424,21 @@ func varValueString(v any) string {
// enrichContext injects pipeline-level variable defaults and then // enrichContext injects pipeline-level variable defaults and then
// workflow-rule-generated variables into ctx before job evaluation. // workflow-rule-generated variables into ctx before job evaluation.
// Injection respects pinned variables (--branch/--tag/--source/--var always win). // Injection respects pinned variables (--branch/--tag/--source/--var always win).
func enrichContext(ctx *cicontext.Context, p *model.Pipeline) { // Returns false when workflow:rules: would prevent the pipeline from starting.
func enrichContext(ctx *cicontext.Context, p *model.Pipeline) bool {
// Pipeline variables: injected as defaults (lowest priority). // Pipeline variables: injected as defaults (lowest priority).
for k, v := range cicontext.ExtractStringVars(p.Variables) { for k, v := range cicontext.ExtractStringVars(p.Variables) {
ctx.Inject(k, v) ctx.Inject(k, v)
} }
// Workflow rules: evaluate to find which rule matches, then inject its variables. // Workflow rules: evaluate to find which rule matches, then inject its variables.
runs, ruleVars := cicontext.EvalWorkflow(p, ctx) runs, ruleVars := cicontext.EvalWorkflow(p, ctx)
if !runs {
fmt.Fprintln(os.Stderr, "[WARNING] workflow:rules: pipeline would not start for this context")
}
for k, v := range ruleVars { for k, v := range ruleVars {
ctx.Inject(k, v) ctx.Inject(k, v)
} }
// Expand $VAR / ${VAR} references within variable values now that all // Expand $VAR / ${VAR} references within variable values now that all
// sources (pipeline, workflow rules, CLI) have been merged. // sources (pipeline, workflow rules, CLI) have been merged.
ctx.ExpandVars() ctx.ExpandVars()
return runs
} }
func printContext(p *model.Pipeline, ctx *cicontext.Context) { func printContext(p *model.Pipeline, ctx *cicontext.Context) {
+24 -2
View File
@@ -12,7 +12,7 @@ import (
// - Variable references: $VAR_NAME or ${VAR_NAME} // - Variable references: $VAR_NAME or ${VAR_NAME}
// - String literals: "value" or 'value' // - String literals: "value" or 'value'
// - Null keyword: null // - Null keyword: null
// - Comparison: == != =~ !~ // - Comparison: == != =~ !~ (single = is accepted as == for user convenience)
// - Boolean: && || ! // - Boolean: && || !
// - Grouping: ( ) // - Grouping: ( )
// - Regex flags: /pattern/i (case-insensitive), /pattern/m, /pattern/s // - Regex flags: /pattern/i (case-insensitive), /pattern/m, /pattern/s
@@ -23,10 +23,21 @@ import (
// used by GitLab CI. Unsupported or unparseable expressions fall back to true // used by GitLab CI. Unsupported or unparseable expressions fall back to true
// (permissive) so the linter never silently drops jobs it cannot evaluate. // (permissive) so the linter never silently drops jobs it cannot evaluate.
func EvalIf(expr string, vars func(string) string) bool { func EvalIf(expr string, vars func(string) string) bool {
return evalIf(expr, vars, true)
}
// EvalIfStrict is like EvalIf but returns false (instead of true) when the
// expression cannot be fully parsed. Use for workflow:rules: evaluation where
// a failed parse should skip to the next rule rather than matching everything.
func EvalIfStrict(expr string, vars func(string) string) bool {
return evalIf(expr, vars, false)
}
func evalIf(expr string, vars func(string) string, permissive bool) bool {
p := &exprParser{s: strings.TrimSpace(expr), vars: vars} p := &exprParser{s: strings.TrimSpace(expr), vars: vars}
result, ok := p.parseOr() result, ok := p.parseOr()
if !ok || p.pos < len(p.s) { if !ok || p.pos < len(p.s) {
return true // unparseable → permissive return permissive
} }
return result return result
} }
@@ -200,6 +211,17 @@ func (p *exprParser) parseComparison() (bool, bool) {
return true, true // bad pattern → permissive return true, true // bad pattern → permissive
} }
return !re.MatchString(leftStr), true return !re.MatchString(leftStr), true
// Single = not followed by = or ~ — accepted as == (common user mistake;
// GitLab CI only supports == but = is frequently written by accident).
case p.peek() == '=' && !p.startsWith("==") && !p.startsWith("=~"):
p.pos++ // consume '='
p.skipWS()
rightStr, ok := p.parseValue()
if !ok {
return false, false
}
return leftStr == rightStr, true
} }
// No operator: variable is truthy when non-empty (defined and non-null). // No operator: variable is truthy when non-empty (defined and non-null).
+51
View File
@@ -120,6 +120,12 @@ func TestEvalIf(t *testing.T) {
// ── Permissive fallback ─────────────────────────────────────────────── // ── Permissive fallback ───────────────────────────────────────────────
{"unparseable returns true", `this is not valid syntax %%%`, true}, {"unparseable returns true", `this is not valid syntax %%%`, true},
{"empty expr returns true", ``, true}, {"empty expr returns true", ``, true},
// ── Single = as alias for == ──────────────────────────────────────────
{"single eq match", `$CI_COMMIT_BRANCH = "develop"`, true},
{"single eq no match", `$CI_COMMIT_BRANCH = "main"`, false},
{"single eq in compound", `$CI_COMMIT_BRANCH = "develop" && $CI_PIPELINE_SOURCE = "push"`, true},
{"single eq compound false", `$CI_COMMIT_BRANCH = "main" && $CI_PIPELINE_SOURCE = "push"`, false},
} }
for _, tc := range tests { for _, tc := range tests {
@@ -131,3 +137,48 @@ func TestEvalIf(t *testing.T) {
}) })
} }
} }
func TestEvalIfStrict(t *testing.T) {
vars := func(key string) string {
m := map[string]string{
"CI_COMMIT_BRANCH": "develop",
"CI_PIPELINE_SOURCE": "push",
"WORKFLOW": "",
}
return m[key]
}
tests := []struct {
name string
expr string
want bool
}{
// Parseable expressions behave identically to EvalIf.
{"parseable match", `$CI_COMMIT_BRANCH == "develop"`, true},
{"parseable no match", `$CI_COMMIT_BRANCH == "main"`, false},
{"single eq match", `$CI_COMMIT_BRANCH = "develop"`, true},
// Empty expression: ruleIfMatchesStrict handles the empty→true case
// before calling EvalIfStrict, so empty falls through to false here.
{"empty expr", ``, false},
// Unparseable expressions return false (strict) instead of true (permissive).
{"unparseable returns false", `this is not valid syntax %%%`, false},
// The key workflow-rule scenario: a complex condition with an
// unevaluable sub-expression should not match (strict=false) so that
// later workflow rules can be evaluated.
{"workflow rule complex no match", `$WORKFLOW = "gitflow" && $CI_PIPELINE_SOURCE == /(push|web)/`, false},
// Compound with a bad second operand: strict returns false.
{"and with bad rhs strict false", `$CI_COMMIT_BRANCH == "develop" && !(((`, false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := EvalIfStrict(tc.expr, vars)
if got != tc.want {
t.Errorf("EvalIfStrict(%q) = %v, want %v", tc.expr, got, tc.want)
}
})
}
}
+12 -1
View File
@@ -42,7 +42,11 @@ func EvalWorkflow(p *model.Pipeline, ctx *Context) (bool, map[string]string) {
} }
vars := ctx.Get vars := ctx.Get
for _, rule := range p.Workflow.Rules { for _, rule := range p.Workflow.Rules {
if !ruleIfMatches(rule.If, vars) { // Workflow rules use strict evaluation: an unparseable condition is
// treated as no-match so later rules (with valid conditions or a
// bare when:) are reached. Permissive-true would cause an early rule
// with a complex/invalid condition to block all subsequent rules.
if !ruleIfMatchesStrict(rule.If, vars) {
continue continue
} }
when := rule.When when := rule.When
@@ -94,6 +98,13 @@ func ruleIfMatches(ifExpr string, vars func(string) string) bool {
return EvalIf(ifExpr, vars) return EvalIf(ifExpr, vars)
} }
func ruleIfMatchesStrict(ifExpr string, vars func(string) string) bool {
if ifExpr == "" {
return true // no if: condition → rule always matches
}
return EvalIfStrict(ifExpr, vars)
}
func whenToState(when string) JobState { func whenToState(when string) JobState {
switch when { switch when {
case "never": case "never":
+13 -1
View File
@@ -1,7 +1,9 @@
package linter package linter
import ( import (
"cmp"
"fmt" "fmt"
"slices"
"strings" "strings"
"git.k3nny.fr/glint/internal/model" "git.k3nny.fr/glint/internal/model"
@@ -48,7 +50,8 @@ func (f Finding) String() string {
return fmt.Sprintf("%s%s%s %s", loc, rule, sev, msg) return fmt.Sprintf("%s%s%s %s", loc, rule, sev, msg)
} }
// Lint runs all rules against p and returns findings sorted by job name. // 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 { func Lint(p *model.Pipeline) []Finding {
var findings []Finding var findings []Finding
findings = append(findings, checkStages(p)...) findings = append(findings, checkStages(p)...)
@@ -57,6 +60,15 @@ func Lint(p *model.Pipeline) []Finding {
findings = append(findings, checkNeeds(p)...) findings = append(findings, checkNeeds(p)...)
findings = append(findings, checkDependencies(p)...) findings = append(findings, checkDependencies(p)...)
findings = append(findings, checkVariableRefs(p)...) findings = append(findings, checkVariableRefs(p)...)
slices.SortStableFunc(findings, func(a, b Finding) int {
if c := cmp.Compare(a.File, b.File); c != 0 {
return c
}
if c := cmp.Compare(a.Line, b.Line); c != 0 {
return c
}
return cmp.Compare(a.Rule, b.Rule)
})
return findings return findings
} }
+5
View File
@@ -80,6 +80,11 @@ func Resolve(p *model.Pipeline) ([]ExtendWarning, error) {
return extWarnings, fmt.Errorf("job %q: re-decoding merged definition: %w", name, err) return extWarnings, fmt.Errorf("job %q: re-decoding merged definition: %w", name, err)
} }
j.Name = name j.Name = name
// Preserve source location — File/Line are not part of the YAML map
// and are lost during the encode/decode round-trip.
orig := p.Jobs[name]
j.File = orig.File
j.Line = orig.Line
p.Jobs[name] = j p.Jobs[name] = j
} }
+12 -6
View File
@@ -4,10 +4,10 @@
# The matching workflow rule sets DEPLOY_TARGET; job rules use it. # The matching workflow rule sets DEPLOY_TARGET; job rules use it.
# #
# Expected behaviour per context: # Expected behaviour per context:
# (no context) → all jobs active (no context evaluation) # --branch main → only build active (no workflow rule matches; WORKFLOW var not set)
# --branch main → deploy-prod active, deploy-staging skipped # --branch develop → deploy-staging active, deploy-prod skipped
# --branch develop → deploy-prod skipped, deploy-staging active # --branch feat/x → only build active (when: always fallback, no deploy vars)
# --branch feat/x → deploy-prod skipped, deploy-staging skipped (manual) # --var WORKFLOW=gitflow --branch us/feature → deploy-prod + build active
stages: stages:
- build - build
@@ -20,9 +20,15 @@ variables:
workflow: workflow:
rules: rules:
- if: '$CI_COMMIT_BRANCH == "main"' - if: "
$WORKFLOW = 'gitflow' &&
$CI_PIPELINE_SOURCE == /(push|web)/ &&
$CI_COMMIT_BRANCH =~ /^us\//
"
variables: variables:
DEPLOY_TARGET: production DEPLOY_TARGET: production
DEPLOY: true
BUILD: true
- if: '$CI_COMMIT_BRANCH == "develop"' - if: '$CI_COMMIT_BRANCH == "develop"'
variables: variables:
DEPLOY_TARGET: staging DEPLOY_TARGET: staging
@@ -38,7 +44,7 @@ deploy-prod:
stage: deploy stage: deploy
script: make deploy ENV=production script: make deploy ENV=production
rules: rules:
- if: '$DEPLOY_TARGET == "production"' - if: '$DEPLOY_TARGET == "production" && $DEPLOY == "true"'
when: on_success when: on_success
- when: never - when: never