diff --git a/CHANGELOG.md b/CHANGELOG.md index a0fa733..0fec911 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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/). 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 ### Added diff --git a/README.md b/README.md index c33da7a..064d588 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@

License - Release + Release

> **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 - **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 +- **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. diff --git a/ROADMAP.md b/ROADMAP.md index 684f540..45e786a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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. @@ -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. - ✓ **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** - **Multi-context simulation** — run multiple contexts in one invocation and print a comparison table: diff --git a/Taskfile.yml b/Taskfile.yml index 620737c..9c84f96 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -3,6 +3,8 @@ version: "3" vars: BINARY: glint GO: /usr/local/go/bin/go + VERSION: + sh: git describe --tags --always --dirty 2>/dev/null || echo "dev" tasks: default: @@ -12,7 +14,7 @@ tasks: build: desc: Build the glint binary cmds: - - "{{.GO}} build -o {{.BINARY}} ./cmd/glint/..." + - "{{.GO}} build -ldflags \"-X main.version={{.VERSION}}\" -o {{.BINARY}} ./cmd/glint/..." sources: - "**/*.go" - go.mod @@ -112,7 +114,7 @@ tasks: - sh: git describe --tags --exact-match msg: "Current commit is not tagged — Windows build requires a git tag" 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: - "**/*.go" - go.mod @@ -128,7 +130,7 @@ tasks: - sh: git describe --tags --exact-match msg: "Current commit is not tagged — Linux build requires a git tag" 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: - "**/*.go" - go.mod diff --git a/cmd/glint/main.go b/cmd/glint/main.go index 9f1538f..efe0a44 100644 --- a/cmd/glint/main.go +++ b/cmd/glint/main.go @@ -16,6 +16,9 @@ import ( "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. Usage: glint [OPTIONS] @@ -25,7 +28,8 @@ Commands: graph Visualise the pipeline as a job tree or Mermaid graph Options: - -h, --help Print help + -h, --help Print help + -v, --version Print version For help with a specific command, see: ` + "`glint --help`" + `. ` @@ -41,7 +45,10 @@ func main() { case "graph": cmdGraph(os.Args[2:]) case "-h", "--help", "help": + fmt.Fprintf(os.Stderr, "glint %s\n\n", version) fmt.Fprint(os.Stderr, globalUsage) + case "-v", "--version", "version": + fmt.Printf("glint %s\n", version) default: fmt.Fprintf(os.Stderr, "glint: unknown command %q\n\n%s", os.Args[1], globalUsage) os.Exit(2) @@ -68,6 +75,7 @@ func cmdCheck(args []string) { var vars multiFlag fs.Var(&vars, "var", "set a CI variable as KEY=VALUE; repeatable") fs.Usage = func() { + fmt.Fprintf(os.Stderr, "glint %s\n\n", version) fmt.Fprint(os.Stderr, `Lint a GitLab CI pipeline file. Resolves local includes and extends chains, then runs all lint rules. @@ -153,7 +161,7 @@ Examples: rootDir := filepath.Dir(filepath.Clean(path)) warnings, _ := resolver.ResolveIncludes(p, cfg, rootDir) 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) @@ -162,12 +170,14 @@ Examples: os.Exit(2) } 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) 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 { 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)") out := fs.String("out", "glint-out", "output directory for Mermaid graph files (pipeline mode)") 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. Usage: glint graph [MODE] [OPTIONS] @@ -413,22 +424,21 @@ func varValueString(v any) string { // enrichContext injects pipeline-level variable defaults and then // workflow-rule-generated variables into ctx before job evaluation. // 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). for k, v := range cicontext.ExtractStringVars(p.Variables) { ctx.Inject(k, v) } // Workflow rules: evaluate to find which rule matches, then inject its variables. 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 { ctx.Inject(k, v) } // Expand $VAR / ${VAR} references within variable values now that all // sources (pipeline, workflow rules, CLI) have been merged. ctx.ExpandVars() + return runs } func printContext(p *model.Pipeline, ctx *cicontext.Context) { diff --git a/internal/cicontext/eval.go b/internal/cicontext/eval.go index 5281a66..8cd24b2 100644 --- a/internal/cicontext/eval.go +++ b/internal/cicontext/eval.go @@ -12,7 +12,7 @@ import ( // - Variable references: $VAR_NAME or ${VAR_NAME} // - String literals: "value" or 'value' // - Null keyword: null -// - Comparison: == != =~ !~ +// - Comparison: == != =~ !~ (single = is accepted as == for user convenience) // - Boolean: && || ! // - Grouping: ( ) // - 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 // (permissive) so the linter never silently drops jobs it cannot evaluate. 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} result, ok := p.parseOr() if !ok || p.pos < len(p.s) { - return true // unparseable → permissive + return permissive } return result } @@ -200,6 +211,17 @@ func (p *exprParser) parseComparison() (bool, bool) { return true, true // bad pattern → permissive } 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). diff --git a/internal/cicontext/eval_test.go b/internal/cicontext/eval_test.go index b02b05f..58c181d 100644 --- a/internal/cicontext/eval_test.go +++ b/internal/cicontext/eval_test.go @@ -120,6 +120,12 @@ func TestEvalIf(t *testing.T) { // ── Permissive fallback ─────────────────────────────────────────────── {"unparseable returns true", `this is not valid syntax %%%`, 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 { @@ -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) + } + }) + } +} diff --git a/internal/cicontext/reachability.go b/internal/cicontext/reachability.go index f4346fc..aafa0ef 100644 --- a/internal/cicontext/reachability.go +++ b/internal/cicontext/reachability.go @@ -42,7 +42,11 @@ func EvalWorkflow(p *model.Pipeline, ctx *Context) (bool, map[string]string) { } vars := ctx.Get 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 } when := rule.When @@ -94,6 +98,13 @@ func ruleIfMatches(ifExpr string, vars func(string) string) bool { 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 { switch when { case "never": diff --git a/internal/linter/linter.go b/internal/linter/linter.go index 81f7c1d..36d4de1 100644 --- a/internal/linter/linter.go +++ b/internal/linter/linter.go @@ -1,7 +1,9 @@ package linter import ( + "cmp" "fmt" + "slices" "strings" "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) } -// 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 { var findings []Finding findings = append(findings, checkStages(p)...) @@ -57,6 +60,15 @@ func Lint(p *model.Pipeline) []Finding { findings = append(findings, checkNeeds(p)...) findings = append(findings, checkDependencies(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 } diff --git a/internal/resolver/extends.go b/internal/resolver/extends.go index 7e3b638..cf869cc 100644 --- a/internal/resolver/extends.go +++ b/internal/resolver/extends.go @@ -80,6 +80,11 @@ func Resolve(p *model.Pipeline) ([]ExtendWarning, error) { return extWarnings, fmt.Errorf("job %q: re-decoding merged definition: %w", name, err) } 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 } diff --git a/testdata/workflow_vars.yml b/testdata/workflow_vars.yml index 064126c..36faef2 100644 --- a/testdata/workflow_vars.yml +++ b/testdata/workflow_vars.yml @@ -4,10 +4,10 @@ # The matching workflow rule sets DEPLOY_TARGET; job rules use it. # # Expected behaviour per context: -# (no context) → all jobs active (no context evaluation) -# --branch main → deploy-prod active, deploy-staging skipped -# --branch develop → deploy-prod skipped, deploy-staging active -# --branch feat/x → deploy-prod skipped, deploy-staging skipped (manual) +# --branch main → only build active (no workflow rule matches; WORKFLOW var not set) +# --branch develop → deploy-staging active, deploy-prod skipped +# --branch feat/x → only build active (when: always fallback, no deploy vars) +# --var WORKFLOW=gitflow --branch us/feature → deploy-prod + build active stages: - build @@ -20,9 +20,15 @@ variables: workflow: rules: - - if: '$CI_COMMIT_BRANCH == "main"' + - if: " + $WORKFLOW = 'gitflow' && + $CI_PIPELINE_SOURCE == /(push|web)/ && + $CI_COMMIT_BRANCH =~ /^us\// + " variables: DEPLOY_TARGET: production + DEPLOY: true + BUILD: true - if: '$CI_COMMIT_BRANCH == "develop"' variables: DEPLOY_TARGET: staging @@ -38,7 +44,7 @@ deploy-prod: stage: deploy script: make deploy ENV=production rules: - - if: '$DEPLOY_TARGET == "production"' + - if: '$DEPLOY_TARGET == "production" && $DEPLOY == "true"' when: on_success - when: never @@ -46,6 +52,6 @@ deploy-staging: stage: deploy script: make deploy ENV=staging rules: - - if: '$DEPLOY_TARGET == "staging"' + - if: '$DEPLOY_TARGET == "staging" ' when: on_success - when: never