diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e6a1ad..1017253 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,15 +5,21 @@ 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). -## [Unreleased] +## [0.2.11] - 2026-06-12 ### Added -- **Variable reference validation (GL032)** — glint now warns when a `rules:if:` expression references a variable (`$VAR` or `${VAR}`) that is not declared anywhere in the pipeline YAML: pipeline-level `variables:`, the job's own `variables:`, or any `workflow:rules:variables:` block. Predefined GitLab CI variable namespaces (`CI_*`, `GITLAB_*`, `FF_*`, `RUNNER_*`, `TRIGGER_*`, `CHAT_*`) are exempt. Because variables can also be set in GitLab CI/CD project settings (invisible to glint), the finding is a `[WARNING]` rather than an error. Each undeclared variable is reported at most once per job to keep the output concise. +- **Ruff-style finding output** — findings now follow the `file:line: RULEID [severity] message` format (e.g. `.gitlab-ci.yml:14: GL004 [error] job "deploy": stage "production" is not defined in 'stages'`), matching the output convention used by [ruff](https://docs.astral.sh/ruff/) and other modern linters. Job-scoped findings prefix the message with `job "name": `; pipeline-level findings omit the job prefix. Severity is lowercase inside brackets (`[error]`, `[warning]`). + +- **Implicit default context (`--branch main --source push`)** — when `glint check` or `glint graph` is invoked without any of `--branch`, `--tag`, `--source`, or `--var`, the context now defaults to `--branch main --source push` so that `rules:if:` expressions are always evaluated. Previously the context was empty and no rule evaluation occurred. Any explicit context flag bypasses the defaults entirely. + +- **`--list-vars` debug flag** — available on both `glint check` and `glint graph`; prints all pipeline-level variables collected from the root file and every included file (sorted `KEY=VALUE`) to stderr, then continues normally. When a context is active, also prints the effective merged variable set (pipeline defaults + workflow-rule variables + CLI flags). Useful for diagnosing GL032 false positives. - **Included-file variables now visible to all lint rules** — `variables:` blocks declared in included files (local, remote, project, and component includes) are now merged into the pipeline's variable namespace before linting. This eliminates false-positive GL032 warnings for variables declared in shared CI templates. Root-pipeline variables take precedence over included-file variables when the same key appears in both (matching GitLab's own override behaviour). -- **Structured rule IDs** — every finding now carries a stable `GL###` identifier (e.g. `GL003`) that appears in the output between the location and the message: `[ERROR] job "deploy" (file.yml:14) GL003: missing required field 'script'`. IDs are assigned per check function across 31 rules (GL001–GL031) and are stable across versions. The `linter.Finding` struct exposes the ID as a `Rule string` field for programmatic consumers. The README lint rules table is updated with ID columns. +- **Variable reference validation (GL032)** — glint now warns when a `rules:if:` expression references a variable (`$VAR` or `${VAR}`) that is not declared anywhere in the pipeline YAML: pipeline-level `variables:`, the job's own `variables:`, or any `workflow:rules:variables:` block. Predefined GitLab CI variable namespaces (`CI_*`, `GITLAB_*`, `FF_*`, `RUNNER_*`, `TRIGGER_*`, `CHAT_*`) are exempt. Because variables can also be set in GitLab CI/CD project settings (invisible to glint), the finding is a `[WARNING]` rather than an error. Each undeclared variable is reported at most once per job to keep the output concise. + +- **Structured rule IDs** — every finding now carries a stable `GL###` identifier (e.g. `GL003`) that appears in the output alongside the location and message: `.gitlab-ci.yml:14: GL003 [error] job "deploy": missing required field 'script'`. IDs are assigned per check function across 31 rules (GL001–GL031) and are stable across versions. The `linter.Finding` struct exposes the ID as a `Rule string` field for programmatic consumers. The README lint rules table is updated with ID columns. - **`workflow:rules:variables:` now propagate to job rule evaluation** — when a `workflow:rules:` entry matches, any `variables:` it defines are injected into the evaluation context so job `rules:if:` expressions can reference them. Pipeline-level `variables:` are also available as defaults (lower priority). Variable priority order, highest first: `--var` CLI overrides → `--branch`/`--tag`/`--source` shortcuts → workflow-rule variables → pipeline-level variable defaults. This means `$DEPLOY_TARGET == "production"` in a job rule correctly evaluates when a workflow rule sets `DEPLOY_TARGET: production` for the matching branch. The `glint graph tree` command benefits from the same enrichment. diff --git a/README.md b/README.md index 5501faf..b915f56 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. @@ -216,8 +216,12 @@ Rules without an `if:` clause always match. ### Example output ``` -# Clean pipeline, no context -OK: .gitlab-ci.yml — no issues found (5 jobs, 3 stages) +# Clean pipeline (implicit default: --branch main --source push) +Context: branch=main, source=push + +Active (5): build, deploy-staging, test, ... + +OK: .gitlab-ci.yml — no issues found (5 job(s), 3 stage(s)) # With --branch develop context Context: branch=develop, source=push @@ -225,7 +229,7 @@ Context: branch=develop, source=push Active (3): build, deploy-staging, test Skipped (2): deploy-prod, release-notes -OK: .gitlab-ci.yml — no issues found (5 jobs, 3 stages) +OK: .gitlab-ci.yml — no issues found (5 job(s), 3 stage(s)) # With --tag v1.0.0 context Context: tag=v1.0.0, source=push @@ -233,12 +237,12 @@ Context: tag=v1.0.0, source=push Active (4): build, deploy-prod, release-notes, test Skipped (1): deploy-staging -OK: .gitlab-ci.yml — no issues found (5 jobs, 3 stages) +OK: .gitlab-ci.yml — no issues found (5 job(s), 3 stage(s)) # Pipeline with issues -[ERROR] job "deploy": stage "production" is not defined in 'stages' -[ERROR] job "test": needs unknown job "build-app" -[WARNING] job "old-job": 'only'/'except' are deprecated; prefer 'rules' +.gitlab-ci.yml:14: GL004 [error] job "deploy": stage "production" is not defined in 'stages' +.gitlab-ci.yml:22: GL027 [error] job "test": needs unknown job "build-app" +.gitlab-ci.yml:31: GL007 [warning] job "old-job": 'only'/'except' are deprecated; prefer 'rules' 3 finding(s): 2 error(s) ``` diff --git a/ROADMAP.md b/ROADMAP.md index 8e1bc32..bd845e3 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 +## 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 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. @@ -26,6 +26,10 @@ glint graph tree --branch main .gitlab-ci.yml # tree annotated with [skipped] - ✓ **Expression evaluator: bare `true` / `false` keywords** — treated as the strings `"true"` / `"false"` matching GitLab CI's own behaviour; `$GATEWAY_ENABLED == true` now evaluates correctly. - ✓ **Expression evaluator: integer literals** — `$COUNT == 4`, `$ENABLED == 1`, `$DISABLED == 0` compare as decimal strings. +~~**Implicit default context**~~ — ✓ shipped v0.2.11; `glint check` and `glint graph` default to `--branch main --source push` when no context flag is given, so `rules:if:` expressions are always evaluated out of the box. + +~~**`--list-vars` debug flag**~~ — ✓ shipped v0.2.11; prints sorted `KEY=VALUE` of all collected variables (pipeline YAML + included files + workflow-rule union + effective context) to stderr. + **Remaining work** - **Multi-context simulation** — run multiple contexts in one invocation and print a comparison table: @@ -41,7 +45,7 @@ glint graph tree --branch main .gitlab-ci.yml # tree annotated with [skipped] The current rule set covers the most common sources of broken pipelines. These are the gaps most likely to matter in practice. -- **Variable reference validation** — warn when a job references `$VAR` (or `${VAR}`) that is not declared anywhere in `variables:`, `default.variables`, or the job itself +- ~~**Variable reference validation (GL032)**~~ — ✓ shipped v0.2.11; warns when a `rules:if:` expression references `$VAR` / `${VAR}` not declared anywhere in pipeline YAML; predefined GitLab namespaces (`CI_*`, `GITLAB_*`, …) exempt; variables from included files are also considered - **`services:` validation** — map form requires `name`; `alias` must be a valid DNS label - **`rules:changes` / `rules:exists`** — warn on glob patterns that can never match (e.g. absolute paths, double `**` on unsupported versions) - **`timeout` format** — must be a duration string GitLab understands (`1h 30m`, `90 minutes`, etc.) @@ -90,9 +94,11 @@ The SVG renderer and terminal tree cover the basic layout. These would bring it --- -## Findings quality — ✓ file and line numbers shipped post-v0.2.0 +## Findings quality — ✓ file and line numbers shipped post-v0.2.0; ruff-style format shipped v0.2.11 -~~**File and line numbers on findings**~~ — ✓ shipped post-v0.2.0; every `[ERROR]` / `[WARNING]` now includes the source file and exact line of the job key (e.g. `job "deploy" (src/deploy.yml:14): …`). Works across local includes, remote project templates, and fetched component templates. +~~**File and line numbers on findings**~~ — ✓ shipped post-v0.2.0; every finding includes the source file and exact line of the job key. Works across local includes, remote project templates, and fetched component templates. + +~~**Ruff-style output format**~~ — ✓ shipped v0.2.11; findings follow `file:line: RULEID [severity] message` matching the convention used by ruff and other modern linters. **Remaining improvements** @@ -125,7 +131,7 @@ The SVG renderer and terminal tree cover the basic layout. These would bring it ## Reliability and developer experience -- **Structured rule IDs** — assign a stable short ID to every rule (e.g. `GS001`) so suppression, documentation, and SARIF output are stable across versions +- ~~**Structured rule IDs**~~ — ✓ shipped post-v0.2.0; GL001–GL031 assigned; GL032 added v0.2.11 - **`--explain `** — print the rule description, rationale, and an example fix - ~~**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` diff --git a/cmd/glint/main.go b/cmd/glint/main.go index 7e1bd30..80c042b 100644 --- a/cmd/glint/main.go +++ b/cmd/glint/main.go @@ -64,6 +64,7 @@ func cmdCheck(args []string) { branch := fs.String("branch", "", "simulate a branch push (sets CI_COMMIT_BRANCH, …)") tag := fs.String("tag", "", "simulate a tag push (sets CI_COMMIT_TAG, …)") source := fs.String("source", "", "set CI_PIPELINE_SOURCE") + listVars := fs.Bool("list-vars", false, "print all collected pipeline variables (from root and included files) to stderr, then continue") var vars multiFlag fs.Var(&vars, "var", "set a CI variable as KEY=VALUE; repeatable") fs.Usage = func() { @@ -90,6 +91,7 @@ Options: --branch Simulate a branch push. Populates: CI_COMMIT_BRANCH, CI_COMMIT_REF_NAME, CI_COMMIT_REF_SLUG, CI_PIPELINE_SOURCE=push. + [default: main] --tag Simulate a tag push. Populates: CI_COMMIT_TAG, CI_COMMIT_REF_NAME, @@ -97,18 +99,30 @@ Options: --source Override CI_PIPELINE_SOURCE. - [possible values: push, merge_request_event, schedule, web, api] + [default: push] [possible values: push, merge_request_event, schedule, web, api] --var Set or override a CI variable. Takes precedence over --branch, --tag, and --source. Repeatable. + --list-vars + Print all pipeline-level variables collected from the root file and + every included file (sorted KEY=VALUE) to stderr, then continue + normally. Useful for debugging variable resolution and GL032 findings. + -h, --help Print help +Note: when none of --branch, --tag, --source, or --var are given, glint +defaults to --branch main --source push so that rules:if: expressions are +always evaluated. + Examples: glint check .gitlab-ci.yml - glint check --branch main .gitlab-ci.yml + glint check --branch develop .gitlab-ci.yml + glint check --tag v1.0.0 .gitlab-ci.yml + glint check --source merge_request_event .gitlab-ci.yml + glint check --list-vars .gitlab-ci.yml GITLAB_TOKEN=glpat-xxxx glint check .gitlab-ci.yml glint check --token glpat-xxxx --gitlab-url https://gitlab.example.com .gitlab-ci.yml glint check --branch main --var DEPLOY_ENV=production .gitlab-ci.yml @@ -116,6 +130,12 @@ Examples: } _ = fs.Parse(args) + // Apply implicit defaults when no context flag is given at all. + if *branch == "" && *tag == "" && *source == "" && len(vars) == 0 { + *branch = "main" + *source = "push" + } + if fs.NArg() != 1 { fs.Usage() os.Exit(2) @@ -148,6 +168,11 @@ Examples: ctx := cicontext.New(*branch, *tag, *source, vars) if !ctx.IsEmpty() { enrichContext(ctx, p) + } + if *listVars { + printVars(p, ctx) + } + if !ctx.IsEmpty() { printContext(p, ctx) } @@ -222,6 +247,7 @@ Options: evaluated state ([skipped] or [manual]; no tag means active). Populates: CI_COMMIT_BRANCH, CI_COMMIT_REF_NAME, CI_COMMIT_REF_SLUG, CI_PIPELINE_SOURCE=push. + [default: main] --tag Simulate a tag push. Populates: CI_COMMIT_TAG, CI_COMMIT_REF_NAME, @@ -229,18 +255,29 @@ Options: --source Override CI_PIPELINE_SOURCE. - [possible values: push, merge_request_event, schedule, web, api] + [default: push] [possible values: push, merge_request_event, schedule, web, api] --var Set or override a CI variable. Repeatable. + --list-vars + Print all pipeline-level variables collected from the root file and + every included file (sorted KEY=VALUE) to stderr, then continue + normally. Useful for debugging variable resolution. + -h, --help Print help +Note: when none of --branch, --tag, --source, or --var are given, glint +defaults to --branch main --source push so that rules:if: expressions are +always evaluated. + Examples: glint graph .gitlab-ci.yml glint graph tree .gitlab-ci.yml - glint graph tree --branch main .gitlab-ci.yml + glint graph tree --branch develop .gitlab-ci.yml + glint graph tree --tag v1.0.0 .gitlab-ci.yml + glint graph tree --list-vars .gitlab-ci.yml glint graph includes .gitlab-ci.yml > includes.mmd glint graph pipeline .gitlab-ci.yml glint graph pipeline --out /tmp/graphs .gitlab-ci.yml @@ -250,10 +287,17 @@ Examples: branch := fs.String("branch", "", "simulate a branch push (sets CI_COMMIT_BRANCH, …)") tag := fs.String("tag", "", "simulate a tag push (sets CI_COMMIT_TAG, …)") source := fs.String("source", "", "set CI_PIPELINE_SOURCE") + listVars := fs.Bool("list-vars", false, "print all collected pipeline variables to stderr, then continue") var vars multiFlag fs.Var(&vars, "var", "set a CI variable as KEY=VALUE; repeatable") _ = fs.Parse(args) + // Apply implicit defaults when no context flag is given at all. + if *branch == "" && *tag == "" && *source == "" && len(vars) == 0 { + *branch = "main" + *source = "push" + } + if fs.NArg() != 1 { fs.Usage() os.Exit(2) @@ -270,12 +314,15 @@ Examples: rootDir := filepath.Dir(filepath.Clean(path)) resolver.ResolveIncludes(p, cfg, rootDir) //nolint:errcheck - resolver.Resolve(p) //nolint:errcheck + resolver.Resolve(p) //nolint:errcheck ctx := cicontext.New(*branch, *tag, *source, vars) if !ctx.IsEmpty() { enrichContext(ctx, p) } + if *listVars { + printVars(p, ctx) + } switch mode { case "default": @@ -304,6 +351,71 @@ Examples: } } +// printVars prints the collected variable namespaces to stderr: +// 1. Pipeline variables — declared in variables: blocks across the root file +// and all included files (merged by ResolveIncludes). +// 2. Workflow-rule variables — union of variables: from every workflow:rules +// entry; any one of them may be injected at runtime. +// 3. Effective context variables — only when ctx is non-empty; shows the +// fully merged set visible to job rules:if: after enrichContext. +func printVars(p *model.Pipeline, ctx *cicontext.Context) { + fmt.Fprintln(os.Stderr, "Pipeline variables (YAML, root + includes):") + printVarMap(p.Variables) + + if p.Workflow != nil { + union := map[string]any{} + for _, rule := range p.Workflow.Rules { + for k, v := range rule.Variables { + union[k] = v + } + } + if len(union) > 0 { + fmt.Fprintln(os.Stderr, "Workflow-rule variables (union across all rules):") + printVarMap(union) + } + } + + if !ctx.IsEmpty() { + fmt.Fprintln(os.Stderr, "Effective context variables (after workflow + CLI flags):") + keys := make([]string, 0, len(ctx.Vars)) + for k := range ctx.Vars { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + fmt.Fprintf(os.Stderr, " %s=%s\n", k, ctx.Vars[k]) + } + } +} + +func printVarMap(m map[string]any) { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + if len(keys) == 0 { + fmt.Fprintln(os.Stderr, " (none)") + return + } + for _, k := range keys { + fmt.Fprintf(os.Stderr, " %s=%s\n", k, varValueString(m[k])) + } +} + +func varValueString(v any) string { + switch val := v.(type) { + case string: + return val + case map[string]any: + if s, ok := val["value"].(string); ok { + return s + } + return "(complex)" + } + return "(complex)" +} + // 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). diff --git a/internal/linter/linter.go b/internal/linter/linter.go index c9496e6..81f7c1d 100644 --- a/internal/linter/linter.go +++ b/internal/linter/linter.go @@ -24,25 +24,28 @@ type Finding struct { } func (f Finding) String() string { - loc := "" + var loc string if f.File != "" { if f.Line > 0 { - loc = fmt.Sprintf(" (%s:%d)", f.File, f.Line) + loc = fmt.Sprintf("%s:%d: ", f.File, f.Line) } else { - loc = fmt.Sprintf(" (%s)", f.File) + loc = fmt.Sprintf("%s: ", f.File) } } - ruleStr := "" + + rule := "" if f.Rule != "" { - ruleStr = " " + f.Rule + rule = f.Rule + " " } + + sev := "[" + strings.ToLower(string(f.Severity)) + "]" + + msg := f.Message if f.Job != "" { - return fmt.Sprintf("[%s] job %q%s%s: %s", f.Severity, f.Job, loc, ruleStr, f.Message) + msg = fmt.Sprintf("job %q: %s", f.Job, f.Message) } - if loc != "" || ruleStr != "" { - return fmt.Sprintf("[%s]%s%s: %s", f.Severity, loc, ruleStr, f.Message) - } - return fmt.Sprintf("[%s] %s", f.Severity, f.Message) + + return fmt.Sprintf("%s%s%s %s", loc, rule, sev, msg) } // Lint runs all rules against p and returns findings sorted by job name.