4 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
k3nny cbed44b1e9 feat(cli): variable expansion, scalar bool/int support, precedence fix
ci / vet, staticcheck, test, build (push) Successful in 2m15s
release / Build and publish release (push) Successful in 1m13s
- Add $VAR / ${VAR} expansion in effective context (ctx.ExpandVars):
  iterates up to 10 passes to resolve transitive chains; circular
  references are left as-is after the limit.
- Handle non-string YAML scalars (bool, int, float64) in
  ExtractStringVars and varValueString via new ScalarString helper;
  values like BUILD: true no longer render as "(complex)" or get
  silently dropped from the effective context.
- Variable precedence (GitLab spec): pipeline defaults < workflow-rule
  vars < CLI --var flags; implemented correctly in enrichContext;
  expansion applied after all sources are merged.
- Update README, CHANGELOG, ROADMAP for v0.2.13.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 12:32:47 +02:00
k3nny 1339ab4149 fix(linter): 🐛 yaml parser with escape in regex
ci / vet, staticcheck, test, build (push) Successful in 1m54s
release / Build and publish release (push) Successful in 1m10s
2026-06-12 01:04:35 +02:00
k3nny fef1536e1b feat(cli): ruff-style output, implicit context defaults, --list-vars
ci / vet, staticcheck, test, build (push) Successful in 2m16s
release / Build and publish release (push) Successful in 1m6s
- Finding format now follows file:line: RULEID [severity] message,
  matching ruff and other modern linters (GL003 [error] job "x": ...)
- glint check and glint graph default to --branch main --source push
  when no context flag is given; rules:if: is always evaluated
- --list-vars flag on both commands prints sorted KEY=VALUE of all
  collected variables (YAML, workflow-rule union, effective context)
- CHANGELOG [Unreleased] promoted to [0.2.11]; README badge updated;
  ROADMAP marks newly shipped items

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 00:36:32 +02:00
14 changed files with 591 additions and 64 deletions
+39 -3
View File
@@ -5,15 +5,51 @@ 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).
## [Unreleased] ## [0.2.14] - 2026-06-13
### Added ### 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. - **`--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
- **Variable expansion (`$VAR` / `${VAR}`)** — variable values that reference other variables are now expanded in the effective context after all sources are merged. Transitive chains (`A=$B`, `B=$C`) are resolved over up to ten passes; circular references are left as-is. The expanded values are visible in `--list-vars` output under "Effective context variables" and are used when evaluating `rules:if:` expressions.
- **Non-string scalar variables (`bool`, `int`, `float64`)** — variables declared with bare `true`/`false` or integer values (e.g. `BUILD: true`, `RETRIES: 3`) are now handled correctly in all variable processing paths. Previously they were rendered as `(complex)` in `--list-vars` output and silently dropped from the effective context; they now render and inject as their string equivalents (`"true"`, `"3"`), matching GitLab CI's own behaviour where all variable values are strings.
### Fixed
- **YAML `\/` escape in double-quoted strings** — regex patterns containing `\/` (escaped forward-slash) in double-quoted `if:` expressions (e.g. `$CI_COMMIT_BRANCH =~ /^us\//`) caused a YAML parse error (`found unknown escape character`) with `gopkg.in/yaml.v3`, which does not implement this YAML 1.2 escape. The parser now preprocesses the raw bytes before unmarshalling: inside double-quoted strings, `\/` is rewritten to `\\/`, which `yaml.v3` parses as a literal backslash followed by a slash — preserving the regex intent.
## [0.2.11] - 2026-06-12
### Added
- **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). - **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 (GL001GL031) 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 (GL001GL031) 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. - **`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.
+18 -8
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.0-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.
@@ -28,6 +28,11 @@ A local tool to validate and lint `.gitlab-ci.yml` pipelines without needing a G
- **Extended variable declarations** — `variables:` entries may use the `{value, description, options}` map form (GitLab CI 13.7+); `default.image` accepts both string and map form; `rules.changes`/`rules.exists` accept both list and `{paths, compare_to}` map form - **Extended variable declarations** — `variables:` entries may use the `{value, description, options}` map form (GitLab CI 13.7+); `default.image` accepts both string and map form; `rules.changes`/`rules.exists` accept both list and `{paths, compare_to}` map form
- **Graph output** — `glint graph` prints a job tree (stages → jobs) to the terminal; `glint graph includes` emits a Mermaid include dependency diagram; `glint graph pipeline` renders a GitLab CI-style PNG/SVG - **Graph output** — `glint graph` prints a job tree (stages → jobs) to the terminal; `glint graph includes` emits a Mermaid include dependency diagram; `glint graph pipeline` renders a GitLab CI-style PNG/SVG
- **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`
- **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.
@@ -200,6 +205,7 @@ glint check --branch main --var DEPLOY_ENV=production .gitlab-ci.yml
**Evaluated:** **Evaluated:**
- `rules:if:` — full expression language: `==`, `!=`, `=~`, `!~`, `&&`, `||`, `!`, `()`, `$VAR`, string literals, `null` - `rules:if:` — full expression language: `==`, `!=`, `=~`, `!~`, `&&`, `||`, `!`, `()`, `$VAR`, string literals, `null`
- `only:` / `except:` — ref keywords (`branches`, `tags`, `merge_requests`, `schedules`, …), branch name globs (`feat/*`), and `/regex/` patterns - `only:` / `except:` — ref keywords (`branches`, `tags`, `merge_requests`, `schedules`, …), branch name globs (`feat/*`), and `/regex/` patterns
- Variable expansion — `$VAR` / `${VAR}` references within variable values are expanded after all sources are merged; use `--list-vars` to inspect the resolved values
**Not evaluated** (no git tree at lint time): `rules:changes:`, `rules:exists:`. **Not evaluated** (no git tree at lint time): `rules:changes:`, `rules:exists:`.
Rules without an `if:` clause always match. Rules without an `if:` clause always match.
@@ -216,8 +222,12 @@ Rules without an `if:` clause always match.
### Example output ### Example output
``` ```
# Clean pipeline, no context # Clean pipeline (implicit default: --branch main --source push)
OK: .gitlab-ci.yml — no issues found (5 jobs, 3 stages) 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 # With --branch develop context
Context: branch=develop, source=push Context: branch=develop, source=push
@@ -225,7 +235,7 @@ Context: branch=develop, source=push
Active (3): build, deploy-staging, test Active (3): build, deploy-staging, test
Skipped (2): deploy-prod, release-notes 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 # With --tag v1.0.0 context
Context: tag=v1.0.0, source=push Context: tag=v1.0.0, source=push
@@ -233,12 +243,12 @@ Context: tag=v1.0.0, source=push
Active (4): build, deploy-prod, release-notes, test Active (4): build, deploy-prod, release-notes, test
Skipped (1): deploy-staging 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 # Pipeline with issues
[ERROR] job "deploy": stage "production" is not defined in 'stages' .gitlab-ci.yml:14: GL004 [error] job "deploy": stage "production" is not defined in 'stages'
[ERROR] job "test": needs unknown job "build-app" .gitlab-ci.yml:22: GL027 [error] job "test": needs unknown job "build-app"
[WARNING] job "old-job": 'only'/'except' are deprecated; prefer 'rules' .gitlab-ci.yml:31: GL007 [warning] job "old-job": 'only'/'except' are deprecated; prefer 'rules'
3 finding(s): 2 error(s) 3 finding(s): 2 error(s)
``` ```
+27 -6
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 ## 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.
@@ -26,6 +26,25 @@ 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: 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. -**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.
**Shipped in v0.2.13**
-**Variable expansion**`$VAR` / `${VAR}` references within variable values are expanded after all sources are merged; transitive chains resolve over multiple passes; visible in `--list-vars` effective-context output.
-**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** **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:
@@ -41,14 +60,14 @@ 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. 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 - **`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) - **`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.) - **`timeout` format** — must be a duration string GitLab understands (`1h 30m`, `90 minutes`, etc.)
- **`id_tokens:` / `secrets:`** — presence and required-key checks - **`id_tokens:` / `secrets:`** — presence and required-key checks
- **`pages:publish`** — validate that the path is consistent with `artifacts.paths` - **`pages:publish`** — validate that the path is consistent with `artifacts.paths`
- **`inherit:` completeness** — flag when a job overrides a default field that would require `inherit: default: false` to suppress - **`inherit:` completeness** — flag when a job overrides a default field that would require `inherit: default: false` to suppress
- **Unreachable jobs** — detect jobs that can never run because every `rules:` branch evaluates to `never` (static analysis only, no variable expansion) - **Unreachable jobs** — detect jobs that can never run because every `rules:` branch evaluates to `never` (static analysis only)
- **Duplicate stage names** — GitLab silently merges them; warn to avoid confusion - **Duplicate stage names** — GitLab silently merges them; warn to avoid confusion
- **`cache:key:files`** — must be a list of paths, not a glob - **`cache:key:files`** — must be a list of paths, not a glob
@@ -90,9 +109,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** **Remaining improvements**
@@ -125,7 +146,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** — 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; GL001GL031 assigned; GL032 added v0.2.11
- **`--explain <rule-id>`** — print the rule description, rationale, and an example fix - **`--explain <rule-id>`** — print the rule description, rationale, and an example fix
- ~~**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`
+7 -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
@@ -73,6 +75,8 @@ tasks:
ignore_error: false ignore_error: false
- cmd: ./{{.BINARY}} check --branch feat/x testdata/workflow_vars.yml - cmd: ./{{.BINARY}} check --branch feat/x testdata/workflow_vars.yml
ignore_error: false ignore_error: false
- cmd: ./{{.BINARY}} check testdata/workflow_escape.yml
ignore_error: false
- cmd: ./{{.BINARY}} check testdata/variable_refs.yml - cmd: ./{{.BINARY}} check testdata/variable_refs.yml
ignore_error: false ignore_error: false
- cmd: ./{{.BINARY}} check testdata/variable_refs_included.yml - cmd: ./{{.BINARY}} check testdata/variable_refs_included.yml
@@ -110,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
@@ -126,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
+130 -11
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)
@@ -64,9 +71,11 @@ func cmdCheck(args []string) {
branch := fs.String("branch", "", "simulate a branch push (sets CI_COMMIT_BRANCH, …)") branch := fs.String("branch", "", "simulate a branch push (sets CI_COMMIT_BRANCH, …)")
tag := fs.String("tag", "", "simulate a tag push (sets CI_COMMIT_TAG, …)") tag := fs.String("tag", "", "simulate a tag push (sets CI_COMMIT_TAG, …)")
source := fs.String("source", "", "set CI_PIPELINE_SOURCE") 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 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.
@@ -90,6 +99,7 @@ Options:
--branch <NAME> --branch <NAME>
Simulate a branch push. Populates: CI_COMMIT_BRANCH, Simulate a branch push. Populates: CI_COMMIT_BRANCH,
CI_COMMIT_REF_NAME, CI_COMMIT_REF_SLUG, CI_PIPELINE_SOURCE=push. CI_COMMIT_REF_NAME, CI_COMMIT_REF_SLUG, CI_PIPELINE_SOURCE=push.
[default: main]
--tag <NAME> --tag <NAME>
Simulate a tag push. Populates: CI_COMMIT_TAG, CI_COMMIT_REF_NAME, Simulate a tag push. Populates: CI_COMMIT_TAG, CI_COMMIT_REF_NAME,
@@ -97,18 +107,30 @@ Options:
--source <EVENT> --source <EVENT>
Override CI_PIPELINE_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 <KEY=VALUE> --var <KEY=VALUE>
Set or override a CI variable. Takes precedence over --branch, --tag, Set or override a CI variable. Takes precedence over --branch, --tag,
and --source. Repeatable. 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 -h, --help
Print 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: Examples:
glint check .gitlab-ci.yml 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 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 --token glpat-xxxx --gitlab-url https://gitlab.example.com .gitlab-ci.yml
glint check --branch main --var DEPLOY_ENV=production .gitlab-ci.yml glint check --branch main --var DEPLOY_ENV=production .gitlab-ci.yml
@@ -116,6 +138,12 @@ Examples:
} }
_ = fs.Parse(args) _ = 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 { if fs.NArg() != 1 {
fs.Usage() fs.Usage()
os.Exit(2) os.Exit(2)
@@ -133,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)
@@ -142,12 +170,19 @@ 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 {
printVars(p, ctx)
}
if !ctx.IsEmpty() {
printContext(p, ctx) printContext(p, ctx)
} }
@@ -194,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>
@@ -222,6 +258,7 @@ Options:
evaluated state ([skipped] or [manual]; no tag means active). evaluated state ([skipped] or [manual]; no tag means active).
Populates: CI_COMMIT_BRANCH, CI_COMMIT_REF_NAME, CI_COMMIT_REF_SLUG, Populates: CI_COMMIT_BRANCH, CI_COMMIT_REF_NAME, CI_COMMIT_REF_SLUG,
CI_PIPELINE_SOURCE=push. CI_PIPELINE_SOURCE=push.
[default: main]
--tag <NAME> --tag <NAME>
Simulate a tag push. Populates: CI_COMMIT_TAG, CI_COMMIT_REF_NAME, Simulate a tag push. Populates: CI_COMMIT_TAG, CI_COMMIT_REF_NAME,
@@ -229,18 +266,29 @@ Options:
--source <EVENT> --source <EVENT>
Override CI_PIPELINE_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 <KEY=VALUE> --var <KEY=VALUE>
Set or override a CI variable. Repeatable. 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 -h, --help
Print 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: Examples:
glint graph .gitlab-ci.yml glint graph .gitlab-ci.yml
glint graph tree .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 includes .gitlab-ci.yml > includes.mmd
glint graph pipeline .gitlab-ci.yml glint graph pipeline .gitlab-ci.yml
glint graph pipeline --out /tmp/graphs .gitlab-ci.yml glint graph pipeline --out /tmp/graphs .gitlab-ci.yml
@@ -250,10 +298,17 @@ Examples:
branch := fs.String("branch", "", "simulate a branch push (sets CI_COMMIT_BRANCH, …)") branch := fs.String("branch", "", "simulate a branch push (sets CI_COMMIT_BRANCH, …)")
tag := fs.String("tag", "", "simulate a tag push (sets CI_COMMIT_TAG, …)") tag := fs.String("tag", "", "simulate a tag push (sets CI_COMMIT_TAG, …)")
source := fs.String("source", "", "set CI_PIPELINE_SOURCE") 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 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.Parse(args) _ = 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 { if fs.NArg() != 1 {
fs.Usage() fs.Usage()
os.Exit(2) os.Exit(2)
@@ -276,6 +331,9 @@ Examples:
if !ctx.IsEmpty() { if !ctx.IsEmpty() {
enrichContext(ctx, p) enrichContext(ctx, p)
} }
if *listVars {
printVars(p, ctx)
}
switch mode { switch mode {
case "default": case "default":
@@ -304,22 +362,83 @@ 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 {
if s, ok := cicontext.ScalarString(v); ok {
return s
}
return "(complex)"
}
// 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
// sources (pipeline, workflow rules, CLI) have been merged.
ctx.ExpandVars()
return runs
} }
func printContext(p *model.Pipeline, ctx *cicontext.Context) { func printContext(p *model.Pipeline, ctx *cicontext.Context) {
+117 -9
View File
@@ -1,6 +1,9 @@
package cicontext package cicontext
import "strings" import (
"fmt"
"strings"
)
// Context holds the simulated CI execution environment used for context-aware // Context holds the simulated CI execution environment used for context-aware
// pipeline evaluation (rules:if:, only:, except:, workflow:rules:). // pipeline evaluation (rules:if:, only:, except:, workflow:rules:).
@@ -117,26 +120,131 @@ func (c *Context) Summary() string {
// ExtractStringVars converts a map[string]any variable block (as used by // ExtractStringVars converts a map[string]any variable block (as used by
// Pipeline.Variables and Rule.Variables) to a flat map[string]string. // Pipeline.Variables and Rule.Variables) to a flat map[string]string.
// Plain string values are used directly. Extended {value: "..."} map form // Plain string values are used directly. Extended {value: ...} map form uses
// uses the "value" key. Other forms are skipped. // the "value" key. Scalar non-string values (bool, int, float64) are
// converted to their string representation, matching GitLab's own behaviour
// where all CI variable values are strings.
func ExtractStringVars(m map[string]any) map[string]string { func ExtractStringVars(m map[string]any) map[string]string {
if len(m) == 0 { if len(m) == 0 {
return nil return nil
} }
out := make(map[string]string, len(m)) out := make(map[string]string, len(m))
for k, v := range m { for k, v := range m {
switch val := v.(type) { if s, ok := ScalarString(v); ok {
case string:
out[k] = val
case map[string]any:
if s, ok := val["value"].(string); ok {
out[k] = s out[k] = s
} }
} }
}
return out return out
} }
// ScalarString converts a YAML-decoded CI variable value to its string
// representation. Handles plain scalars and the extended {value: ...} map
// form. Returns (s, true) on success, ("", false) for unrecognised forms.
func ScalarString(v any) (string, bool) {
switch val := v.(type) {
case string:
return val, true
case bool:
return fmt.Sprintf("%t", val), true
case int:
return fmt.Sprintf("%d", val), true
case float64:
return fmt.Sprintf("%g", val), true
case map[string]any:
inner, ok := val["value"]
if !ok {
return "", false
}
return ScalarString(inner)
}
return "", false
}
// ExpandVars expands $VAR and ${VAR} references within every value in
// ctx.Vars, using the same map as the expansion source. Iteration repeats
// (up to 10 passes) so transitive chains like A=$B, B=$C resolve fully.
// Variables that form circular references are left as-is after the limit.
func (c *Context) ExpandVars() {
if c == nil || len(c.Vars) == 0 {
return
}
for range 10 {
changed := false
for k, v := range c.Vars {
expanded := expandVarRefs(v, c.Vars)
if expanded != v {
c.Vars[k] = expanded
changed = true
}
}
if !changed {
return
}
}
}
// expandVarRefs replaces $VAR and ${VAR} occurrences in s with their values
// from vars. Unknown variables are left unchanged.
func expandVarRefs(s string, vars map[string]string) string {
if !strings.Contains(s, "$") {
return s
}
var sb strings.Builder
i := 0
for i < len(s) {
if s[i] != '$' {
sb.WriteByte(s[i])
i++
continue
}
i++ // consume '$'
if i >= len(s) {
sb.WriteByte('$')
break
}
if s[i] == '{' {
i++ // consume '{'
j := i
for j < len(s) && isIdentByte(s[j]) {
j++
}
if j < len(s) && s[j] == '}' {
name := s[i:j]
if val, ok := vars[name]; ok {
sb.WriteString(val)
} else {
sb.WriteString("${")
sb.WriteString(name)
sb.WriteByte('}')
}
i = j + 1
} else {
// Malformed ${…} — emit literally
sb.WriteString("${")
i = j
}
} else {
j := i
for j < len(s) && isIdentByte(s[j]) {
j++
}
if j > i {
name := s[i:j]
if val, ok := vars[name]; ok {
sb.WriteString(val)
} else {
sb.WriteByte('$')
sb.WriteString(name)
}
i = j
} else {
sb.WriteByte('$')
}
}
}
return sb.String()
}
// slugify converts a ref name to its GitLab slug form: // slugify converts a ref name to its GitLab slug form:
// lowercased, non-alphanumeric characters replaced with '-', leading/trailing '-' removed. // lowercased, non-alphanumeric characters replaced with '-', leading/trailing '-' removed.
func slugify(s string) string { func slugify(s string) string {
+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":
+26 -11
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"
@@ -24,28 +26,32 @@ type Finding struct {
} }
func (f Finding) String() string { func (f Finding) String() string {
loc := "" var loc string
if f.File != "" { if f.File != "" {
if f.Line > 0 { if f.Line > 0 {
loc = fmt.Sprintf(" (%s:%d)", f.File, f.Line) loc = fmt.Sprintf("%s:%d: ", f.File, f.Line)
} else { } else {
loc = fmt.Sprintf(" (%s)", f.File) loc = fmt.Sprintf("%s: ", f.File)
} }
} }
ruleStr := ""
rule := ""
if f.Rule != "" { if f.Rule != "" {
ruleStr = " " + f.Rule rule = f.Rule + " "
} }
sev := "[" + strings.ToLower(string(f.Severity)) + "]"
msg := f.Message
if f.Job != "" { 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%s %s", loc, rule, sev, msg)
}
return fmt.Sprintf("[%s] %s", f.Severity, f.Message)
} }
// 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)...)
@@ -54,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
} }
+62
View File
@@ -24,6 +24,8 @@ func Parse(path string) (*Pipeline, error) {
// ParseBytes parses YAML from an in-memory byte slice. // ParseBytes parses YAML from an in-memory byte slice.
func ParseBytes(data []byte) (*Pipeline, error) { func ParseBytes(data []byte) (*Pipeline, error) {
data = sanitizeYAMLEscapes(data)
// First pass: parse into a yaml.Node document to extract job keys with // First pass: parse into a yaml.Node document to extract job keys with
// their exact source line numbers (key nodes carry the line, value nodes // their exact source line numbers (key nodes carry the line, value nodes
// carry the body we decode into Job / map[string]any). // carry the body we decode into Job / map[string]any).
@@ -75,3 +77,63 @@ func ParseBytes(data []byte) (*Pipeline, error) {
return p, nil return p, nil
} }
// sanitizeYAMLEscapes rewrites double-quoted YAML strings, replacing the \/
// escape sequence (unrecognised by gopkg.in/yaml.v3) with \\/ so that the
// parser produces a literal backslash+slash — preserving regex patterns like
// /^us\// that appear in GitLab CI if: expressions.
func sanitizeYAMLEscapes(data []byte) []byte {
type state int
const (
stOutside state = iota
stSingleQ
stDoubleQ
stEscape
)
out := make([]byte, 0, len(data))
s := stOutside
for i := 0; i < len(data); i++ {
b := data[i]
switch s {
case stOutside:
out = append(out, b)
switch b {
case '"':
s = stDoubleQ
case '\'':
s = stSingleQ
}
case stSingleQ:
out = append(out, b)
if b == '\'' {
if i+1 < len(data) && data[i+1] == '\'' {
// '' inside a single-quoted string is an escaped single-quote
out = append(out, data[i+1])
i++
} else {
s = stOutside
}
}
case stDoubleQ:
out = append(out, b)
switch b {
case '\\':
s = stEscape
case '"':
s = stOutside
}
case stEscape:
if b == '/' {
// \/ is not recognised by yaml.v3; rewrite as \\/ which
// the parser resolves to a literal backslash + slash.
out = append(out, '\\', '/')
} else {
out = append(out, b)
}
s = stDoubleQ
}
}
return out
}
+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
} }
+57
View File
@@ -0,0 +1,57 @@
---
# workflow_vars.yml
# Exercises workflow:rules:variables: injection.
# 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)
stages:
- build
- deploy
variables:
DEPLOY_TARGET:
value: ""
description: "Deployment target — set by workflow rules"
workflow:
rules:
- if: "
$WORKFLOW = 'gitflow' &&
$CI_PIPELINE_SOURCE == /(push|web)/ &&
$CI_COMMIT_BRANCH =~ /^us\//
"
variables:
DEPLOY_TARGET: production
BUILD: true
TEST: true
- if: '$CI_COMMIT_BRANCH == "develop"'
variables:
DEPLOY_TARGET: staging
- when: always
build:
stage: build
script: make build
rules:
- when: always
deploy-prod:
stage: deploy
script: make deploy ENV=production
rules:
- if: '$DEPLOY_TARGET == "production"'
when: on_success
- when: never
deploy-staging:
stage: deploy
script: make deploy ENV=staging
rules:
- if: '$DEPLOY_TARGET == "staging"'
when: on_success
- when: never
+13 -7
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
@@ -46,6 +52,6 @@ deploy-staging:
stage: deploy stage: deploy
script: make deploy ENV=staging script: make deploy ENV=staging
rules: rules:
- if: '$DEPLOY_TARGET == "staging"' - if: '$DEPLOY_TARGET == "staging" '
when: on_success when: on_success
- when: never - when: never