Compare commits

..

12 Commits

Author SHA1 Message Date
k3nny 9342ce0eff feat(build): fuzz testing and git-cliff changelog automation
ci / vet, staticcheck, test, build (push) Failing after 2m24s
release / Build and publish release (push) Successful in 1m19s
Add FuzzParseBytes and FuzzSanitizeYAMLEscapes in internal/model/fuzz_test.go.
Both targets run as regular seed-based tests in CI (go test ./...) and can be
run continuously via `task fuzz` (default 30 s per target; FUZZ_TIME=60s to
extend). Fuzz corpus failures are saved to testdata/fuzz/ for regression.

Add cliff.toml configuring git-cliff to generate Keep-a-Changelog-compatible
release notes from Conventional Commits. New tasks: `task changelog`
(regenerate full CHANGELOG.md) and `task changelog-next` (preview unreleased
entries without writing). Requires git-cliff (brew/cargo install git-cliff).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 23:40:16 +02:00
k3nny 7a4d55ec9a feat(graph): intra-stage DAG sub-columns and connectors behind chips
ci / vet, staticcheck, test, build (push) Failing after 2m58s
release / Build and publish release (push) Successful in 1m25s
When jobs within the same declared GitLab stage have needs: relationships
between each other, the pipeline graph now splits that stage into
topological sub-columns: jobs with no same-stage deps are in sub-column 0,
jobs that depend on them shift one column right. A new computeColumns()
function handles the topo-sort; a narrower subStageGap (20 px vs 50 px
stageGap) separates sub-columns; stage headers span all sub-columns.

SVG connector lines (Bézier curves in DAG mode, bus-bar stubs in classic
mode) are now emitted before job chip rectangles so connectors visually
pass behind chips rather than on top of them.

100% statement coverage maintained (99 tests in graph package).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 23:06:49 +02:00
k3nny 0e51844c3a feat(graph): pipeline graph improvements — on_failure, tooltips, bus-bar connectors, skipped colouring, HTML and Mermaid output
ci / vet, staticcheck, test, build (push) Failing after 1m58s
release / Build and publish release (push) Successful in 1m10s
- when: on_failure visual distinction: red circle (#d9534f), X-mark icon, dashed
  chip border, and Mermaid classDef; legend entry added
- Job tooltip / detail panel: each chip wrapped in <g data-job="…"><title>…</title>
  <desc>…</desc> so SVG viewers and the HTML sidebar show stage, when, image, needs
- Multi-job connector accuracy: classic mode now uses a bus-bar pattern (vertical
  rail at midpoint + per-job stubs) instead of one center-to-center line per stage pair
- Blocked/skipped state colouring: RenderPipeline and pipelineSVG accept
  *cicontext.Context; skipped jobs rendered in grey (#868686) with dimmed text
- Interactive HTML output (--format html): self-contained .html with inline SVG,
  mouse-wheel zoom, drag-to-pan, double-click reset, and a click-to-open sidebar
- Mermaid pipeline output (--format mermaid): prints existing Pipeline() flowchart
  to stdout; suitable for mermaid.live or Markdown embedding
- imageString() helper to extract image name from string or map form
- 100% statement coverage maintained; 809 tests pass

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 00:56:06 +02:00
k3nny 8dc30d9207 feat(linter): rules:needs: validation (GL044)
ci / vet, staticcheck, test, build (push) Failing after 2m4s
release / Build and publish release (push) Successful in 1m12s
Add GL044 to validate jobs listed in rules:needs: overrides (GitLab CI
16.4+). rules:needs: lets a specific rule override the job's top-level
needs: list; any referenced job must exist in the pipeline. Unknown jobs
produce an error; optional: true entries produce a warning, matching the
GL027 behaviour for top-level needs:. Cross-pipeline needs and skipped
jobs (when a context is active) are excluded from checking.

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 00:33:52 +02:00
k3nny 416659ffd8 feat(linter): context-scoped needs:/dependencies: cross-checks
ci / vet, staticcheck, test, build (push) Failing after 1m54s
release / Build and publish release (push) Successful in 1m6s
When glint check runs in single-context mode (--branch, --tag, --source,
or --var), jobs that resolve to JobSkipped against that context are now
excluded from needs: and dependencies: cross-job checks (GL027-GL031).
This eliminates false-positive errors for jobs intentionally gated to
specific pipeline events (e.g. a deploy job with rules:if: CI_COMMIT_TAG
no longer triggers GL027 on branch pipelines).

linter.Lint now accepts a skipped map[string]bool (nil = check all jobs);
checkNeeds and checkDependencies skip jobs present in the map. Multi-context
mode (--context) passes nil, so all jobs are checked regardless of context.

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 00:12:38 +02:00
k3nny b21ef5c0bb feat(cicontext): rules:changes: path-glob evaluation; 100% test coverage
release / Build and publish release (push) Successful in 1m12s
ci / vet, staticcheck, test, build (push) Failing after 1m54s
- Add --changes PATH and --changes-from REF flags to glint check and glint graph
  for rules:changes: evaluation. --changes marks files explicitly; --changes-from
  runs git diff --name-only <REF> automatically. Both flags can be combined.
- Implement doublestar glob matching (*, ** across path segments) in EvalJob and
  EvalWorkflow; extended {paths, compare_to} map form supported.
- Without --changes/--changes-from the condition stays permissive (existing behaviour).
- Context summary line now shows changed-file count when file data is provided.
- Achieve 100% statement coverage: comprehensive tests added across all packages;
  removed provably dead code; added testability seams (exit, userHomeDirFn,
  execCommandOutput variables) to cover previously unreachable paths.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 22:47:32 +02:00
k3nny 04f17f8616 test(coverage): add unit tests across all packages; remove dead code
ci / vet, staticcheck, test, build (push) Successful in 2m25s
- Added comprehensive table-driven test suites for all packages:
  cmd/glint, cicontext, fetcher, graph, linter, model, resolver.
  Coverage reaches 98%+ statement coverage across the codebase.
- Replaced os.Exit calls in cmd/glint with an `exit` variable so tests
  can capture exit codes without terminating the test process.
- Removed unreachable code found during coverage analysis:
  dead guard in cicontext.parseRegexLiteral; dead len(jobs)==0 branch
  in graph.Pipeline; skipWin struct field and dead continue in
  graph.convertToPNG; pipelineSVG return type simplified to string.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 22:03:46 +02:00
k3nny 7f7e2bf77b docs(docs): release v0.2.20
ci / vet, staticcheck, test, build (push) Successful in 1m52s
release / Build and publish release (push) Successful in 1m17s
Add FEATURES.md and USAGE.md entries to the [0.2.20] changelog section.
No code changes — documentation reorganisation only.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 11:17:27 +02:00
k3nny e13455a629 docs(docs): extract usage examples to USAGE.md, trim README
Move all command-by-command reference (output formats, context
simulation, remote includes, cache/offline, component format, graph
modes, explain, project config, inline suppression, example output)
from README.md into a new USAGE.md. Replace the 326-line Usage section
in README with the commands block and a single link to USAGE.md.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 11:13:02 +02:00
k3nny b8e640de03 docs(docs): extract features to FEATURES.md, trim README
The README features block grew to 38 bullets plus a full lint-rules
table, making the document hard to scan. This commit:

- Creates FEATURES.md with a structured reference covering lint rules
  (GL001–GL043 tables), include resolution, context simulation, output
  formats, configuration, graph visualization, and developer tools.
- Replaces the flat bullet list in README with a 6-line "What it does"
  category summary that links to FEATURES.md and ROADMAP.md.
- Removes the redundant ## Lint rules section from README (now in
  FEATURES.md).
- Adds 'explain' to the commands block in the README Usage section.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 11:07:51 +02:00
k3nny 4ce7f86d4d feat(linter): glint explain, GL042 rules:if: reachability, GL043 inherit completeness
- `glint explain <RULE>`: new subcommand printing rule description,
  rationale, bad-YAML example and fix for every GL001–GL043 rule.
  `glint explain` (no arg) lists all rules with ID, severity, title.
  Rule IDs are case-insensitive.

- GL042 (rules:if: evaluated reachability): warns when every rules:if:
  condition evaluates to false given the values of variables declared in
  the pipeline YAML, making the job statically unreachable. Conservative:
  only fires when all referenced variables are declared in YAML; predefined
  CI_* / GITLAB_* variables are skipped to avoid false positives.

- GL043 (inherit: completeness): warns when inherit: default: is declared
  but there is no default: block in the pipeline (dead declaration), or
  when the list form names fields not set in the default: block.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 11:02:23 +02:00
53 changed files with 10060 additions and 638 deletions
+88
View File
@@ -5,6 +5,94 @@ 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.27] - 2026-06-25
### Added
- **Fuzz testing** — `FuzzParseBytes` and `FuzzSanitizeYAMLEscapes` in `internal/model/fuzz_test.go` verify that neither the YAML parser nor the escape sanitizer panics on arbitrary input. Successful parses are also checked for structural integrity (non-nil pipeline, no empty job names). Run with `task fuzz` (default 30 s per target; set `FUZZ_TIME=60s` to extend). Found failures are saved to `testdata/fuzz/` for regression.
- **Changelog automation** — `cliff.toml` configures [git-cliff](https://git-cliff.org) to generate Keep-a-Changelogcompatible release notes from Conventional Commits. `task changelog` regenerates `CHANGELOG.md` from the full git history; `task changelog-next` previews only unreleased commits without writing. Install git-cliff with `brew install git-cliff` or `cargo install git-cliff`.
## [0.2.26] - 2026-06-25
### Changed
- **Same-stage job ordering** — when jobs within the same declared GitLab stage have `needs:` relationships between each other, the pipeline graph now splits that stage into topological sub-columns: jobs with no same-stage dependencies occupy the leftmost sub-column; jobs that depend on them are placed one sub-column to the right. Sub-columns use a narrower 20 px gap (vs. the 50 px gap between stages), and the stage header spans all sub-columns. Stages with no intra-stage `needs:` are unaffected.
- **Graph links rendered behind job chips** — SVG connector lines (Bézier curves in DAG mode, bus-bar stubs in classic mode) are now drawn before the job chip rectangles, so connector lines pass behind chips rather than on top of them.
## [0.2.25] - 2026-06-25
### Added
- **`when: on_failure` visual distinction** — SVG and HTML pipeline graphs now render failure-path jobs with a red status circle (`#d9534f`), an X-mark icon, and a dashed chip border. A new `on_failure` legend entry and Mermaid `classDef` entry are included.
- **Job tooltip / detail panel** — every job chip in SVG and HTML output is wrapped in a `<g data-job="…"><title>…</title><desc>…</desc>` group. SVG viewers that support `<title>` show the job name on hover; `<desc>` carries `stage`, `when`, `image`, and `needs` details. In HTML output the sidebar reads these directly from the DOM.
- **Multi-job connector accuracy** — classic mode (no `needs:`) now uses a bus-bar connector: horizontal stubs from every job in stage N to a vertical rail at the midpoint, then stubs from the rail to every job in stage N+1. The previous single center-to-center line left top and bottom jobs visually disconnected.
- **Skipped / blocked state colouring** — `glint graph pipeline` now accepts the same context flags as `glint check` (`--branch`, `--tag`, `--source`, `--var`, `--changes`, `--changes-from`). Jobs that evaluate to `JobSkipped` in the given context are rendered with a grey circle (`#868686`) and dimmed job name. When no context flags are given, all jobs render with their normal colours.
- **Interactive HTML output** (`--format html`) — `glint graph pipeline --format html` writes a self-contained `.html` file to `--out`. The page embeds the SVG inline with mouse-wheel zoom (centred on cursor), drag-to-pan, double-click-to-reset, and a collapsible sidebar showing job details on chip click. No external dependencies; works offline.
- **Mermaid pipeline output** (`--format mermaid`) — `glint graph pipeline --format mermaid` prints the existing Mermaid flowchart (`pipeline.go`) to stdout. Suitable for pasting into [mermaid.live](https://mermaid.live) or embedding in Markdown documentation.
- **`imageString` helper** — internal utility that extracts the image name from a `Job.Image` field that may be a plain string or a map (`image: {name: ..., entrypoint: ...}`); used by the tooltip desc builder.
## [0.2.24] - 2026-06-25
### Added
- **`rules:needs:` validation (GL044)** — validates jobs listed in `rules:needs:` overrides (GitLab CI 16.4+). Each entry must reference a job that exists in the pipeline; `optional: true` entries that reference missing jobs are downgraded to warnings (same behaviour as GL027 for top-level `needs:`). Cross-pipeline needs (maps with a `pipeline:` key) are ignored. Skipped jobs are excluded when a context is provided. `glint explain GL044` documents the rule with a bad-YAML example and fix.
- **`Rule.Needs` model field** — `rules:` entries now parse their `needs:` key into `model.Rule.Needs []any`, making the per-rule needs list available for validation and future evaluation.
## [0.2.23] - 2026-06-25
### Added
- **Context-scoped linting** — when a context is supplied via `--branch`, `--tag`, `--source`, or `--var`, `glint check` now evaluates every job against that context before running lint rules. Jobs that are statically unreachable in the given context (i.e. their `rules:` block resolves to `JobSkipped`) are excluded from `needs:` and `dependencies:` cross-job checks (GL027GL031). This eliminates false-positive errors for jobs that are intentionally gated to specific pipeline events (e.g. a deploy job with `rules: [{if: '$CI_COMMIT_TAG != ""'}]` does not generate a GL027 error on branch pipelines). Filtering applies only in single-context mode; multi-context mode (`--context`) always checks all jobs.
## [0.2.22] - 2026-06-25
### Added
- **Multi-context simulation** — `glint check` now accepts a repeatable `--context KEY=VALUE[,...]` flag. Each `--context` invocation defines one simulation context; when two or more are given, glint evaluates every pipeline job across all contexts and prints a side-by-side comparison table with `active`, `manual`, `skipped`, or `blocked` (when `workflow:rules:` would prevent the pipeline from starting) per column. Known context keys are `branch`, `tag`, and `source` (case-insensitive); any other `KEY=VALUE` pair is treated as a CI variable override. The `--changes` / `--changes-from` flags work alongside `--context` and the changed-file list is shared across all contexts. Implicit `--branch main --source push` defaults are skipped when `--context` is given.
## [0.2.21] - 2026-06-21
### Added
- **`rules:changes:` evaluation** — `glint check` and `glint graph` now evaluate `rules:changes:` conditions when file-change data is provided. Two new flags on both subcommands:
- `--changes <PATH>` — mark one or more file paths as changed (repeatable).
- `--changes-from <REF>` — run `git diff --name-only <REF>` to determine changed files automatically (e.g. `--changes-from origin/main`).
Both flags can be combined. Glob patterns in `rules:changes:` support `*` (within a path segment) and `**` (across segments). When neither flag is given the condition is treated as always matching (permissive), preserving the existing behaviour. The extended map form `{ paths: [...], compare_to: ... }` is also supported.
- **`workflow:rules:changes:` evaluation** — `workflow:rules:` entries now also evaluate `changes:` patterns when changed-file data is available, consistent with job rules.
- **Context summary includes changed files** — the `Context:` line printed by `glint check --format text` now shows the count of changed files when `--changes`/`--changes-from` is given (e.g. `branch=main, source=push, 3 changed file(s)`).
- **Unit test suite** — 100% statement coverage across all packages (`cmd/glint`, `internal/cicontext`, `internal/fetcher`, `internal/graph`, `internal/linter`, `internal/model`, `internal/resolver`).
### Changed
- **Internal:** replaced all `os.Exit` calls in `cmd/glint` with an `exit` variable to enable unit testing without process termination; no behaviour change.
- **Internal:** removed unreachable code paths found during coverage analysis — dead guard in `cicontext.parseRegexLiteral`, unreachable `len(jobs) == 0` branch in `graph.Pipeline`, and the `skipWin` struct field / dead `continue` in `graph.convertToPNG`; `pipelineSVG` return type simplified from `(string, error)` to `string` as it never returned a non-nil error. No behaviour change.
## [0.2.20] - 2026-06-14
### Added
- **`glint explain <RULE>`** — new subcommand that prints the description, rationale, a bad-YAML example, and the corrected fix for any rule ID (GL001GL043). `glint explain` with no argument lists all rules with ID, severity, and title. Rule IDs are case-insensitive (`gl007` and `GL007` are equivalent).
- **`rules:if:` evaluated reachability (GL042)** — warns when every `rules:if:` condition in a job evaluates to false using the values of variables declared in the pipeline YAML, meaning the job can never be included in a pipeline run. The check is conservative: it only fires when all variables referenced in the `if:` expressions are explicitly declared in the YAML; predefined `CI_*` / `GITLAB_*` variables and undeclared variables are treated as unknown (may have any runtime value) to avoid false positives.
- **`inherit:` completeness (GL043)** — warns when a job declares `inherit: default:` (boolean or list form) but the pipeline has no `default:` block, making the declaration a no-op. Also warns when the list form names fields (e.g. `[image, before_script]`) that are not actually set in the `default:` block — those list entries have no effect.
- **`FEATURES.md`** — full feature reference extracted from the README: all 43 lint rules in categorised tables, include resolution capabilities, context simulation details, output format schemas, configuration reference, graph visualisation modes, and developer tools. The README `## Features` section replaced with a compact 6-line `## What it does` summary with a link to `FEATURES.md`.
- **`USAGE.md`** — complete command-by-command usage reference extracted from the README: `glint check` with all output formats and examples, context simulation flags and predefined variable table, remote includes and token resolution, cache and offline mode, component reference format, `glint graph` modes, `glint explain` usage, project config (`.glint.yml`), and inline suppression. The README `## Usage` section replaced with the commands block and a single link to `USAGE.md`.
## [0.2.19] - 2026-06-14
### Added
+318
View File
@@ -0,0 +1,318 @@
# glint — feature reference
This document describes every capability in the current release.
For planned work see [ROADMAP.md](ROADMAP.md).
---
## Lint rules
Every finding carries a stable rule ID (`GL001` `GL043`) that can be used to
suppress, filter, or look up the check. Run `glint explain <ID>` for a
description, bad-YAML example, and fix.
### Pipeline-level
| ID | Sev | Rule |
|----|-----|------|
| GL001 | WARN | No `stages:` block — GitLab falls back to its built-in default stages |
| GL002 | ERR | `workflow.rules[*].when` must be `always` or `never` |
| GL036 | ERR | `default.timeout` is not a valid GitLab CI duration string |
| GL040 | WARN | A stage name appears more than once in `stages:` |
### Job structure
| ID | Sev | Rule |
|----|-----|------|
| GL003 | ERR | Job missing required `script:` (or `run:`) |
| GL004 | ERR | Job `stage:` references a stage not declared in `stages:` |
| GL005 | ERR | `only:` and `rules:` used together |
| GL006 | ERR | `except:` and `rules:` used together |
| GL007 | WARN | `only:` / `except:` used (deprecated; prefer `rules:`) |
### Keyword constraints
| ID | Sev | Rule |
|----|-----|------|
| GL008 | ERR | `when:` has an invalid value |
| GL009 | ERR | `when: delayed` without `start_in:` |
| GL010 | ERR | `start_in:` set but `when:` is not `delayed` |
| GL011 | ERR | `parallel:` integer not in range 2200, or map form missing `matrix:` |
| GL012 | ERR | `retry:` integer not in range 02, or `retry.max` out of range |
| GL013 | ERR | `retry.when:` contains an unrecognised failure type |
| GL014 | ERR | `allow_failure:` is not a boolean or a map with `exit_codes:` |
| GL015 | ERR | `interruptible:` is not a boolean |
| GL016 | ERR | Trigger job also defines `script:` |
| GL017 | ERR | `trigger:` map missing `project:` or `include:` |
| GL018 | ERR | `coverage:` is not a regex wrapped in `/…/` |
| GL019 | ERR | `release:` missing required `tag_name:`, or is not a map |
| GL020 | ERR | `environment.url` set without `environment.name`, or invalid `action:` |
| GL021 | ERR | `artifacts.when` invalid, or `expose_as` set without `paths` |
| GL022 | WARN | `pages` job `artifacts.paths` does not include `public/` |
| GL023 | ERR | `cache.when` or `cache.policy` has an invalid value |
| GL024 | ERR | `rules[*].when` has an invalid value |
| GL025 | ERR | `image:` map form missing `name:` |
| GL026 | ERR | `inherit.default` / `inherit.variables` is not a boolean or list |
| GL034 | ERR | `services:` map form missing `name:`, or `alias:` is not a valid DNS label |
| GL036 | ERR | `timeout:` is not a valid GitLab CI duration string |
| GL037 | ERR | `id_tokens:` entry missing required `aud:` |
| GL038 | ERR | `secrets:` entry missing a provider key (`vault`, `gcp_secret_manager`, `azure_key_vault`) |
| GL039 | WARN | Job uses `pages:` keyword but `artifacts.paths` doesn't include the publish directory |
| GL041 | WARN | `cache.key.files` entry looks like a glob; must be an exact file path |
### Cross-job graph
| ID | Sev | Rule |
|----|-----|------|
| GL027 | ERR/WARN | `needs:` references a job that doesn't exist (WARN when `optional: true`) |
| GL028 | ERR | `needs:` references a job in a later stage |
| GL029 | ERR | Circular dependency in `needs:` graph |
| GL030 | ERR | `dependencies:` references a job that doesn't exist |
| GL031 | ERR | `dependencies:` references a job in the same or a later stage |
| GL044 | ERR/WARN | `rules:needs:` references a job that doesn't exist (WARN when `optional: true`) |
### Expression & reachability
| ID | Sev | Rule |
|----|-----|------|
| GL032 | WARN | `rules:if:` references `$VAR` not declared in any `variables:` block (may be false-positive for project-setting variables) |
| GL033 | WARN | Every rule in `rules:` has `when: never` — job permanently excluded (provable without evaluating `if:` expressions) |
| GL035 | WARN | `rules:changes` / `rules:exists` path is absolute — absolute paths never match in GitLab CI |
| GL042 | WARN | Every `rules:if:` condition evaluates to false given the declared variable values — job statically unreachable (only fires when all referenced variables are declared in YAML) |
### Inheritance
| ID | Sev | Rule |
|----|-----|------|
| GL043 | WARN | `inherit: default:` declared but the pipeline has no `default:` block (dead declaration), or the list form names fields not set in `default:` |
### Hidden jobs
Jobs whose name starts with `.` are reusable templates; most rules are skipped for them. This matches GitLab CI's own behaviour.
---
## Include resolution
| Capability | Notes |
|------------|-------|
| `include: local:` | Read from disk, recursively merged before linting |
| `include: remote:` | HTTPS URL fetched unauthenticated; unreachable URLs produce a warning, linting continues |
| `include: project:` | Fetched from the GitLab REST API using the configured token; skipped with a warning when no token is available |
| `include: component:` | Fetched from the GitLab CI/CD Catalog; public components work without a token |
| `include: inputs:` | `$[[ inputs.KEY ]]` and `$[[ inputs.KEY \| default(…) ]]` placeholders substituted from the `with:` block before parsing |
| Depth limit | Include chains capped at 100 levels (matches GitLab); circular cross-file references detected via visited-set tracking |
| Offline mode | `--offline` serves all remote includes from the cache; missing entries produce a warning |
| Include cache | `--cache-dir DIR` (or `~/.cache/glint` by default with `--offline`) persists fetched templates; keyed by SHA-256 of the request coordinates |
**Token resolution order** (first non-empty wins):
| Source | Header |
|--------|--------|
| `--token` flag / `GITLAB_TOKEN` env | `PRIVATE-TOKEN` |
| `CI_JOB_TOKEN` env | `JOB-TOKEN` |
| `GITLAB_PRIVATE_TOKEN` env | `PRIVATE-TOKEN` |
**URL resolution order:** `--gitlab-url` flag → `CI_SERVER_URL` env → `GITLAB_URL` env → `https://gitlab.com`
---
## Context simulation
Pass `--branch`, `--tag`, `--source`, or `--var` to evaluate `rules:if:` and
`only`/`except` filters against a specific pipeline event. When no context flag
is given glint defaults to `--branch main --source push` so that `rules:if:`
expressions are always evaluated.
**Evaluated at lint time:**
- `rules:if:` — full expression language: `==`, `!=`, `=~`, `!~`, `&&`, `||`, `!`, `(…)`, `$VAR`/`${VAR}`, string literals, `null`, regex flags (`/pat/i`)
- `only:` / `except:` — ref keywords, branch-name globs, and `/regex/` patterns
- `workflow:rules:` — evaluated to determine whether the pipeline would run; matching rule's `variables:` are injected before job evaluation
- `rules:changes:` — path-glob patterns evaluated against the supplied changed-file list (see `--changes` / `--changes-from` flags); `*` matches within a segment, `**` crosses `/` boundaries; the extended `{paths: [...], compare_to: ...}` form is supported; without changed-file data the condition is always treated as matching (permissive)
- Variable expansion — `$VAR` / `${VAR}` references in variable values expanded after all sources merge; transitive chains resolved (up to 10 passes)
**Not evaluated** (no git tree at lint time): `rules:exists:`.
**Predefined variables** set by shortcut flags:
| Flag | Variables populated |
|------|---------------------|
| `--branch NAME` | `CI_COMMIT_BRANCH`, `CI_COMMIT_REF_NAME`, `CI_COMMIT_REF_SLUG`, `CI_PIPELINE_SOURCE=push` |
| `--tag NAME` | `CI_COMMIT_TAG`, `CI_COMMIT_REF_NAME`, `CI_COMMIT_REF_SLUG`, `CI_PIPELINE_SOURCE=push`; clears `CI_COMMIT_BRANCH` |
| `--source EVENT` | `CI_PIPELINE_SOURCE` |
| `--var KEY=VALUE` | any variable; overrides shortcuts; repeatable |
Use `--list-vars` to print the resolved variable table to stderr.
**Changed-file flags** (for `rules:changes:` evaluation):
| Flag | Effect |
|------|--------|
| `--changes PATH` | Mark PATH as changed; repeatable |
| `--changes-from REF` | Run `git diff --name-only REF` to auto-detect changed files |
**Context-scoped linting** (single-context mode):
When a context is given, jobs evaluated as `skipped` are excluded from `needs:` and `dependencies:` cross-job checks (GL027GL031). This eliminates false positives for jobs that are intentionally gated to specific pipeline events:
```yaml
deploy-job:
needs: [build-job] # GL027 suppressed on branch pipelines; only checked on tag pipelines
rules:
- if: '$CI_COMMIT_TAG != ""'
```
**Multi-context comparison** (`--context`):
Pass `--context KEY=VALUE[,...]` (repeatable) instead of `--branch`/`--tag`/`--source` to evaluate every job across multiple contexts simultaneously. Each `--context` flag defines one column; glint prints a table showing `active`, `manual`, `skipped`, or `blocked` per job per context.
Known context keys: `branch`, `tag`, `source` (case-insensitive). Any other `KEY=VALUE` pair is injected as a CI variable override. The `--changes`/`--changes-from` file list is shared across all contexts.
```
glint check --context branch=main --context branch=develop .gitlab-ci.yml
Context comparison:
JOB branch=main branch=develop
---------- ----------- -------------
build-job active active
deploy-job active skipped
test-job active active
```
---
## Output formats
Pass `--format` to `glint check`. In structured formats the summary line is
written to stderr so stdout contains only the machine-readable payload.
| Format | Flag | Description |
|--------|------|-------------|
| Text (default) | `--format text` | Ruff-style `file:line: RULE [sev] message` |
| JSON | `--format json` | Stable schema (version 1); `findings` array + `summary` block |
| SARIF 2.1.0 | `--format sarif` | Consumed by GitHub Code Scanning and GitLab SAST |
| JUnit XML | `--format junit` | CI test-report artifact (`artifacts:reports:junit`) |
| GitHub annotations | `--format github` | `::error file=…,line=…,title=RULE::message` inline PR comments |
**JSON schema (`schema_version: 1`):**
```json
{
"schema_version": 1,
"glint_version": "v0.2.20",
"pipeline": ".gitlab-ci.yml",
"findings": [
{"rule":"GL004","severity":"error","file":".gitlab-ci.yml","line":14,
"job":"deploy","message":"stage \"production\" is not defined in 'stages'"}
],
"summary": {"total": 1, "errors": 1, "warnings": 0}
}
```
---
## Configuration
### `.glint.yml` project config file
Searched from the pipeline file's directory upward to the first `.git` boundary.
```yaml
# Suppress rules globally for this project.
ignore:
- GL007 # migrating from only:/except:
- GL032 # dynamic variables injected by CI
# Override rule severity (error | warning | ignore).
# 'ignore' is equivalent to listing the rule in ignore:.
severity:
GL004: warning # demote during a stage migration
GL035: error # promote to hard error for this project
# Extra stage names valid beyond those in the pipeline's own stages: block.
stages:
- quality
- security
# Default GitLab token (lower priority than --token and GITLAB_TOKEN).
token: glpat-xxxx
# Default GitLab instance URL.
url: https://gitlab.example.com
# Default cache directory.
cache_dir: ~/.cache/glint
```
**Priority chain:** `--token`/`--gitlab-url` flags > `.glint.yml` > environment variables.
### Inline suppression (`# glint: ignore`)
Suppress a finding for a specific job by placing a comment immediately before it:
```yaml
# glint: ignore GL007
legacy-job:
only: [main]
script: echo ok
# Multiple rules (comma- or space-separated):
# glint: ignore GL007, GL032
other-job:
script: echo ok
# Suppress every rule for this job:
# glint: ignore all
noisy-job:
script: echo ok
```
Suppressions are scoped to the single job they precede and do not affect other
jobs or pipeline-level findings.
---
## Graph visualization (`glint graph`)
| Mode | Output |
|------|--------|
| `tree` (default) | Terminal job tree: stages as branches, jobs as leaves; annotated with `[manual]`, `[delayed]`, `[trigger]` where applicable |
| `includes` | Mermaid flowchart to stdout; colour-coded nodes by include type (local, remote, project, component, template) |
| `pipeline` | GitLab CI-style SVG/PNG written to `--out` directory (default: `glint-out/`); converted to PNG when `rsvg-convert`, `inkscape`, or `magick` is available |
| `all` | `includes` to stdout + `pipeline` file path to stderr |
**`glint graph pipeline --format <FORMAT>`**
| Format | Effect |
|--------|--------|
| `svg` (default) | Write GitLab CI-style SVG/PNG to `--out` |
| `mermaid` | Print Mermaid flowchart to stdout (paste into [mermaid.live](https://mermaid.live)) |
| `html` | Write self-contained HTML to `--out` with mouse pan/zoom and a click-to-open job-detail sidebar |
**Visual distinctions in SVG and HTML output:**
- **Regular** — blue circle with checkmark
- **Manual** — orange circle with play triangle
- **Trigger** — purple circle with chevron
- **Delayed** — yellow circle with clock
- **`when: on_failure`** — red circle (`#d9534f`) with X mark; dashed chip border
- **Skipped** (with `--branch`/`--tag`/`--source` context flags) — grey circle, dimmed job name
In DAG pipelines (any job has `needs:`) the pipeline graph uses job-to-job
Bézier connectors. In classic mode a bus-bar pattern (vertical rail + per-job
stubs) accurately connects every job across adjacent stages.
Each chip carries a `<title>` and `<desc>` with stage, when, image, and needs —
shown as a tooltip in SVG viewers and as a sidebar panel in HTML output.
---
## Developer tools
| Tool | Description |
|------|-------------|
| `glint explain <RULE>` | Print description, rationale, bad-YAML example, and fix for a rule. Case-insensitive (`gl007` = `GL007`). |
| `glint explain` | List all rules with ID, severity, and title. |
| `--list-vars` | Print all resolved pipeline variables (pipeline + workflow rules + context) to stderr before linting. |
| `--version` / `-v` | Print the compiled version string. |
+20 -422
View File
@@ -6,50 +6,23 @@
<p align="center">
<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.19-blue.svg" alt="Release"></a>
<a href="CHANGELOG.md"><img src="https://img.shields.io/badge/release-v0.2.27-blue.svg" alt="Release"></a>
</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.
A local tool to validate and lint `.gitlab-ci.yml` pipelines without needing a GitLab server.
## Features
## What it does
- **YAML validation** — detects malformed pipeline files early
- **Stage validation** — every job's `stage` must be declared in `stages`
- **`extends:` resolution** — resolves single and multi-level template inheritance before linting, so derived jobs are evaluated against their fully merged definition
- **`needs:` DAG validation** — checks that `needs:` references exist, respect stage ordering, and contain no circular dependencies
- **`dependencies:` validation** — checks that artifact dependency references exist and are in earlier stages
- **Keyword validation** — validates constraints on `when`, `parallel`, `retry`, `allow_failure`, `trigger`, `artifacts`, `cache`, `release`, `environment`, `coverage`, `rules`, and more
- **Remote project includes** — fetches `include: project:` templates from the GitLab API so extends/needs can be validated against the full merged pipeline
- **CI/CD catalog components** — resolves `include: component:` references from the GitLab CI/CD Catalog; public components work without a token
- **Deprecation warnings** — flags `only`/`except` usage in favour of `rules`
- **Local include resolution** — `include: local:` entries are read from disk and recursively merged before linting, so multi-file pipelines are fully validated
- **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
- **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
- **Static reachability (GL033)** — warns when a job's `rules:` block can never activate: if every rule has `when: never` the job is permanently excluded from any pipeline run, provable without evaluating any `if:` expressions
- **`services:` validation (GL034)** — map form requires a `name` key; `alias` must be a valid DNS label (letters, digits, hyphens, dots; no leading/trailing hyphens)
- **`rules:changes` / `rules:exists` glob safety (GL035)** — warns when paths are absolute (start with `/`), which can never match since GitLab CI paths are always relative to the repository root
- **`timeout:` format (GL036)** — validates that job and `default:` timeout values are valid GitLab CI duration strings (`1h 30m`, `90 minutes`, `2 hours`, etc.)
- **`id_tokens:` validation (GL037)** — each OIDC token entry must have an `aud` key (missing `aud` is a GitLab API error at runtime)
- **`secrets:` validation (GL038)** — each secret entry must declare exactly one provider (`vault`, `gcp_secret_manager`, or `azure_key_vault`)
- **`pages:` keyword + `artifacts.paths` (GL039)** — warns when a job uses the `pages:` keyword but `artifacts.paths` does not include the publish directory (default: `public`)
- **Duplicate stage names (GL040)** — warns when a stage name appears more than once in `stages:`; GitLab silently merges duplicates, which can cause confusing ordering
- **`cache.key.files` glob detection (GL041)** — warns when `cache.key.files` entries contain glob metacharacters; this field requires exact file paths, not patterns
- **Recursive include depth limit** — include chains are capped at 100 nesting levels (matching GitLab's own limit); project and component includes are now tracked in the visited-file set to prevent cross-include cycles
- **`include: inputs:` substitution** — when a `component:` entry has a `with:` block, all `$[[ inputs.KEY ]]` and `$[[ inputs.KEY | default(…) ]]` placeholders in the fetched template are substituted before parsing, so component-scoped jobs get their correct `stage:` and keyword values instead of `$[[…]]` placeholders
- **Offline mode + include cache** — pass `--cache-dir DIR` to cache fetched remote templates (project: and component: includes) to disk; `--offline` serves entirely from the cache without making network calls
- **Structured output formats** — `--format json` emits a stable JSON report; `--format sarif` emits SARIF 2.1.0 (consumed by GitHub Code Scanning and GitLab SAST); `--format junit` emits JUnit XML (consumable as a CI test-report artifact); `--format github` emits GitHub Actions annotation lines (`::error file=…::`) so findings appear as inline PR comments
- **`.glint.yml` project config** — rule suppression (`ignore: [GL007]`), severity overrides (`severity: {GL004: warning}`), extra stages allowlist (`stages: [quality]`), and default token/URL/cache-dir so flags are not needed on every invocation
- **Inline suppression comments** — `# glint: ignore GL007` (or `# glint: ignore all`) immediately before a job definition suppresses the specified rule(s) for that job without touching other jobs
- **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
- **Lints** — 43 rules covering pipeline structure, keyword constraints, `needs:`/`dependencies:` graphs, expression reachability, and deprecations (GL001GL043); run `glint explain <ID>` for any rule
- **Resolves includes** — local files, HTTPS URLs, GitLab project templates, and CI/CD Catalog components, with offline cache support
- **Simulates context** — `--branch`, `--tag`, `--source` flags evaluate `rules:if:` and `only`/`except` to show which jobs would be active, manual, or skipped; `--context branch=main --context branch=develop` prints a multi-column comparison table across multiple contexts in one run
- **Multiple output formats** — `--format text` (default, ruff-style), `json`, `sarif` (GitHub Code Scanning / GitLab SAST), `junit`, `github` (PR annotations)
- **Project config** — `.glint.yml` for rule suppression, severity overrides, token/URL defaults; `# glint: ignore RULE` for per-job inline suppression
- **Graph visualization** — `glint graph` prints a terminal job tree; `glint graph pipeline` renders a GitLab CI-style SVG/PNG; `--format mermaid` emits a Mermaid flowchart; `--format html` produces a self-contained HTML file with pan/zoom and a job-detail sidebar; context flags grey out skipped jobs
See [ROADMAP.md](ROADMAP.md) for planned improvements.
See [FEATURES.md](FEATURES.md) for the complete feature reference and lint rules table, and [ROADMAP.md](ROADMAP.md) for planned improvements.
## Requirements
@@ -78,394 +51,12 @@ glint [OPTIONS] <COMMAND>
Commands:
check Lint a pipeline file — exits 0 (clean) or 1 (errors found)
graph Visualise the pipeline as a job tree or Mermaid graph
explain Print description and fix for a lint rule
```
Run `glint <command> --help` for command-specific options and examples.
### `glint check`
```bash
glint check .gitlab-ci.yml
```
Exits `0` when no errors are found, `1` when at least one error is reported.
### Output formats
Pass `--format` to control the output. Plain text is the default.
```bash
# Default: ruff-style text (human-readable)
glint check .gitlab-ci.yml
# JSON — stable schema, machine-readable
glint check --format json .gitlab-ci.yml
# SARIF 2.1.0 — GitHub Code Scanning / GitLab SAST
glint check --format sarif .gitlab-ci.yml > glint.sarif
# JUnit XML — CI test-report artifact (GitLab: artifacts:reports:junit)
glint check --format junit .gitlab-ci.yml > glint-junit.xml
# GitHub Actions annotations — inline PR diff comments
glint check --format github .gitlab-ci.yml
```
In structured formats (`json`, `sarif`, `junit`, `github`) the summary line
(`OK: … no issues found` or `N finding(s): M error(s)`) is written to stderr
so stdout contains only the machine-readable payload.
**JSON schema (`schema_version: 1`):**
```json
{
"schema_version": 1,
"glint_version": "v0.2.18",
"pipeline": ".gitlab-ci.yml",
"findings": [
{"rule":"GL004","severity":"error","file":".gitlab-ci.yml","line":14,
"job":"deploy","message":"stage \"production\" is not defined in 'stages'"}
],
"summary": {"total": 1, "errors": 1, "warnings": 0}
}
```
**GitHub annotation lines:**
```
::error file=.gitlab-ci.yml,line=14,title=GL004::job "deploy": stage "production" is not defined in 'stages'
```
### Project configuration (`.glint.yml`)
Place a `.glint.yml` file next to your pipeline (or anywhere in the directory tree up to the repository root) to configure glint for that project. glint searches upward from the pipeline file's directory, stopping at the first `.git` boundary.
```yaml
# .glint.yml
# Suppress specific rules entirely.
ignore:
- GL007 # we still use only:/except:, migration in progress
- GL032 # lots of dynamic variables injected by CI
# Override the severity of specific rules.
severity:
GL004: warning # demote stage errors to warnings during a migration
GL035: error # promote absolute-path warning to error for this project
# Extra stages that are valid but not declared in the pipeline YAML itself
# (e.g. injected by an include template we can't edit).
stages:
- quality
- security
# Default token — overridden by --token flag and GITLAB_TOKEN env.
token: glpat-xxxx
# Default GitLab instance URL.
url: https://gitlab.example.com
# Default cache directory for fetched remote includes.
cache_dir: ~/.cache/glint
```
**Priority chain for token and URL:** `--token`/`--gitlab-url` flags > `.glint.yml` values > `GITLAB_TOKEN`/`CI_SERVER_URL` environment variables.
### Inline suppression (`# glint: ignore`)
Suppress a finding for a specific job by placing a `# glint: ignore RULE` comment immediately before the job definition:
```yaml
# glint: ignore GL007
legacy-job:
stage: build
only:
- main
script: echo ok
# Multiple rules — comma- or space-separated:
# glint: ignore GL007, GL032
another-job:
stage: build
script: echo ok
# Suppress all rules for this job:
# glint: ignore all
noisy-job:
stage: build
script: echo ok
```
Inline suppressions are scoped to the single job they precede. They do not affect other jobs or pipeline-level findings. For project-wide suppression use `.glint.yml` `ignore:`.
### Remote project includes
Pipelines that include templates from other GitLab projects are supported.
Provide a token so `glint` can fetch them:
```bash
# personal access token (read_api scope)
GITLAB_TOKEN=glpat-xxxx glint check .gitlab-ci.yml
# CI/CD job token (when running inside a pipeline)
CI_JOB_TOKEN=$CI_JOB_TOKEN glint check .gitlab-ci.yml
# self-hosted GitLab
GITLAB_TOKEN=glpat-xxxx GITLAB_URL=https://gitlab.example.com glint check .gitlab-ci.yml
# or via flags
glint check --token glpat-xxxx --gitlab-url https://gitlab.example.com .gitlab-ci.yml
```
**Project includes** require a token; without one they are skipped with a
warning and the rest of the pipeline is linted as-is.
### Include cache and offline mode
Pass `--cache-dir` to cache fetched remote templates so repeated runs skip the network:
```bash
# First run: fetches and caches
glint check --cache-dir ~/.cache/glint .gitlab-ci.yml
# Subsequent runs: served from cache
glint check --cache-dir ~/.cache/glint .gitlab-ci.yml
# Fully offline (uses ~/.cache/glint automatically when --cache-dir is absent)
glint check --offline .gitlab-ci.yml
glint check --offline --cache-dir ~/.cache/glint .gitlab-ci.yml
```
Cache entries are keyed by SHA-256 of the full request URL/coordinates and stored as plain YAML files in the cache directory. There is currently no automatic expiry — delete the directory or individual entries to force a fresh fetch.
**Component includes** (`include: component: ...`) attempt the fetch
unauthenticated, so public [CI/CD Catalog](https://gitlab.com/explore/catalog)
components work without a token. A warning is emitted if the fetch fails.
Token resolution order (first non-empty wins):
| Source | Header used |
|--------|-------------|
| `--token` flag / `GITLAB_TOKEN` | `PRIVATE-TOKEN` |
| `CI_JOB_TOKEN` | `JOB-TOKEN` |
| `GITLAB_PRIVATE_TOKEN` | `PRIVATE-TOKEN` |
Instance URL resolution order: `--gitlab-url` flag → `CI_SERVER_URL`
`GITLAB_URL``https://gitlab.com`
### Component reference format
```
<host>/<project-path>/<component-name>@<version>
gitlab.com/components/secret-detection/secret-detection@v0.1.0
gitlab.com/my-org/ci-catalog/lint@main
gitlab.example.com/platform/components/build@~latest
```
The component file is looked up in order:
1. `templates/<component-name>.yml` (single-file layout)
2. `templates/<component-name>/template.yml` (directory layout)
Component input parameters (`with:`) are not validated — they are resolved by
GitLab at runtime. Jobs in fetched components may use `$[[ inputs.xxx ]]`
placeholders in fields like `stage`; `glint` skips those fields rather
than producing false positive errors.
### `glint graph`
Visualise the pipeline. Without a mode word, prints a job tree and the include
dependency graph separated by `---`.
```bash
# Default: job tree + include dependency graph
glint graph .gitlab-ci.yml
# Job tree only (stages → jobs, like the tree command)
glint graph tree .gitlab-ci.yml
# Include dependency graph → Mermaid flowchart to stdout
glint graph includes .gitlab-ci.yml > includes.mmd
# GitLab-like pipeline layout → PNG (or SVG fallback) written to --out dir
glint graph pipeline .gitlab-ci.yml
# prints the output file path, e.g.: glint-out/pipeline-20260607-143022.png
# Mermaid to stdout + pipeline file path to stderr
glint graph all .gitlab-ci.yml > includes.mmd
# Custom output directory (pipeline mode)
glint graph pipeline --out /tmp/graphs .gitlab-ci.yml
```
**Job tree** (`graph tree`) — stages as branches, jobs as leaves. Jobs with
`when: manual`, `when: delayed`, or `trigger:` are annotated in brackets.
**Include graph** (`graph includes`) — [Mermaid](https://mermaid.js.org) flowchart written to stdout.
Pipe to a `.mmd` file or paste into [mermaid.live](https://mermaid.live).
One node per include entry, colour-coded by type:
- Orange (bold): the main pipeline file
- Purple: `project:` includes
- Green: `component:` includes
- Blue: `local:` includes
- Grey: `remote:` URL includes
- Light orange: GitLab-provided `template:` includes
**Pipeline graph** (`graph pipeline`) — GitLab CI-style SVG rendered to a timestamped file
in the `--out` directory (default: `glint-out/`). Converted to PNG automatically
when `rsvg-convert`, `inkscape`, or `magick` is available; falls back to SVG otherwise.
Jobs are colour-coded by type:
- Blue (`#1f75cb`): regular jobs
- Orange (`#fc6d26`): `when: manual` jobs
- Purple (`#6b4fbb`): `trigger:` jobs
- Amber (`#fca326`): `when: delayed` jobs
DAG mode (job-to-job Bézier arrows) activates automatically when any job has a `needs:` list.
Classic mode draws L-shaped or straight connectors between stage columns otherwise.
### Context simulation
Pass `--branch`, `--tag`, or `--source` to `glint check` to see which jobs
would run for a given pipeline event. The pipeline is still fully linted;
context output is printed first.
```bash
# What runs on a push to develop?
glint check --branch develop .gitlab-ci.yml
# What runs when a v1.2.0 tag is pushed?
glint check --tag v1.2.0 .gitlab-ci.yml
# Merge request pipeline
glint check --source merge_request_event .gitlab-ci.yml
# Arbitrary variable overrides (repeatable)
glint check --branch main --var DEPLOY_ENV=production .gitlab-ci.yml
```
**Evaluated:**
- `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
- 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:`.
Rules without an `if:` clause always match.
**Predefined variables** set automatically by the shortcut flags:
| Flag | Variables populated |
|------|---------------------|
| `--branch <name>` | `CI_COMMIT_BRANCH`, `CI_COMMIT_REF_NAME`, `CI_COMMIT_REF_SLUG`, `CI_PIPELINE_SOURCE=push` |
| `--tag <name>` | `CI_COMMIT_TAG`, `CI_COMMIT_REF_NAME`, `CI_COMMIT_REF_SLUG`, `CI_PIPELINE_SOURCE=push` (clears `CI_COMMIT_BRANCH`) |
| `--source <event>` | `CI_PIPELINE_SOURCE` |
| `--var KEY=VALUE` | any variable; overrides shortcuts |
### Example output
```
# 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
Active (3): build, deploy-staging, test
Skipped (2): deploy-prod, release-notes
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
Active (4): build, deploy-prod, release-notes, test
Skipped (1): deploy-staging
OK: .gitlab-ci.yml — no issues found (5 job(s), 3 stage(s))
# Pipeline with issues
.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)
```
## Lint rules
Every finding includes a stable rule ID (e.g. `GL003`) that can be used to filter output or reference a specific check in documentation.
### Pipeline-level
| ID | Severity | Rule |
|----|----------|------|
| GL002 | ERROR | `workflow.rules[*].when` is not `always` or `never` |
| GL001 | WARNING | No `stages` defined (GitLab falls back to default stages) |
| GL036 | ERROR | `default.timeout` is not a valid GitLab CI duration string |
| GL040 | WARNING | A stage name appears more than once in `stages:` |
### Job-level — structure
| ID | Severity | Rule |
|----|----------|------|
| GL003 | ERROR | Job is missing required `script` (or `run`) — non-trigger, non-template jobs |
| GL004 | ERROR | Job references a `stage` not declared in `stages` |
| GL005 | ERROR | `only` and `rules` used together on the same job |
| GL006 | ERROR | `except` and `rules` used together on the same job |
| GL007 | WARNING | `only`/`except` used (deprecated, prefer `rules`) |
### Job-level — keyword constraints
| ID | Severity | Rule |
|----|----------|------|
| GL008 | ERROR | `when` is not one of `on_success`, `on_failure`, `always`, `manual`, `delayed`, `never` |
| GL009 | ERROR | `when: delayed` without `start_in` |
| GL010 | ERROR | `start_in` set but `when` is not `delayed` |
| GL011 | ERROR | `parallel` integer not in range 2200, or map form missing `matrix` key |
| GL012 | ERROR | `retry` integer not in range 02, or `retry.max` out of range |
| GL013 | ERROR | `retry.when` contains an unrecognised failure type |
| GL014 | ERROR | `allow_failure` is not a boolean or a map with `exit_codes` |
| GL015 | ERROR | `interruptible` is not a boolean |
| GL016 | ERROR | `trigger` job also has `script` |
| GL017 | ERROR | `trigger` map missing `project` or `include` |
| GL018 | ERROR | `coverage` is not a regex pattern wrapped in `/` |
| GL019 | ERROR | `release` missing required `tag_name`, or is not a map |
| GL020 | ERROR | `environment.url` set without `environment.name`, or invalid `environment.action` |
| GL021 | ERROR | `artifacts.when` invalid, or `artifacts.expose_as` set without `artifacts.paths` |
| GL022 | WARNING | `pages` job `artifacts.paths` does not include `public` |
| GL023 | ERROR | `cache.when` or `cache.policy` has an invalid value |
| GL024 | ERROR | `rules[*].when` is not one of the valid `when` values |
| GL025 | ERROR | `image` map form missing `name` key |
| GL026 | ERROR | `inherit.default` / `inherit.variables` is not a boolean or list |
| GL034 | ERROR | `services:` map form missing `name`, or `alias` is not a valid DNS label |
| GL036 | ERROR | `timeout:` is not a valid GitLab CI duration string (e.g. `1h 30m`, `90 minutes`) |
| GL037 | ERROR | `id_tokens:` entry is missing the required `aud` key |
| GL038 | ERROR | `secrets:` entry is missing a provider key (`vault`, `gcp_secret_manager`, or `azure_key_vault`) |
| GL039 | WARNING | Job has `pages:` keyword but `artifacts.paths` does not include the publish directory |
| GL041 | WARNING | `cache.key.files` entry looks like a glob pattern; must be an exact file path |
### Cross-job graph
| ID | Severity | Rule |
|----|----------|------|
| GL027 | ERROR/WARNING | `needs:` references a job that does not exist (WARNING when `optional: true`) |
| GL028 | ERROR | `needs:` references a job in a later stage |
| GL029 | ERROR | Circular dependency detected in `needs:` graph |
| GL030 | ERROR | `dependencies:` references a job that does not exist |
| GL031 | ERROR | `dependencies:` references a job in the same or a later stage |
### Expression validation
| ID | Severity | Rule |
|----|----------|------|
| GL032 | WARNING | `rules:if:` references `$VAR` not declared in `variables:` (pipeline, job, or `workflow:rules:variables:`) — may be a false positive for variables set in GitLab CI/CD project settings |
| GL033 | WARNING | Every rule in `rules:` has `when: never` — job is permanently excluded from the pipeline (statically provable without context) |
| GL035 | WARNING | `rules:changes` / `rules:exists` path is absolute; GitLab CI paths are relative to the repo root — absolute paths will never match |
### Hidden jobs (templates)
Jobs whose name starts with `.` are treated as reusable templates and skipped for most rules. This matches GitLab's own behaviour.
Run `glint <command> --help` for all flags. See [USAGE.md](USAGE.md) for full
examples covering output formats, context simulation, remote includes, cache,
graph modes, and project configuration.
## Development
@@ -478,11 +69,18 @@ task test # run Go unit tests
task lint-go # run go vet
task validate # run the binary against all testdata fixtures
task ci # full check: vet → test → build → validate
task fuzz # run fuzz tests for the YAML parser (Ctrl-C to stop; FUZZ_TIME=60s to set duration)
task changelog # regenerate CHANGELOG.md from git history via git-cliff
task changelog-next # preview unreleased section (dry-run, no file written)
task build-windows # cross-compile for Windows x64 (requires a tagged commit → glint-<tag>.exe)
task build-linux # cross-compile for Linux x64 (requires a tagged commit → glint-<tag>-linux-amd64)
task clean # remove build artifacts
```
**Optional tools:**
- [git-cliff](https://git-cliff.org) — changelog generator used by `task changelog`. Install with `brew install git-cliff` or `cargo install git-cliff`.
## Project structure
```
+33 -59
View File
@@ -4,55 +4,27 @@ 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; workflow evaluation and output fixes shipped v0.2.14
## Context simulation
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.
Pass `--branch`, `--tag`, `--source`, or `--var` to `glint check` or `glint graph` to evaluate `rules:if:` expressions and `only`/`except` filters against a specific pipeline event.
```bash
# shipped: single-context simulation
glint check --branch develop .gitlab-ci.yml
glint check --tag v1.2.0 .gitlab-ci.yml
glint check --source merge_request_event --var CI_MERGE_REQUEST_TARGET_BRANCH_NAME=main .gitlab-ci.yml
glint graph tree --branch main .gitlab-ci.yml # tree annotated with [skipped] / [manual]
```
**Shipped post-v0.2.0 (unreleased)**
- **`workflow:rules:variables:` propagation** — variables defined on the matching `workflow:rules:` entry are injected into the evaluation context before job `rules:if:` expressions are evaluated. Pipeline-level `variables:` defaults are also available. Priority chain (highest wins): `--var` > shortcuts > workflow-rule vars > pipeline defaults.
- **Expression evaluator: multi-line expressions** — newlines in block-scalar and folded YAML `if:` values are now treated as whitespace; `||` / `&&` on a continuation line evaluate correctly.
- **Expression evaluator: `${VAR}` curly-brace syntax**`${CI_COMMIT_BRANCH}` is equivalent to `$CI_COMMIT_BRANCH` everywhere.
- **Expression evaluator: regex flags**`/pattern/i`, `/pattern/m`, `/pattern/s` are now supported; `i` maps to `(?i)` in Go's regexp.
- **Expression evaluator: variable as regex RHS**`$BRANCH =~ $PATTERN` where `$PATTERN` holds a `/regex/` string is evaluated 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.
~~**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**
- **Multi-context simulation** — run multiple contexts in one invocation and print a comparison table:
```bash
glint check --context branch=main --context branch=develop --context tag=v1.0.0 .gitlab-ci.yml
```
- **Context-scoped linting** — skip `needs:`/`dependencies:` cross-checks for jobs that are statically unreachable in the given context
- **`rules:changes:` evaluation** — path glob evaluation against the local git tree
- ~~**Single-context simulation**~~ — ✓ shipped v0.2.0; `--branch`, `--tag`, `--source`, `--var` flags on both subcommands; jobs classified as active / manual / skipped
- ~~**`workflow:rules:variables:` propagation**~~ — ✓ shipped post-v0.2.0; variables from the matching workflow rule entry injected into the evaluation context before job `rules:if:` expressions are evaluated
- ~~**Expression evaluator: multi-line `if:` values**~~ — ✓ shipped post-v0.2.0; newlines in block-scalar and folded YAML `if:` values treated as whitespace
- ~~**Expression evaluator: `${VAR}` syntax**~~ — ✓ shipped post-v0.2.0; `${CI_COMMIT_BRANCH}` equivalent to `$CI_COMMIT_BRANCH` everywhere
- ~~**Expression evaluator: regex flags**~~ — ✓ shipped post-v0.2.0; `/pattern/i`, `/pattern/m`, `/pattern/s` supported
- ~~**Expression evaluator: variable as regex RHS**~~ — ✓ shipped post-v0.2.0; `$BRANCH =~ $PATTERN` where `$PATTERN` holds a `/regex/` string evaluates correctly
- ~~**Expression evaluator: bare `true`/`false` and integer literals**~~ — ✓ shipped post-v0.2.0; `$FLAG == true`, `$COUNT == 4` compare as decimal strings matching GitLab CI behaviour
- ~~**Implicit default context**~~ — ✓ shipped v0.2.11; defaults to `--branch main --source push` when no context flag is given, so `rules:if:` is always evaluated
- ~~**`--list-vars` debug flag**~~ — ✓ shipped v0.2.11; prints sorted `KEY=VALUE` of all collected pipeline variables (root file + includes + workflow-rule union + effective context) to stderr
- ~~**Variable expansion**~~ — ✓ shipped v0.2.13; `$VAR`/`${VAR}` references within variable values expanded after all sources are merged; transitive chains resolved; visible in `--list-vars`
- ~~**Non-string scalar variables**~~ — ✓ shipped v0.2.13; `BUILD: true`, `RETRIES: 3` rendered and injected correctly instead of being silently dropped
- ~~**YAML `\/` escape in double-quoted strings**~~ — ✓ shipped v0.2.13; regex patterns like `/^us\//` in double-quoted `if:` blocks no longer cause a parse error
- ~~**Workflow rule strict evaluation**~~ — ✓ shipped v0.2.14; unparseable `if:` skips the rule instead of matching everything; prevents wrong variables being injected
- ~~**Single `=` operator**~~ — ✓ shipped v0.2.14; bare `=` accepted as alias for `==` in `rules:if:` expressions
- ~~**`rules:changes:` evaluation**~~ — ✓ shipped v0.2.21; `--changes PATH` and `--changes-from REF` flags; doublestar glob matching (`*` within segment, `**` across segments); permissive when no file list provided
- ~~**Multi-context simulation**~~ — ✓ shipped v0.2.22; `--context KEY=VALUE[,...]`; repeatable; prints a comparison table of `active`/`manual`/`skipped`/`blocked` per job across all contexts
- ~~**Context-scoped linting**~~ — ✓ shipped v0.2.23; jobs evaluated as `JobSkipped` in the supplied context are excluded from `needs:`/`dependencies:` cross-checks (GL027GL031) to eliminate false-positive errors for conditionally-gated jobs
---
@@ -70,7 +42,7 @@ The current rule set covers the most common sources of broken pipelines. These a
- ~~**Duplicate stage names (GL040)**~~ — ✓ shipped v0.2.16; warns when a stage appears more than once in `stages:`
- ~~**`cache:key:files` must be exact paths (GL041)**~~ — ✓ shipped v0.2.16; warns when entries look like glob patterns
- ~~**Unreachable jobs**~~ — covered by GL033 (shipped v0.2.15); every-`when:never` rules block is statically dead
- **`inherit:` completeness** — flag when a job overrides a default field that would require `inherit: default: false` to suppress
- ~~**`inherit:` completeness (GL043)**~~ — ✓ shipped v0.2.20; warns when `inherit: default:` is declared but there's no `default:` block, or list form names fields not set in `default:`
---
@@ -99,12 +71,14 @@ The SVG renderer and terminal tree cover the basic layout. These would bring it
- ~~**Terminal job tree**~~ — ✓ shipped in v0.2.0 as `glint graph tree`; stages as branches, jobs as leaves, context-aware annotations
- ~~**`glint graph includes` shows jobs per file**~~ — ✓ shipped post-v0.2.0; each include node shows the jobs it defines as dashed-arrow rounded nodes in a distinct style
- **Multi-job connector accuracy** — draw one connector per job pair rather than one per stage pair in classic mode, so pipelines with uneven columns look correct
- **Job tooltip / detail panel** — embed a hidden `<title>` and `<desc>` per chip so SVG viewers show `stage`, `when`, `image`, and `needs` on hover
- **`when: on_failure` visual distinction** — dashed border or distinct icon for failure-path jobs
- **Blocked / skipped state colouring** — grey out jobs that are statically unreachable given known `rules:` conditions
- **Interactive HTML output** self-contained `.html` file with pan/zoom and a job-detail sidebar; no external dependencies
- **Mermaid pipeline output** — keep `pipeline.go` but wire it up through `--graph pipeline --format mermaid` for users who want to paste into mermaid.live
- ~~**Multi-job connector accuracy**~~✓ shipped v0.2.25; classic mode uses a bus-bar pattern (vertical rail at the midpoint + horizontal stubs per job) instead of a single center-to-center line, so uneven columns look correct
- ~~**Job tooltip / detail panel**~~✓ shipped v0.2.25; each chip is wrapped in `<g data-job="…"><title>…</title><desc>…</desc>` SVG viewers show stage, when, image, and needs on hover; HTML output uses the data for the sidebar
- ~~**`when: on_failure` visual distinction**~~ ✓ shipped v0.2.25; dashed chip border + X-mark icon + red circle (`#d9534f`); legend entry added; Mermaid `on_failure` class wired
- ~~**Blocked / skipped state colouring**~~✓ shipped v0.2.25; `glint graph pipeline` accepts context flags (`--branch`, `--tag`, etc.); jobs evaluated as skipped are greyed out (`#868686`) with dimmed text and no icon
- ~~**Interactive HTML output**~~ — ✓ shipped v0.2.25; `glint graph pipeline --format html` writes a self-contained `.html` file with mouse pan/zoom and a click-to-open job-detail sidebar; no external dependencies
- ~~**Mermaid pipeline output**~~✓ shipped v0.2.25; `glint graph pipeline --format mermaid` prints a Mermaid flowchart to stdout (paste into mermaid.live)
- ~~**Same-stage job ordering**~~ — ✓ shipped v0.2.26; jobs within a stage that have `needs:` between each other are placed in topological sub-columns (left-to-right by depth); stage header spans all sub-columns
- ~~**Graph links rendered behind job chips**~~ — ✓ shipped v0.2.26; SVG connectors (Bézier curves and bus-bar stubs) are drawn before job chips so lines pass behind rectangles
---
@@ -118,7 +92,7 @@ The SVG renderer and terminal tree cover the basic layout. These would bring it
- ~~**`needs: optional: true` false-positive errors**~~ — ✓ shipped post-v0.2.0; optional missing needs are downgraded to `[WARNING]`
- ~~**`extends:` jobs with missing script false errors**~~ — ✓ shipped post-v0.2.0; jobs using `extends:` that have no `script` after resolution emit `[WARNING]` (the script may come from an unfetchable remote base)
- **`rules:if:` static reachability** — report when a job's entire `rules:` block can never evaluate to `when: on_success` given the declared pipeline variables (pure static, no context required)
- ~~**`rules:if:` static reachability (GL042)**~~ — ✓ shipped v0.2.20; warns when all `rules:if:` conditions evaluate to false given declared variable values (only fires when all referenced vars are declared in YAML)
---
@@ -141,9 +115,9 @@ The SVG renderer and terminal tree cover the basic layout. These would bring it
## Reliability and developer experience
- ~~**Structured rule IDs**~~ — ✓ shipped post-v0.2.0; GL001GL031 assigned; GL032 added v0.2.11; GL033 added v0.2.15; GL034GL041 added v0.2.16; output formats (--format json/sarif/junit/github) added v0.2.18
- **`--explain <rule-id>`** — print the rule description, rationale, and an example fix
- ~~**Structured rule IDs**~~ — ✓ shipped post-v0.2.0; GL001GL031 assigned; GL032 added v0.2.11; GL033 added v0.2.15; GL034GL041 added v0.2.16; output formats (--format json/sarif/junit/github) added v0.2.18; GL042GL043 added v0.2.20; GL044 added v0.2.24; graph improvements shipped v0.2.25v0.2.26
- ~~**`glint explain <rule-id>`**~~ — ✓ shipped v0.2.20; prints rule description, rationale, bad-YAML example, and fix; `glint explain` (no arg) lists all rules
- ~~**Semantic versioning and first release**~~ — shipped as `v0.1.0` (2026-06-07)
- ~~**Subcommand CLI**~~ — shipped as `v0.2.0` (2026-06-11); `glint check` / `glint graph [mode]` with ruff-style `--help`
- **Changelog automation** — generate release notes from Conventional Commits via `git-cliff` or similar
- **Fuzz testing** — add a `go test -fuzz` target for the YAML parser to harden it against malformed input
- ~~**Changelog automation**~~✓ shipped v0.2.27; `cliff.toml` configures git-cliff to produce Keep-a-Changelogcompatible release notes from Conventional Commits; `task changelog` regenerates `CHANGELOG.md`, `task changelog-next` previews unreleased entries
- ~~**Fuzz testing**~~✓ shipped v0.2.27; `FuzzParseBytes` and `FuzzSanitizeYAMLEscapes` in `internal/model/fuzz_test.go`; seeds run as regular tests in CI; `task fuzz` runs them continuously (default 30 s)
+32
View File
@@ -107,6 +107,24 @@ tasks:
ignore_error: false
- cmd: ./{{.BINARY}} check testdata/config_suppress/.gitlab-ci.yml
ignore_error: false
- cmd: ./{{.BINARY}} check testdata/static_dead_rules.yml
ignore_error: false
- cmd: ./{{.BINARY}} check testdata/inherit_dead.yml
ignore_error: false
- cmd: ./{{.BINARY}} check testdata/inherit_dead_fields.yml
ignore_error: false
- cmd: ./{{.BINARY}} check testdata/rules_needs_valid.yml
ignore_error: false
- cmd: ./{{.BINARY}} check testdata/rules_needs_invalid.yml
ignore_error: true
- cmd: ./{{.BINARY}} explain GL007
ignore_error: false
- cmd: ./{{.BINARY}} explain gl042
ignore_error: false
- cmd: ./{{.BINARY}} explain GL044
ignore_error: false
- cmd: ./{{.BINARY}} explain
ignore_error: false
lint-go:
desc: Run go vet on all packages
@@ -157,6 +175,20 @@ tasks:
generates:
- "{{.BINARY}}-{{.TAG}}-linux-amd64"
fuzz:
desc: "Run fuzz tests for the YAML parser and sanitizer (set FUZZ_TIME=60s to control duration, default 30s)"
cmds:
- "{{.GO}} test -fuzz=FuzzParseBytes -fuzztime=${FUZZ_TIME:-30s} ./internal/model/"
- "{{.GO}} test -fuzz=FuzzSanitizeYAMLEscapes -fuzztime=${FUZZ_TIME:-30s} ./internal/model/"
changelog:
desc: "Regenerate CHANGELOG.md from git history (requires git-cliff — see README)"
cmd: git cliff --config cliff.toml --output CHANGELOG.md
changelog-next:
desc: "Preview unreleased changelog entries without writing (requires git-cliff)"
cmd: git cliff --config cliff.toml --unreleased
clean:
desc: Remove build artifacts
cmd: rm -f {{.BINARY}} {{.BINARY}}-*.exe {{.BINARY}}-*-linux-amd64
+345
View File
@@ -0,0 +1,345 @@
# glint — usage reference
Full examples and option descriptions for every command.
For the lint rules reference see [FEATURES.md](FEATURES.md).
---
## `glint check`
Lint a pipeline file. Exits `0` when no errors are found, `1` when at least
one error is reported (warnings alone do not fail).
```bash
glint check .gitlab-ci.yml
```
### Output formats
Pass `--format` to control the output. Plain text is the default.
```bash
# Default: ruff-style text (human-readable)
glint check .gitlab-ci.yml
# JSON — stable schema, machine-readable
glint check --format json .gitlab-ci.yml
# SARIF 2.1.0 — GitHub Code Scanning / GitLab SAST
glint check --format sarif .gitlab-ci.yml > glint.sarif
# JUnit XML — CI test-report artifact (GitLab: artifacts:reports:junit)
glint check --format junit .gitlab-ci.yml > glint-junit.xml
# GitHub Actions annotations — inline PR diff comments
glint check --format github .gitlab-ci.yml
```
In structured formats (`json`, `sarif`, `junit`, `github`) the summary line is
written to stderr so stdout contains only the machine-readable payload.
**JSON schema (`schema_version: 1`):**
```json
{
"schema_version": 1,
"glint_version": "v0.2.20",
"pipeline": ".gitlab-ci.yml",
"findings": [
{"rule":"GL004","severity":"error","file":".gitlab-ci.yml","line":14,
"job":"deploy","message":"stage \"production\" is not defined in 'stages'"}
],
"summary": {"total": 1, "errors": 1, "warnings": 0}
}
```
**GitHub annotation lines:**
```
::error file=.gitlab-ci.yml,line=14,title=GL004::job "deploy": stage "production" is not defined in 'stages'
```
### Context simulation
Pass `--branch`, `--tag`, or `--source` to evaluate `rules:if:` and
`only`/`except` against a specific pipeline event. The pipeline is still fully
linted; the context summary is printed first. When no context flag is given,
glint defaults to `--branch main --source push`.
```bash
# What runs on a push to develop?
glint check --branch develop .gitlab-ci.yml
# What runs when a v1.2.0 tag is pushed?
glint check --tag v1.2.0 .gitlab-ci.yml
# Merge request pipeline
glint check --source merge_request_event .gitlab-ci.yml
# Arbitrary variable overrides (repeatable)
glint check --branch main --var DEPLOY_ENV=production .gitlab-ci.yml
# Debug variable resolution
glint check --list-vars .gitlab-ci.yml
```
**Predefined variables** set by the shortcut flags:
| Flag | Variables populated |
|------|---------------------|
| `--branch NAME` | `CI_COMMIT_BRANCH`, `CI_COMMIT_REF_NAME`, `CI_COMMIT_REF_SLUG`, `CI_PIPELINE_SOURCE=push` |
| `--tag NAME` | `CI_COMMIT_TAG`, `CI_COMMIT_REF_NAME`, `CI_COMMIT_REF_SLUG`, `CI_PIPELINE_SOURCE=push`; clears `CI_COMMIT_BRANCH` |
| `--source EVENT` | `CI_PIPELINE_SOURCE` |
| `--var KEY=VALUE` | any variable; overrides shortcuts; repeatable |
### Remote project includes
Provide a token so glint can fetch `include: project:` templates:
```bash
# Personal access token (read_api scope)
GITLAB_TOKEN=glpat-xxxx glint check .gitlab-ci.yml
# CI/CD job token (when running inside a pipeline)
CI_JOB_TOKEN=$CI_JOB_TOKEN glint check .gitlab-ci.yml
# Self-hosted GitLab
GITLAB_TOKEN=glpat-xxxx GITLAB_URL=https://gitlab.example.com glint check .gitlab-ci.yml
# Via flags (override env vars)
glint check --token glpat-xxxx --gitlab-url https://gitlab.example.com .gitlab-ci.yml
```
Project includes are skipped with a warning when no token is available; linting
continues with whatever is resolved.
**Token resolution order** (first non-empty wins):
| Source | Header |
|--------|--------|
| `--token` flag / `GITLAB_TOKEN` env | `PRIVATE-TOKEN` |
| `CI_JOB_TOKEN` env | `JOB-TOKEN` |
| `GITLAB_PRIVATE_TOKEN` env | `PRIVATE-TOKEN` |
**URL resolution order:** `--gitlab-url` flag → `CI_SERVER_URL` env → `GITLAB_URL` env → `https://gitlab.com`
### Include cache and offline mode
```bash
# Cache fetched templates to disk (keyed by SHA-256 of the request coordinates)
glint check --cache-dir ~/.cache/glint .gitlab-ci.yml
# Fully offline — serve from cache, warn on misses, make no network calls
glint check --offline .gitlab-ci.yml
glint check --offline --cache-dir /path/to/cache .gitlab-ci.yml
```
`--offline` without `--cache-dir` uses `~/.cache/glint` automatically.
There is no automatic cache expiry; delete the directory or individual entries
to force a re-fetch.
### Component reference format
`include: component:` references follow the format:
```
<host>/<project-path>/<component-name>@<version>
gitlab.com/components/secret-detection/secret-detection@v0.1.0
gitlab.com/my-org/ci-catalog/lint@main
gitlab.example.com/platform/components/build@~latest
```
The component file is looked up in order:
1. `templates/<component-name>.yml` (single-file layout)
2. `templates/<component-name>/template.yml` (directory layout)
Public Catalog components work without a token. `with:` input parameters are
substituted locally before parsing; they are not validated against the
component spec.
### Example output
```
# Clean pipeline
Context: branch=main, source=push
Active (5): build, deploy-staging, test, lint, security-scan
OK: .gitlab-ci.yml — no issues found (5 job(s), 3 stage(s))
# With --branch develop
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 job(s), 3 stage(s))
# Pipeline with issues
.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)
```
---
## `glint graph`
Visualise the pipeline. Without a mode word, prints a job tree and the include
dependency graph separated by `---`.
```bash
# Default: job tree + include dependency graph
glint graph .gitlab-ci.yml
# Job tree only
glint graph tree .gitlab-ci.yml
# Job tree with context (shows active/manual/skipped annotations)
glint graph tree --branch develop .gitlab-ci.yml
# Include dependency graph → Mermaid flowchart to stdout
glint graph includes .gitlab-ci.yml > includes.mmd
# GitLab-like pipeline layout → PNG/SVG written to --out dir
glint graph pipeline .gitlab-ci.yml
# prints the output path, e.g.: glint-out/pipeline-20260614-143022.png
# Pipeline graph with skipped-job colouring (grey out context-unreachable jobs)
glint graph pipeline --branch main .gitlab-ci.yml
# Interactive HTML with pan/zoom and job-detail sidebar
glint graph pipeline --format html .gitlab-ci.yml
# Mermaid flowchart of the pipeline to stdout
glint graph pipeline --format mermaid .gitlab-ci.yml
# Mermaid includes graph to stdout + pipeline SVG file path to stderr
glint graph all .gitlab-ci.yml > includes.mmd
# Custom output directory
glint graph pipeline --out /tmp/graphs .gitlab-ci.yml
```
**Job tree** — stages as branches, jobs as leaves; annotated with `[manual]`,
`[delayed]`, `[trigger]` where applicable. Context flags apply the same
evaluation as `glint check`.
**Include graph** — [Mermaid](https://mermaid.js.org) flowchart; pipe to `.mmd`
or paste into [mermaid.live](https://mermaid.live). Nodes are colour-coded by
include type: orange (main file), purple (project), green (component), blue
(local), grey (remote URL), light orange (GitLab template).
**Pipeline graph** — GitLab CI-style SVG rendered to a timestamped file.
Converted to PNG when `rsvg-convert`, `inkscape`, or `magick` is available.
**Pipeline graph formats** (`--format`):
| Flag | Output |
|------|--------|
| `svg` (default) | Write SVG/PNG to `--out` |
| `html` | Write self-contained HTML to `--out` — inline SVG with mouse pan/zoom, drag, and a job-detail sidebar that opens on chip click |
| `mermaid` | Print Mermaid flowchart to stdout |
**Visual chip styles:**
| Type | Colour | Icon | Border |
|------|--------|------|--------|
| regular | blue `#1f75cb` | checkmark | solid |
| manual | orange `#fc6d26` | play triangle | solid |
| trigger | purple `#6b4fbb` | chevron | solid |
| delayed | yellow `#fca326` | clock | solid |
| on_failure | red `#d9534f` | X mark | dashed |
| skipped (context) | grey `#868686` | none | solid |
Pass context flags (`--branch`, `--tag`, `--source`, `--var`) to grey out jobs
that would be skipped in the given pipeline event.
DAG mode (Bézier arrows between jobs) activates automatically when any job has
a `needs:` list; classic mode uses stage-column connectors otherwise.
---
## `glint explain`
Print the documentation for a specific lint rule.
```bash
# Description, example, and fix for GL007
glint explain GL007
# Case-insensitive
glint explain gl007
# List all rules with ID, severity, and title
glint explain
```
---
## Project configuration (`.glint.yml`)
Place a `.glint.yml` anywhere in the directory tree from the pipeline file up
to the first `.git` boundary. glint searches upward and uses the first file it
finds.
```yaml
# Suppress rules globally for this project.
ignore:
- GL007 # migrating from only:/except:
- GL032 # dynamic variables injected by CI
# Override rule severity: error | warning | ignore
# 'ignore' is equivalent to listing the rule under ignore:.
severity:
GL004: warning # demote during a stage migration
GL035: error # promote to hard error for this project
# Extra stage names valid beyond those declared in the pipeline YAML
# (e.g. injected by an include template you don't own).
stages:
- quality
- security
# Default token — lower priority than --token and GITLAB_TOKEN.
token: glpat-xxxx
# Default GitLab instance URL.
url: https://gitlab.example.com
# Default cache directory for fetched remote includes.
cache_dir: ~/.cache/glint
```
**Priority chain:** `--token`/`--gitlab-url` flags > `.glint.yml` > environment variables.
---
## Inline suppression (`# glint: ignore`)
Suppress a finding for a specific job by placing a comment immediately before
the job definition in the pipeline YAML:
```yaml
# glint: ignore GL007
legacy-job:
stage: build
only: [main]
script: echo ok
# Multiple rules — comma- or space-separated:
# glint: ignore GL007, GL032
other-job:
stage: build
script: echo ok
# Suppress every rule for this job:
# glint: ignore all
noisy-job:
stage: build
script: echo ok
```
Suppressions are scoped to the single job they precede and do not affect other
jobs or pipeline-level findings. For project-wide suppression, use `.glint.yml`
`ignore:` instead.
+65
View File
@@ -0,0 +1,65 @@
# git-cliff configuration for glint
# Install: brew install git-cliff OR cargo install git-cliff
# Usage: task changelog -- regenerate full CHANGELOG.md
# task changelog-next -- preview unreleased section (dry-run)
[changelog]
header = """
# Changelog
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).
"""
body = """
{% if version %}\
## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
{% else %}\
## [Unreleased]
{% endif %}\
{% for group, commits in commits | group_by(attribute="group") %}\
### {{ group | upper_first }}
{% for commit in commits %}\
- {% if commit.scope %}**{{ commit.scope }}**: {% endif %}\
{{ commit.message | upper_first }}\
{% if commit.breaking %} **[BREAKING]**{% endif %}
{% endfor %}\
{% endfor %}\
"""
trim = true
footer = ""
postprocessors = []
[git]
conventional_commits = true
filter_unconventional = true
split_commits = false
commit_preprocessors = [
# Drop Co-Authored-By trailers (should not appear in subjects, but guard anyway).
{ pattern = "Co-Authored-By:.*", replace = "" },
]
commit_parsers = [
# Breaking changes (type! or scope!) — promote above everything else.
{ message = "^[a-z]+\\([a-z-]+\\)!:|^[a-z]+!:", group = "Breaking Changes" },
{ message = "^feat", group = "Added" },
{ message = "^fix", group = "Fixed" },
{ message = "^perf", group = "Changed" },
{ message = "^refactor", group = "Changed" },
# Maintenance commits — omit from the changelog body.
{ message = "^docs", skip = true },
{ message = "^style", skip = true },
{ message = "^test", skip = true },
{ message = "^chore", skip = true },
{ message = "^build", skip = true },
{ message = "^claude", skip = true },
]
protect_breaking_commits = false
filter_commits = true
tag_pattern = "v[0-9].*"
topo_order = false
sort_commits = "oldest"
+70
View File
@@ -0,0 +1,70 @@
package main
import (
"fmt"
"os"
"sort"
"strings"
"git.k3nny.fr/glint/internal/linter"
)
func cmdExplain(args []string) {
if len(args) == 0 {
printRuleList()
return
}
ruleID := strings.ToUpper(args[0])
entry, ok := linter.RuleCatalog[ruleID]
if !ok {
fmt.Fprintf(os.Stderr, "glint explain: unknown rule %q\n\nRun 'glint explain' to list all rules.\n", ruleID)
exit(2)
return
}
printRuleEntry(ruleID, entry)
}
func printRuleEntry(id string, e linter.RuleEntry) {
sev := strings.ToLower(string(e.Severity))
header := fmt.Sprintf("%s [%s] %s", id, sev, e.Title)
rule := fmt.Sprintf("\n%s\n%s\n\n%s\n",
header,
strings.Repeat("─", len(header)),
e.Description,
)
fmt.Print(rule)
if e.Example != "" {
fmt.Println("Example:")
fmt.Println()
for _, line := range strings.Split(e.Example, "\n") {
fmt.Printf(" %s\n", line)
}
fmt.Println()
}
if e.Fix != "" {
fmt.Println("Fix:")
fmt.Println()
for _, line := range strings.Split(e.Fix, "\n") {
fmt.Printf(" %s\n", line)
}
fmt.Println()
}
}
func printRuleList() {
ids := make([]string, 0, len(linter.RuleCatalog))
for id := range linter.RuleCatalog {
ids = append(ids, id)
}
sort.Strings(ids)
fmt.Printf("glint %s — lint rules\n\n", version)
for _, id := range ids {
e := linter.RuleCatalog[id]
sev := strings.ToLower(string(e.Severity))
fmt.Printf(" %-6s [%-7s] %s\n", id, sev, e.Title)
}
fmt.Println("\nUse 'glint explain <RULE>' for details on a specific rule.")
}
+347 -32
View File
@@ -4,6 +4,7 @@ import (
"flag"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
@@ -20,18 +21,46 @@ import (
// version is set at build time via -ldflags "-X main.version=vX.Y.Z".
var version = "dev"
// exit is a variable so tests can capture exit calls without terminating.
var exit = os.Exit
// userHomeDirFn is a variable so tests can simulate UserHomeDir failure.
var userHomeDirFn = os.UserHomeDir
// defaultCacheDir returns the platform-default glint cache directory:
// $XDG_CACHE_HOME/glint or ~/.cache/glint.
func defaultCacheDir() string {
if xdg := os.Getenv("XDG_CACHE_HOME"); xdg != "" {
return filepath.Join(xdg, "glint")
}
if home, err := os.UserHomeDir(); err == nil {
if home, err := userHomeDirFn(); err == nil {
return filepath.Join(home, ".cache", "glint")
}
return ""
}
// execCommandOutput is a variable so tests can mock external command execution.
var execCommandOutput = func(name string, args ...string) ([]byte, error) {
return exec.Command(name, args...).Output()
}
// gitDiffFiles runs "git diff --name-only <ref>" and returns the list of changed
// file paths. Returns nil + error when the command fails (e.g. not in a git repo
// or the ref doesn't exist).
func gitDiffFiles(ref string) ([]string, error) {
out, err := execCommandOutput("git", "diff", "--name-only", ref)
if err != nil {
return nil, fmt.Errorf("git diff --name-only %s: %w", ref, err)
}
var files []string
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
if line != "" {
files = append(files, line)
}
}
return files, nil
}
const globalUsage = `glint: Lint and visualise GitLab CI pipelines locally.
Usage: glint [OPTIONS] <COMMAND>
@@ -39,6 +68,7 @@ Usage: glint [OPTIONS] <COMMAND>
Commands:
check Lint a pipeline file — exits 0 (clean) or 1 (errors found)
graph Visualise the pipeline as a job tree or Mermaid graph
explain Show description and fix for a lint rule (e.g. glint explain GL007)
Options:
-h, --help Print help
@@ -50,13 +80,16 @@ For help with a specific command, see: ` + "`glint <command> --help`" + `.
func main() {
if len(os.Args) < 2 {
fmt.Fprint(os.Stderr, globalUsage)
os.Exit(2)
exit(2)
return
}
switch os.Args[1] {
case "check":
cmdCheck(os.Args[2:])
case "graph":
cmdGraph(os.Args[2:])
case "explain":
cmdExplain(os.Args[2:])
case "-h", "--help", "help":
fmt.Fprintf(os.Stderr, "glint %s\n\n", version)
fmt.Fprint(os.Stderr, globalUsage)
@@ -64,7 +97,7 @@ func main() {
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)
exit(2)
}
}
@@ -90,6 +123,11 @@ func cmdCheck(args []string) {
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")
var changesFiles multiFlag
fs.Var(&changesFiles, "changes", "mark a file path as changed for rules:changes: evaluation; repeatable")
changesFrom := fs.String("changes-from", "", "git ref to diff against for rules:changes: evaluation (e.g. HEAD~1, origin/main)")
var contexts multiFlag
fs.Var(&contexts, "context", "simulation context as KEY=VALUE[,...]; repeatable for multi-context comparison table")
fs.Usage = func() {
fmt.Fprintf(os.Stderr, "glint %s\n\n", version)
fmt.Fprint(os.Stderr, `Lint a GitLab CI pipeline file.
@@ -144,6 +182,25 @@ Options:
Set or override a CI variable. Takes precedence over --branch, --tag,
and --source. Repeatable.
--changes <PATH>
Mark a file as changed for rules:changes: evaluation. Repeatable.
When given, only jobs whose rules:changes: patterns match at least one
--changes path will have that rule fire; without --changes or
--changes-from the condition is treated as always matching (permissive).
--changes-from <REF>
Run "git diff --name-only <REF>" to determine changed files for
rules:changes: evaluation. Combined with --changes if both are given.
--context <KEY=VALUE[,...]>
Define a simulation context. Repeatable: each --context flag adds one
column to a comparison table showing every job's state across contexts.
Known keys: branch, tag, source. Any other KEY=VALUE is treated as a
CI variable override. Examples:
--context branch=main --context branch=develop
--context tag=v1.0.0
--context branch=main,DEPLOY_ENV=prod
--list-vars
Print all pipeline-level variables collected from the root file and
every included file (sorted KEY=VALUE) to stderr, then continue
@@ -171,12 +228,16 @@ Examples:
glint check --branch main --var DEPLOY_ENV=production .gitlab-ci.yml
glint check --cache-dir ~/.cache/glint .gitlab-ci.yml
glint check --offline --cache-dir ~/.cache/glint .gitlab-ci.yml
glint check --changes src/main.go --changes Dockerfile .gitlab-ci.yml
glint check --changes-from origin/main .gitlab-ci.yml
glint check --context branch=main --context branch=develop .gitlab-ci.yml
glint check --context branch=main --context tag=v1.0.0 --context source=schedule .gitlab-ci.yml
`)
}
_ = fs.Parse(args)
// Apply implicit defaults when no context flag is given at all.
if *branch == "" && *tag == "" && *source == "" && len(vars) == 0 {
// Apply implicit defaults only in single-context mode when no flags are given.
if len(contexts) == 0 && *branch == "" && *tag == "" && *source == "" && len(vars) == 0 {
*branch = "main"
*source = "push"
}
@@ -186,12 +247,14 @@ Examples:
}
if !validFormats[*format] {
fmt.Fprintf(os.Stderr, "glint: unknown format %q; valid: text, json, sarif, junit, github\n", *format)
os.Exit(2)
exit(2)
return
}
if fs.NArg() != 1 {
fs.Usage()
os.Exit(2)
exit(2)
return
}
path := fs.Arg(0)
rootDir := filepath.Dir(filepath.Clean(path))
@@ -226,7 +289,8 @@ Examples:
p, err := model.Parse(path)
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(2)
exit(2)
return
}
// Merge config-defined stages into the pipeline before linting so that
@@ -250,28 +314,85 @@ Examples:
extWarnings, err := resolver.Resolve(p)
if err != nil {
fmt.Fprintf(os.Stderr, "error: resolving extends: %v\n", err)
os.Exit(2)
exit(2)
return
}
for _, w := range extWarnings {
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() {
if !enrichContext(ctx, p) {
fmt.Fprintf(os.Stderr, "%s: [warning] workflow:rules: pipeline would not start for this context\n", path)
// Compute changed files once; shared across single and multi-context modes.
var allChanged []string
var changesReliable bool
if *changesFrom != "" || len(changesFiles) > 0 {
changesReliable = len(changesFiles) > 0
if *changesFrom != "" {
files, err := gitDiffFiles(*changesFrom)
if err != nil {
fmt.Fprintf(os.Stderr, "%s: [warning] --changes-from: %v\n", path, err)
} else {
allChanged = append(allChanged, files...)
changesReliable = true
}
}
allChanged = append(allChanged, changesFiles...)
if changesReliable && allChanged == nil {
allChanged = []string{}
}
}
if *listVars {
printVars(p, ctx)
}
// Context summary only makes sense in plain-text output; suppress it in
// structured formats so stdout contains only the machine-readable payload.
if !ctx.IsEmpty() && *format == "text" {
printContext(p, ctx)
var skipped map[string]bool // jobs excluded from cross-job lint checks
if len(contexts) > 0 {
// Multi-context mode: build one context per --context flag, then print a comparison table.
ctxList := make([]*cicontext.Context, 0, len(contexts))
runs := make([]bool, 0, len(contexts))
for _, spec := range contexts {
cb, ct, cs, cv := parseContextSpec(spec)
c := cicontext.New(cb, ct, cs, cv)
if changesReliable {
c.SetChangedFiles(allChanged)
}
ran := enrichContext(c, p)
ctxList = append(ctxList, c)
runs = append(runs, ran)
}
if *format == "text" {
printContextTable(p, ctxList, contexts, runs)
}
} else {
// Single-context mode: existing flow.
ctx := cicontext.New(*branch, *tag, *source, vars)
if changesReliable {
ctx.SetChangedFiles(allChanged)
}
if !ctx.IsEmpty() {
if !enrichContext(ctx, p) {
fmt.Fprintf(os.Stderr, "%s: [warning] workflow:rules: pipeline would not start for this context\n", path)
}
// Build skipped set: jobs statically unreachable in this context are
// excluded from needs:/dependencies: cross-checks to avoid false positives.
skipped = make(map[string]bool)
for name, job := range p.Jobs {
if cicontext.EvalJob(job, ctx) == cicontext.JobSkipped {
skipped[name] = true
}
}
if len(skipped) == 0 {
skipped = nil
}
}
if *listVars {
printVars(p, ctx)
}
// Context summary only makes sense in plain-text output; suppress it in
// structured formats so stdout contains only the machine-readable payload.
if !ctx.IsEmpty() && *format == "text" {
printContext(p, ctx)
}
}
findings := linter.Lint(p)
findings := linter.Lint(p, skipped)
findings = applyConfig(findings, glintCfg, p.Suppressions)
errCount, _ := countSeverities(findings)
@@ -303,7 +424,7 @@ Examples:
}
if errCount > 0 {
os.Exit(1)
exit(1)
}
}
@@ -324,7 +445,8 @@ func cmdGraph(args []string) {
gitlabURL := fs.String("gitlab-url", "", "GitLab instance URL (overrides CI_SERVER_URL / GITLAB_URL)")
cacheDir := fs.String("cache-dir", "", "directory to cache fetched remote includes (created if needed)")
offline := fs.Bool("offline", false, "skip all network calls; serve only from --cache-dir")
out := fs.String("out", "glint-out", "output directory for Mermaid graph files (pipeline mode)")
out := fs.String("out", "glint-out", "output directory for rendered graph files (pipeline mode)")
format := fs.String("format", "svg", "pipeline output format: svg, mermaid, or html")
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.
@@ -341,6 +463,15 @@ Options:
Output directory for rendered graph files.
Used by the pipeline and all modes only. [default: glint-out]
--format <FORMAT>
Output format for pipeline mode: svg (default), mermaid, or html.
svg: write a GitLab CI-style SVG/PNG to --out (converted to PNG
when rsvg-convert, inkscape, or magick is available).
mermaid: print a Mermaid flowchart to stdout (paste into mermaid.live).
html: write a self-contained HTML file with pan/zoom and a
job-detail sidebar to --out.
[default: svg] [possible values: svg, mermaid, html]
--token <TOKEN>
GitLab personal access token. Used to fetch remote project: includes
when building the include dependency graph.
@@ -368,6 +499,13 @@ Options:
--var <KEY=VALUE>
Set or override a CI variable. Repeatable.
--changes <PATH>
Mark a file path as changed for rules:changes: evaluation. Repeatable.
--changes-from <REF>
Run "git diff --name-only <REF>" to determine changed files for
rules:changes: evaluation.
--list-vars
Print all pipeline-level variables collected from the root file and
every included file (sorted KEY=VALUE) to stderr, then continue
@@ -386,9 +524,12 @@ Examples:
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 tree --changes src/main.go .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
glint graph pipeline --format mermaid .gitlab-ci.yml
glint graph pipeline --format html .gitlab-ci.yml
glint graph all .gitlab-ci.yml > includes.mmd
`)
}
@@ -398,6 +539,9 @@ Examples:
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")
var changesFiles multiFlag
fs.Var(&changesFiles, "changes", "mark a file path as changed for rules:changes: evaluation; repeatable")
changesFrom := fs.String("changes-from", "", "git ref to diff against for rules:changes: evaluation (e.g. HEAD~1, origin/main)")
_ = fs.Parse(args)
// Apply implicit defaults when no context flag is given at all.
@@ -408,7 +552,8 @@ Examples:
if fs.NArg() != 1 {
fs.Usage()
os.Exit(2)
exit(2)
return
}
path := fs.Arg(0)
@@ -422,7 +567,8 @@ Examples:
p, err := model.Parse(path)
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(2)
exit(2)
return
}
rootDir := filepath.Dir(filepath.Clean(path))
@@ -430,6 +576,27 @@ Examples:
resolver.Resolve(p) //nolint:errcheck
ctx := cicontext.New(*branch, *tag, *source, vars)
// Wire up rules:changes: evaluation when file-change data is provided.
if *changesFrom != "" || len(changesFiles) > 0 {
var allChanged []string
reliable := len(changesFiles) > 0
if *changesFrom != "" {
files, err := gitDiffFiles(*changesFrom)
if err != nil {
fmt.Fprintf(os.Stderr, "%s: [warning] --changes-from: %v\n", path, err)
} else {
allChanged = append(allChanged, files...)
reliable = true
}
}
allChanged = append(allChanged, changesFiles...)
if reliable {
if allChanged == nil {
allChanged = []string{}
}
ctx.SetChangedFiles(allChanged)
}
}
if !ctx.IsEmpty() {
enrichContext(ctx, p)
}
@@ -447,18 +614,33 @@ Examples:
case "includes":
fmt.Print(graph.Includes(path, p.Include, cfg))
case "pipeline":
outPath, err := graph.RenderPipeline(p, *out)
if err != nil {
fmt.Fprintf(os.Stderr, "error: rendering pipeline graph: %v\n", err)
os.Exit(2)
switch *format {
case "mermaid":
fmt.Print(graph.Pipeline(p))
case "html":
outPath, err := graph.RenderHTML(p, *out, ctx)
if err != nil {
fmt.Fprintf(os.Stderr, "error: rendering pipeline graph: %v\n", err)
exit(2)
return
}
fmt.Println(outPath)
default: // "svg"
outPath, err := graph.RenderPipeline(p, *out, ctx)
if err != nil {
fmt.Fprintf(os.Stderr, "error: rendering pipeline graph: %v\n", err)
exit(2)
return
}
fmt.Println(outPath)
}
fmt.Println(outPath)
case "all":
fmt.Print(graph.Includes(path, p.Include, cfg))
outPath, err := graph.RenderPipeline(p, *out)
outPath, err := graph.RenderPipeline(p, *out, ctx)
if err != nil {
fmt.Fprintf(os.Stderr, "error: rendering pipeline graph: %v\n", err)
os.Exit(2)
exit(2)
return
}
fmt.Fprintln(os.Stderr, outPath)
}
@@ -570,3 +752,136 @@ func printJobGroup(label string, jobs []string) {
}
fmt.Printf("%s (%d): %s\n", label, len(jobs), strings.Join(jobs, ", "))
}
// parseContextSpec parses "branch=main,DEPLOY_ENV=prod" into its parts.
// Known keys (branch, tag, source) are extracted; everything else goes into extraVars.
func parseContextSpec(spec string) (branch, tag, source string, extraVars []string) {
for _, kv := range strings.Split(spec, ",") {
kv = strings.TrimSpace(kv)
if kv == "" {
continue
}
parts := strings.SplitN(kv, "=", 2)
key := parts[0]
val := ""
if len(parts) == 2 {
val = parts[1]
}
switch strings.ToLower(key) {
case "branch":
branch = val
case "tag":
tag = val
case "source":
source = val
default:
extraVars = append(extraVars, kv)
}
}
return
}
// sortedJobNames returns non-hidden job names ordered by stage position, then alphabetically.
func sortedJobNames(p *model.Pipeline) []string {
stageIdx := make(map[string]int, len(p.Stages))
for i, s := range p.Stages {
stageIdx[s] = i
}
type entry struct {
name string
stage int
}
entries := make([]entry, 0, len(p.Jobs))
for name, job := range p.Jobs {
if strings.HasPrefix(name, ".") {
continue
}
idx, ok := stageIdx[job.Stage]
if !ok {
idx = len(p.Stages)
}
entries = append(entries, entry{name: name, stage: idx})
}
sort.Slice(entries, func(i, j int) bool {
if entries[i].stage != entries[j].stage {
return entries[i].stage < entries[j].stage
}
return entries[i].name < entries[j].name
})
names := make([]string, len(entries))
for i, e := range entries {
names[i] = e.name
}
return names
}
// printContextTable prints a side-by-side comparison of job states across contexts.
func printContextTable(p *model.Pipeline, ctxs []*cicontext.Context, labels []string, runs []bool) {
jobs := sortedJobNames(p)
if len(jobs) == 0 {
return
}
// Evaluate all jobs for all contexts up front so we can compute column widths.
states := make([][]string, len(jobs))
for r, name := range jobs {
states[r] = make([]string, len(ctxs))
for c, ctx := range ctxs {
if !runs[c] {
states[r][c] = "blocked"
} else {
switch cicontext.EvalJob(p.Jobs[name], ctx) {
case cicontext.JobActive:
states[r][c] = "active"
case cicontext.JobManual:
states[r][c] = "manual"
default:
states[r][c] = "skipped"
}
}
}
}
// Compute column widths.
jobCol := len("JOB")
for _, name := range jobs {
if len(name) > jobCol {
jobCol = len(name)
}
}
ctxCols := make([]int, len(labels))
for i, lbl := range labels {
ctxCols[i] = len(lbl)
}
for r := range jobs {
for c, s := range states[r] {
if len(s) > ctxCols[c] {
ctxCols[c] = len(s)
}
}
}
// Print header.
fmt.Println("Context comparison:")
fmt.Println()
fmt.Printf("%-*s", jobCol, "JOB")
for i, lbl := range labels {
fmt.Printf(" %-*s", ctxCols[i], lbl)
}
fmt.Println()
fmt.Print(strings.Repeat("-", jobCol))
for _, w := range ctxCols {
fmt.Print(" " + strings.Repeat("-", w))
}
fmt.Println()
// Print rows.
for r, name := range jobs {
fmt.Printf("%-*s", jobCol, name)
for c, s := range states[r] {
fmt.Printf(" %-*s", ctxCols[c], s)
}
fmt.Println()
}
fmt.Println()
}
File diff suppressed because it is too large Load Diff
+291
View File
@@ -0,0 +1,291 @@
package cicontext
import (
"testing"
"git.k3nny.fr/glint/internal/model"
)
// ── doublestarMatch ───────────────────────────────────────────────────────────
func TestDoublestarMatch(t *testing.T) {
cases := []struct {
pattern string
path string
want bool
}{
// exact match
{"Dockerfile", "Dockerfile", true},
{"Dockerfile", "dockerfile", false},
// single-segment wildcard
{"*.go", "main.go", true},
{"*.go", "main.py", false},
// * does not cross /
{"*.go", "src/main.go", false},
// ** matches multiple segments
{"**/*.go", "src/main.go", true},
{"**/*.go", "src/pkg/util.go", true},
{"**/*.go", "src/main.py", false},
// ** at end matches everything
{"src/**", "src/main.go", true},
{"src/**", "src/a/b/c.go", true},
{"src/**", "other/main.go", false},
// ** in the middle
{"src/**/*.go", "src/pkg/main.go", true},
{"src/**/*.go", "src/a/b/main.go", true},
{"src/**/*.go", "test/pkg/main.go", false},
// bare ** matches everything
{"**", "anything/and/everything.txt", true},
{"**", "file.go", true},
// no wildcard, multi-segment
{"src/main.go", "src/main.go", true},
{"src/main.go", "src/other.go", false},
// ** matches zero segments too
{"src/**/main.go", "src/main.go", true},
{"src/**/main.go", "src/pkg/main.go", true},
// malformed pattern (gracefully handled)
{"[invalid", "file.go", false},
}
for _, tc := range cases {
t.Run(tc.pattern+"~"+tc.path, func(t *testing.T) {
got := doublestarMatch(tc.pattern, tc.path)
if got != tc.want {
t.Errorf("doublestarMatch(%q, %q) = %v; want %v", tc.pattern, tc.path, got, tc.want)
}
})
}
}
// ── extractChangesPaths ───────────────────────────────────────────────────────
func TestExtractChangesPaths(t *testing.T) {
t.Run("nil → nil", func(t *testing.T) {
if extractChangesPaths(nil) != nil {
t.Error("expected nil")
}
})
t.Run("string", func(t *testing.T) {
got := extractChangesPaths("Dockerfile")
if len(got) != 1 || got[0] != "Dockerfile" {
t.Errorf("got %v", got)
}
})
t.Run("[]string", func(t *testing.T) {
got := extractChangesPaths([]string{"a.go", "b.go"})
if len(got) != 2 || got[0] != "a.go" || got[1] != "b.go" {
t.Errorf("got %v", got)
}
})
t.Run("[]any strings", func(t *testing.T) {
got := extractChangesPaths([]any{"src/**", "*.yml"})
if len(got) != 2 || got[0] != "src/**" || got[1] != "*.yml" {
t.Errorf("got %v", got)
}
})
t.Run("[]any ignores non-strings", func(t *testing.T) {
got := extractChangesPaths([]any{"ok", 42})
if len(got) != 1 || got[0] != "ok" {
t.Errorf("got %v", got)
}
})
t.Run("map with paths key", func(t *testing.T) {
got := extractChangesPaths(map[string]any{
"paths": []any{"src/**"},
"compare_to": "origin/main",
})
if len(got) != 1 || got[0] != "src/**" {
t.Errorf("got %v", got)
}
})
t.Run("map without paths key → nil", func(t *testing.T) {
got := extractChangesPaths(map[string]any{"compare_to": "origin/main"})
if got != nil {
t.Errorf("expected nil, got %v", got)
}
})
t.Run("unknown type → nil", func(t *testing.T) {
got := extractChangesPaths(12345)
if got != nil {
t.Errorf("expected nil, got %v", got)
}
})
}
// ── changesMatch ──────────────────────────────────────────────────────────────
func TestChangesMatch(t *testing.T) {
t.Run("nil changes → always true", func(t *testing.T) {
ctx := New("main", "", "", nil)
ctx.SetChangedFiles([]string{"anything.go"})
if !changesMatch(nil, ctx) {
t.Error("nil changes should always match")
}
})
t.Run("nil changedFiles → permissive true", func(t *testing.T) {
ctx := New("main", "", "", nil)
// changedFiles is nil by default → permissive
if !changesMatch([]any{"src/**"}, ctx) {
t.Error("nil changedFiles should be permissive (true)")
}
})
t.Run("no match → false", func(t *testing.T) {
ctx := New("main", "", "", nil)
ctx.SetChangedFiles([]string{"test/main_test.go"})
if changesMatch([]any{"src/**/*.go"}, ctx) {
t.Error("should not match: changed file not under src/")
}
})
t.Run("match → true", func(t *testing.T) {
ctx := New("main", "", "", nil)
ctx.SetChangedFiles([]string{"src/pkg/main.go"})
if !changesMatch([]any{"src/**/*.go"}, ctx) {
t.Error("should match: src/pkg/main.go satisfies src/**/*.go")
}
})
t.Run("empty changedFiles + non-nil changes → false", func(t *testing.T) {
ctx := New("main", "", "", nil)
ctx.SetChangedFiles([]string{}) // explicit empty list
if changesMatch([]any{"Dockerfile"}, ctx) {
t.Error("empty file list with active filter should be false")
}
})
t.Run("one of many changed files matches", func(t *testing.T) {
ctx := New("main", "", "", nil)
ctx.SetChangedFiles([]string{"README.md", "src/main.go", "go.sum"})
if !changesMatch([]any{"src/**"}, ctx) {
t.Error("src/main.go should satisfy src/**")
}
})
}
// ── EvalJob with rules:changes: ───────────────────────────────────────────────
func TestEvalJob_RulesChanges(t *testing.T) {
makeJob := func(rules []model.Rule) model.Job {
return model.Job{Name: "job", Script: []any{"echo"}, Rules: rules}
}
t.Run("no changed files (permissive) — rule fires", func(t *testing.T) {
ctx := New("main", "", "", nil)
// changedFiles nil → permissive
job := makeJob([]model.Rule{{Changes: []any{"src/**/*.go"}, When: "on_success"}})
if EvalJob(job, ctx) != JobActive {
t.Error("expected active: permissive when no file list")
}
})
t.Run("matching changed file — rule fires", func(t *testing.T) {
ctx := New("main", "", "", nil)
ctx.SetChangedFiles([]string{"src/main.go"})
job := makeJob([]model.Rule{{Changes: []any{"src/**"}, When: "on_success"}})
if EvalJob(job, ctx) != JobActive {
t.Error("expected active: src/main.go matches src/**")
}
})
t.Run("no matching file — rule skipped, no other rule → skipped", func(t *testing.T) {
ctx := New("main", "", "", nil)
ctx.SetChangedFiles([]string{"docs/README.md"})
job := makeJob([]model.Rule{{Changes: []any{"src/**"}, When: "on_success"}})
if EvalJob(job, ctx) != JobSkipped {
t.Error("expected skipped: docs/README.md does not match src/**")
}
})
t.Run("changes filter with if: both must pass", func(t *testing.T) {
ctx := New("main", "", "", nil)
ctx.SetChangedFiles([]string{"src/main.go"})
job := makeJob([]model.Rule{
{If: `$CI_COMMIT_BRANCH == "develop"`, Changes: []any{"src/**"}, When: "on_success"},
})
// if: fails → rule skipped even though changes match
if EvalJob(job, ctx) != JobSkipped {
t.Error("expected skipped: if: condition fails")
}
})
t.Run("map form changes: {paths: [...]}", func(t *testing.T) {
ctx := New("main", "", "", nil)
ctx.SetChangedFiles([]string{"Dockerfile"})
job := makeJob([]model.Rule{{
Changes: map[string]any{"paths": []any{"Dockerfile"}, "compare_to": "origin/main"},
When: "on_success",
}})
if EvalJob(job, ctx) != JobActive {
t.Error("expected active: map form changes should work")
}
})
}
// ── EvalWorkflow with rules:changes: ─────────────────────────────────────────
func TestEvalWorkflow_Changes(t *testing.T) {
makePipeline := func(rules []model.Rule) *model.Pipeline {
return &model.Pipeline{Workflow: &model.Workflow{Rules: rules}}
}
t.Run("no changedFiles (permissive) → pipeline runs", func(t *testing.T) {
ctx := New("main", "", "", nil)
p := makePipeline([]model.Rule{{Changes: []any{"src/**"}, When: "always"}})
runs, _ := EvalWorkflow(p, ctx)
if !runs {
t.Error("expected pipeline to run: permissive when no file list")
}
})
t.Run("matching file → pipeline runs", func(t *testing.T) {
ctx := New("main", "", "", nil)
ctx.SetChangedFiles([]string{"src/app.go"})
p := makePipeline([]model.Rule{{Changes: []any{"src/**"}, When: "always"}})
runs, _ := EvalWorkflow(p, ctx)
if !runs {
t.Error("expected pipeline to run: src/app.go matches src/**")
}
})
t.Run("no matching file → no rule matches → pipeline blocked", func(t *testing.T) {
ctx := New("main", "", "", nil)
ctx.SetChangedFiles([]string{"docs/README.md"})
p := makePipeline([]model.Rule{{Changes: []any{"src/**"}, When: "always"}})
runs, _ := EvalWorkflow(p, ctx)
if runs {
t.Error("expected pipeline blocked: docs/README.md does not match src/**")
}
})
}
// ── SetChangedFiles / Summary ─────────────────────────────────────────────────
func TestSetChangedFiles(t *testing.T) {
ctx := New("main", "", "", nil)
if ctx.changedFiles != nil {
t.Error("changedFiles should be nil initially")
}
ctx.SetChangedFiles([]string{"a.go", "b.go"})
if len(ctx.changedFiles) != 2 {
t.Errorf("expected 2 changed files, got %d", len(ctx.changedFiles))
}
// nil receiver should not panic
var nilCtx *Context
nilCtx.SetChangedFiles([]string{"x"})
}
func TestSummary_WithChangedFiles(t *testing.T) {
ctx := New("main", "", "", nil)
// Without changed files
if s := ctx.Summary(); s != "branch=main, source=push" {
t.Errorf("unexpected summary without changes: %q", s)
}
// With changed files
ctx.SetChangedFiles([]string{"a.go", "b.go"})
s := ctx.Summary()
if s != "branch=main, source=push, 2 changed file(s)" {
t.Errorf("unexpected summary with changes: %q", s)
}
// With empty changed files (strict, 0 files)
ctx.SetChangedFiles([]string{})
s = ctx.Summary()
if s != "branch=main, source=push, 0 changed file(s)" {
t.Errorf("unexpected summary with 0 changes: %q", s)
}
}
+18 -2
View File
@@ -9,8 +9,9 @@ import (
// pipeline evaluation (rules:if:, only:, except:, workflow:rules:).
// Variables are keyed by their name without the leading $.
type Context struct {
Vars map[string]string
pinned map[string]bool // vars set via --var or shortcuts; never overwritten by Inject
Vars map[string]string
pinned map[string]bool // vars set via --var or shortcuts; never overwritten by Inject
changedFiles []string // nil = not provided (permissive); non-nil = known set of changed files
}
// New builds a Context from high-level shortcut values and optional KEY=VALUE
@@ -101,6 +102,18 @@ func (c *Context) Inject(key, value string) {
c.Vars[key] = value
}
// SetChangedFiles records the list of files that changed for rules:changes:
// evaluation. A non-nil slice (even empty) enables strict matching: only jobs
// whose rules:changes: patterns match at least one file in the list will have
// that rule fire. A nil slice (the default) means "not provided" — rules:changes:
// conditions are treated as always satisfied (permissive), preserving the
// behaviour when no changed-file data is available.
func (c *Context) SetChangedFiles(files []string) {
if c != nil {
c.changedFiles = files
}
}
// Summary returns a short human-readable description of the context for CLI output.
func (c *Context) Summary() string {
if c.IsEmpty() {
@@ -115,6 +128,9 @@ func (c *Context) Summary() string {
if v := c.Get("CI_PIPELINE_SOURCE"); v != "" {
parts = append(parts, "source="+v)
}
if c.changedFiles != nil {
parts = append(parts, fmt.Sprintf("%d changed file(s)", len(c.changedFiles)))
}
return strings.Join(parts, ", ")
}
+253
View File
@@ -0,0 +1,253 @@
package cicontext
import (
"testing"
)
// ── New / IsEmpty / Get / Inject ──────────────────────────────────────────────
func TestNew_Empty(t *testing.T) {
ctx := New("", "", "", nil)
if !ctx.IsEmpty() {
t.Error("expected empty context")
}
if ctx.Get("CI_COMMIT_BRANCH") != "" {
t.Error("expected empty Get on empty context")
}
}
func TestNew_Branch(t *testing.T) {
ctx := New("feature/abc", "", "", nil)
if ctx.Get("CI_COMMIT_BRANCH") != "feature/abc" {
t.Errorf("unexpected branch: %q", ctx.Get("CI_COMMIT_BRANCH"))
}
if ctx.Get("CI_COMMIT_REF_SLUG") != "feature-abc" {
t.Errorf("unexpected slug: %q", ctx.Get("CI_COMMIT_REF_SLUG"))
}
if ctx.Get("CI_PIPELINE_SOURCE") != "push" {
t.Error("expected default source=push for branch")
}
}
func TestNew_Tag(t *testing.T) {
ctx := New("", "v1.0.0", "", nil)
if ctx.Get("CI_COMMIT_TAG") != "v1.0.0" {
t.Errorf("unexpected tag: %q", ctx.Get("CI_COMMIT_TAG"))
}
// tag should clear CI_COMMIT_BRANCH
if ctx.Get("CI_COMMIT_BRANCH") != "" {
t.Error("CI_COMMIT_BRANCH should be cleared when tag is set")
}
if ctx.Get("CI_PIPELINE_SOURCE") != "push" {
t.Error("expected default source=push for tag")
}
}
func TestNew_BranchAndTagTogether(t *testing.T) {
// branch set first, then tag overwrites REF_NAME and clears BRANCH
ctx := New("main", "v2.0", "", nil)
if ctx.Get("CI_COMMIT_BRANCH") != "" {
t.Error("BRANCH should be cleared by tag")
}
if ctx.Get("CI_COMMIT_TAG") != "v2.0" {
t.Errorf("unexpected tag %q", ctx.Get("CI_COMMIT_TAG"))
}
}
func TestNew_ExtraVars(t *testing.T) {
ctx := New("main", "", "", []string{"DEPLOY_ENV=prod", "BAD_NO_EQUALS"})
if ctx.Get("DEPLOY_ENV") != "prod" {
t.Errorf("extra var not set: %q", ctx.Get("DEPLOY_ENV"))
}
if ctx.Get("BAD_NO_EQUALS") != "" {
t.Error("malformed KEY=VALUE should not be set")
}
}
func TestNew_SourceOverride(t *testing.T) {
ctx := New("", "", "schedule", nil)
if ctx.Get("CI_PIPELINE_SOURCE") != "schedule" {
t.Errorf("unexpected source: %q", ctx.Get("CI_PIPELINE_SOURCE"))
}
}
func TestNew_DefaultBranch(t *testing.T) {
ctx := New("feature", "", "", nil)
if ctx.Get("CI_DEFAULT_BRANCH") != "main" {
t.Errorf("unexpected default branch: %q", ctx.Get("CI_DEFAULT_BRANCH"))
}
}
func TestInject(t *testing.T) {
ctx := New("main", "", "", nil)
// pinned var should not be overwritten
ctx.Inject("CI_COMMIT_BRANCH", "other")
if ctx.Get("CI_COMMIT_BRANCH") != "main" {
t.Error("pinned var was overwritten by Inject")
}
// non-pinned var should be injected
ctx.Inject("MY_VAR", "hello")
if ctx.Get("MY_VAR") != "hello" {
t.Errorf("Inject failed: %q", ctx.Get("MY_VAR"))
}
}
func TestInject_NilVarsMap(t *testing.T) {
ctx := &Context{}
ctx.Inject("K", "v") // should not panic
if ctx.Get("K") != "v" {
t.Error("Inject on nil Vars should initialise the map")
}
}
func TestGet_NilContext(t *testing.T) {
var ctx *Context
if ctx.Get("X") != "" {
t.Error("nil context Get should return empty string")
}
}
// ── Summary ───────────────────────────────────────────────────────────────────
func TestSummary(t *testing.T) {
cases := []struct {
name string
branch string
tag string
source string
want string
}{
{"empty context", "", "", "", ""},
{"branch only", "main", "", "", "branch=main, source=push"},
{"tag only", "", "v1.0", "", "tag=v1.0, source=push"},
{"source only", "", "", "schedule", "source=schedule"},
{"branch + source", "develop", "", "merge_request_event", "branch=develop, source=merge_request_event"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
ctx := New(tc.branch, tc.tag, tc.source, nil)
got := ctx.Summary()
if got != tc.want {
t.Errorf("got %q want %q", got, tc.want)
}
})
}
}
// ── ScalarString ──────────────────────────────────────────────────────────────
func TestScalarString(t *testing.T) {
cases := []struct {
name string
input any
wantS string
wantOK bool
}{
{"string", "hello", "hello", true},
{"bool true", true, "true", true},
{"bool false", false, "false", true},
{"int", 42, "42", true},
{"float64", 3.14, "3.14", true},
{"map with value key", map[string]any{"value": "v"}, "v", true},
{"map without value key", map[string]any{"description": "x"}, "", false},
{"nil", nil, "", false},
{"slice", []any{1, 2}, "", false},
{"nested map value", map[string]any{"value": 99}, "99", true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
s, ok := ScalarString(tc.input)
if ok != tc.wantOK || s != tc.wantS {
t.Errorf("got (%q, %v) want (%q, %v)", s, ok, tc.wantS, tc.wantOK)
}
})
}
}
// ── ExpandVars / expandVarRefs ────────────────────────────────────────────────
func TestExpandVars(t *testing.T) {
t.Run("simple expansion", func(t *testing.T) {
ctx := &Context{Vars: map[string]string{"A": "hello", "B": "$A world"}}
ctx.ExpandVars()
if ctx.Vars["B"] != "hello world" {
t.Errorf("got %q", ctx.Vars["B"])
}
})
t.Run("transitive chain", func(t *testing.T) {
ctx := &Context{Vars: map[string]string{"A": "x", "B": "$A", "C": "$B"}}
ctx.ExpandVars()
if ctx.Vars["C"] != "x" {
t.Errorf("got %q", ctx.Vars["C"])
}
})
t.Run("circular reference stops", func(t *testing.T) {
ctx := &Context{Vars: map[string]string{"A": "$B", "B": "$A"}}
ctx.ExpandVars() // must not infinite-loop
})
t.Run("nil context is noop", func(t *testing.T) {
var ctx *Context
ctx.ExpandVars() // must not panic
})
t.Run("empty vars is noop", func(t *testing.T) {
ctx := &Context{}
ctx.ExpandVars() // must not panic
})
}
func TestExpandVarRefs(t *testing.T) {
vars := map[string]string{"FOO": "bar", "X": "123"}
cases := []struct {
name string
input string
want string
}{
{"no dollar", "hello", "hello"},
{"simple ref", "$FOO", "bar"},
{"braced ref", "${FOO}", "bar"},
{"unknown ref unchanged", "$UNKNOWN", "$UNKNOWN"},
{"unknown braced unchanged", "${UNKNOWN}", "${UNKNOWN}"},
{"trailing dollar", "abc$", "abc$"},
{"dollar at end after ident", "$X_", "$X_"},
// Malformed ${…} without closing brace: "${" emitted, ident chars consumed.
{"malformed brace no close", "${FOO", "${"},
{"mixed", "pre_${FOO}_$X", "pre_bar_123"},
{"dollar no ident after", "$ abc", "$ abc"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := expandVarRefs(tc.input, vars)
if got != tc.want {
t.Errorf("got %q want %q", got, tc.want)
}
})
}
}
// ── slugify ───────────────────────────────────────────────────────────────────
func TestSlugify(t *testing.T) {
cases := []struct {
input string
want string
}{
{"main", "main"},
{"feature/my-branch", "feature-my-branch"},
{"Feature_123", "feature-123"},
{"--leading", "leading"},
{"trailing--", "trailing"},
{"v1.2.3", "v1-2-3"},
}
for _, tc := range cases {
t.Run(tc.input, func(t *testing.T) {
got := slugify(tc.input)
if got != tc.want {
t.Errorf("got %q want %q", got, tc.want)
}
})
}
}
-3
View File
@@ -339,9 +339,6 @@ func (p *exprParser) parseStringLiteral() (string, bool) {
}
func (p *exprParser) parseRegexLiteral() (string, bool) {
if p.peek() != '/' {
return "", false
}
p.pos++ // consume opening '/'
var sb strings.Builder
for p.pos < len(p.s) {
+146
View File
@@ -138,6 +138,152 @@ func TestEvalIf(t *testing.T) {
}
}
// TestEvalIfParserEdgeCases exercises low-level parser branches not reached
// by the main table-driven tests above.
func TestEvalIfParserEdgeCases(t *testing.T) {
vars := func(key string) string {
switch key {
case "BRANCH":
return "develop"
case "UNTERMINATED_RE":
return "/no-end"
case "ESCAPED_RE":
return `/^dev\./`
}
return ""
}
// parseOr: right side of || fails to parse.
if !EvalIf(`$BRANCH || !(`, vars) {
t.Error("parseOr bad right: expect permissive-true")
}
if EvalIfStrict(`$BRANCH || !(`, vars) {
t.Error("parseOr bad right: expect strict-false")
}
// parsePrimary: inner parseOr succeeds but no closing ')'.
if !EvalIf(`($BRANCH == "develop"`, vars) {
t.Error("unmatched paren: expect permissive-true")
}
if EvalIfStrict(`($BRANCH == "develop"`, vars) {
t.Error("unmatched paren: expect strict-false")
}
// parseComparison ==: right-hand parseValue fails (! is not a valid value start).
if !EvalIf(`$BRANCH == !invalid`, vars) {
t.Error("== bad rhs: expect permissive-true")
}
// parseComparison !=: right-hand parseValue fails.
if !EvalIf(`$BRANCH != ${`, vars) {
t.Error("!= bad rhs: expect permissive-true")
}
// parseRegexRHS: peek is neither '/' nor '$' → return "", false, false.
// parseComparison =~: patOk=false, permissive=false → return false, false.
if !EvalIf(`$BRANCH =~ "literal"`, vars) {
t.Error("=~ string literal rhs: expect permissive-true")
}
if EvalIfStrict(`$BRANCH =~ "literal"`, vars) {
t.Error("=~ string literal rhs: expect strict-false")
}
// parseComparison =~: bad regex pattern → compile error → return true, true.
if !EvalIf(`$BRANCH =~ /[unclosed/`, vars) {
t.Error("=~ bad regex: expect permissive-true")
}
// parseComparison !~: patOk=false → return false, false.
if !EvalIf(`$BRANCH !~ "literal"`, vars) {
t.Error("!~ string literal rhs: expect permissive-true")
}
if EvalIfStrict(`$BRANCH !~ "literal"`, vars) {
t.Error("!~ string literal rhs: expect strict-false")
}
// parseComparison !~: bad regex pattern → compile error → return true, true.
if !EvalIf(`$BRANCH !~ /[unclosed/`, vars) {
t.Error("!~ bad regex: expect permissive-true")
}
// parseComparison single =: RHS parseValue fails.
if !EvalIf(`$BRANCH = !invalid`, vars) {
t.Error("single = bad rhs: expect permissive-true")
}
// parseValue: '$' not followed by a valid identifier.
if !EvalIf(`$} == "develop"`, vars) {
t.Error("$ bad ident: expect permissive-true")
}
// parseValue: '${' with no closing '}' or empty name.
if !EvalIf(`${} == "develop"`, vars) {
t.Error("${} empty name: expect permissive-true")
}
if !EvalIf(`${BRANCH == "develop"`, vars) {
t.Error("${BRANCH no close brace: expect permissive-true")
}
// parseStringLiteral: escape sequence — \v is consumed and the next byte
// is written literally, so "de\velop" → "develop" which matches BRANCH.
if !EvalIf(`$BRANCH == "de\velop"`, vars) {
t.Error(`string escape: "de\velop" should decode to "develop"`)
}
// parseStringLiteral: unterminated string literal.
if !EvalIf(`$BRANCH == "no-end`, vars) {
t.Error("unterminated string: expect permissive-true")
}
if EvalIfStrict(`$BRANCH == "no-end`, vars) {
t.Error("unterminated string: expect strict-false")
}
// parseRegexLiteral: escape sequence in regex (backslash preserved).
// /^d\evelop/ → pattern "^d\evelop" — \e is invalid in Go regexp
// → compile error → permissive true.
if !EvalIf(`$BRANCH =~ /^d\evelop/`, vars) {
t.Error("regex escape (bad compile): expect permissive-true")
}
// parseRegexLiteral: unterminated regex (no closing '/').
if !EvalIf(`$BRANCH =~ /no-end`, vars) {
t.Error("unterminated regex: expect permissive-true")
}
if EvalIfStrict(`$BRANCH =~ /no-end`, vars) {
t.Error("unterminated regex: expect strict-false")
}
// applyRegexFlags: 'm' and 's' flags (exercises two additional switch cases).
if !EvalIf(`$BRANCH =~ /^DEV/ims`, vars) {
t.Error("regex /ims flags: case-insensitive should match 'develop'")
}
// extractRegexFromString: unterminated regex in variable value.
// UNTERMINATED_RE = "/no-end" (no closing '/') → permissive.
if !EvalIf(`$BRANCH =~ $UNTERMINATED_RE`, vars) {
t.Error("unterminated re in var: expect permissive-true")
}
// extractRegexFromString: escape sequence in variable value.
// ESCAPED_RE = /^dev\./ → pattern = "^dev\." (literal dot) → no match for "develop".
if EvalIf(`$BRANCH =~ $ESCAPED_RE`, vars) {
t.Error("ESCAPED_RE=/^dev\\./ requires a literal dot; 'develop' has no dot")
}
// !~ permissive path (line 203-205): var value is a plain string, not /regex/ → permissive.
// BRANCH = "develop" which does not start with '/' → extractRegexFromString returns ok=false
// → parseRegexRHS returns permissive=true → !~ case returns (true, true).
if !EvalIf(`$BRANCH !~ $BRANCH`, vars) {
t.Error("!~ plain-var rhs: plain variable value triggers permissive-true")
}
// parseRegexRHS: '$' in rhs but parseValue fails (line 244-246).
// '$}' — '$' followed by '}' which is not a valid identifier start → varOk=false.
if !EvalIf(`$BRANCH =~ $}`, vars) {
t.Error("=~ $}: parseValue fails → permissive-true (EvalIf overall)")
}
}
func TestEvalIfStrict(t *testing.T) {
vars := func(key string) string {
m := map[string]string{
+95
View File
@@ -1,6 +1,7 @@
package cicontext
import (
"path"
"regexp"
"strings"
@@ -49,6 +50,9 @@ func EvalWorkflow(p *model.Pipeline, ctx *Context) (bool, map[string]string) {
if !ruleIfMatchesStrict(rule.If, vars) {
continue
}
if !changesMatch(rule.Changes, ctx) {
continue
}
when := rule.When
if when == "" {
when = "always"
@@ -74,6 +78,9 @@ func EvalJob(job model.Job, ctx *Context) JobState {
if !ruleIfMatches(rule.If, vars) {
continue
}
if !changesMatch(rule.Changes, ctx) {
continue
}
return whenToState(rule.When)
}
return JobSkipped // no rule matched → job is excluded
@@ -262,3 +269,91 @@ func matchGlob(pattern, s string) bool {
}
return len(s) == 0
}
// ── rules:changes: evaluation ─────────────────────────────────────────────────
// changesMatch reports whether the rule's changes: filter is satisfied.
//
// - rule.Changes == nil → no filter; always true
// - ctx.changedFiles == nil → file list not provided; always true (permissive)
// - otherwise → true iff at least one changed file matches at least one pattern
func changesMatch(changes any, ctx *Context) bool {
patterns := extractChangesPaths(changes)
if patterns == nil {
return true // no changes: filter
}
if ctx.changedFiles == nil {
return true // no file list provided → permissive
}
for _, changed := range ctx.changedFiles {
for _, pat := range patterns {
if doublestarMatch(pat, changed) {
return true
}
}
}
return false
}
// extractChangesPaths normalises a rule.Changes value into a []string of glob
// patterns. Returns nil when no changes: filter is present.
func extractChangesPaths(changes any) []string {
switch v := changes.(type) {
case nil:
return nil
case string:
return []string{v}
case []string:
return v
case []any:
var out []string
for _, item := range v {
if s, ok := item.(string); ok {
out = append(out, s)
}
}
return out
case map[string]any:
// Extended form: { paths: [...], compare_to: "..." }
if raw, ok := v["paths"]; ok {
return extractChangesPaths(raw)
}
return nil
}
return nil
}
// doublestarMatch reports whether pattern matches the file path using GitLab-style
// glob rules: '*' matches any character sequence within a single path segment;
// '**' matches zero or more path segments (crossing '/' boundaries).
func doublestarMatch(pattern, filePath string) bool {
return matchParts(strings.Split(pattern, "/"), strings.Split(filePath, "/"))
}
// matchParts is the recursive engine for doublestarMatch.
func matchParts(pat, name []string) bool {
for len(pat) > 0 {
if pat[0] == "**" {
if len(pat) == 1 {
return true // trailing ** matches everything remaining
}
// ** can match 0, 1, 2, … leading segments of name.
for i := 0; i <= len(name); i++ {
if matchParts(pat[1:], name[i:]) {
return true
}
}
return false
}
if len(name) == 0 {
return false
}
ok, err := path.Match(pat[0], name[0])
if err != nil || !ok {
return false
}
pat = pat[1:]
name = name[1:]
}
return len(name) == 0
}
@@ -0,0 +1,262 @@
package cicontext
import (
"testing"
"git.k3nny.fr/glint/internal/model"
)
// ── JobState.String ───────────────────────────────────────────────────────────
func TestJobStateString(t *testing.T) {
if JobActive.String() != "active" { t.Errorf("active: got %q", JobActive.String()) }
if JobManual.String() != "manual" { t.Errorf("manual: got %q", JobManual.String()) }
if JobSkipped.String() != "skipped" { t.Errorf("skipped: got %q", JobSkipped.String()) }
}
// ── whenToState ───────────────────────────────────────────────────────────────
func TestWhenToState(t *testing.T) {
cases := []struct { when string; want JobState }{
{"never", JobSkipped},
{"manual", JobManual},
{"on_success", JobActive},
{"always", JobActive},
{"", JobActive},
}
for _, tc := range cases {
got := whenToState(tc.when)
if got != tc.want {
t.Errorf("whenToState(%q)=%v want %v", tc.when, got, tc.want)
}
}
}
// ── EvalJob: only/except paths ────────────────────────────────────────────────
func TestEvalJob_OnlyExcept(t *testing.T) {
ctx := New("main", "", "", nil)
t.Run("only branches matches", func(t *testing.T) {
job := model.Job{Only: []any{"branches"}}
if EvalJob(job, ctx) != JobActive {
t.Error("expected active on branch")
}
})
t.Run("only tags excludes branch push", func(t *testing.T) {
job := model.Job{Only: []any{"tags"}}
if EvalJob(job, ctx) != JobSkipped {
t.Error("expected skipped — we are on a branch, not tag")
}
})
t.Run("except branches skips on branch push", func(t *testing.T) {
job := model.Job{Script: "echo ok", Except: []any{"branches"}}
if EvalJob(job, ctx) != JobSkipped {
t.Error("expected skipped — branches excluded")
}
})
t.Run("except non-matching source does not skip", func(t *testing.T) {
job := model.Job{Except: []any{"schedules"}}
if EvalJob(job, ctx) != JobActive {
t.Error("expected active — schedule is not the source")
}
})
t.Run("job when: manual with no filter", func(t *testing.T) {
job := model.Job{When: "manual"}
if EvalJob(job, ctx) != JobManual {
t.Error("expected manual")
}
})
t.Run("empty context returns active", func(t *testing.T) {
emptyCtx := New("", "", "", nil)
job := model.Job{Only: []any{"tags"}}
if EvalJob(job, emptyCtx) != JobActive {
t.Error("empty context should always return active")
}
})
}
// ── EvalJob: rules path ───────────────────────────────────────────────────────
func TestEvalJob_Rules(t *testing.T) {
ctx := New("main", "", "", nil)
t.Run("rule with never when excludes", func(t *testing.T) {
job := model.Job{Rules: []model.Rule{{When: "never"}}}
if EvalJob(job, ctx) != JobSkipped {
t.Error("expected skipped — when:never")
}
})
t.Run("no matching rule → skipped", func(t *testing.T) {
job := model.Job{Rules: []model.Rule{
{If: "$CI_COMMIT_BRANCH == \"develop\"", When: "on_success"},
}}
if EvalJob(job, ctx) != JobSkipped {
t.Error("expected skipped — no rule matches main branch with develop condition")
}
})
}
// ── onlyMatches / exceptMatches ───────────────────────────────────────────────
func TestOnlyMatches(t *testing.T) {
branchCtx := New("main", "", "", nil)
tagCtx := New("", "v1.0", "", nil)
mrCtx := New("", "", "merge_request_event", nil)
cases := []struct {
name string
only any
ctx *Context
want bool
}{
{"nil only always matches", nil, branchCtx, true},
{"unrecognised form permissive", 42, branchCtx, true},
{"string form — branches keyword", "branches", branchCtx, true},
{"string form — tags keyword miss", "tags", branchCtx, false},
{"slice — merge_requests keyword", []any{"merge_requests"}, mrCtx, true},
{"slice — schedules keyword", []any{"schedules"}, branchCtx, false},
{"slice — pipelines keyword", []any{"pipelines"}, New("","","pipeline",nil), true},
{"slice — pushes keyword", []any{"pushes"}, New("","","push",nil), true},
{"slice — web keyword", []any{"web"}, New("","","web",nil), true},
{"slice — api keyword", []any{"api"}, New("","","api",nil), true},
{"slice — tag keyword match", []any{"tags"}, tagCtx, true},
{"glob pattern matches", []any{"feat/*"}, New("feat/abc","","",nil), true},
{"glob no match", []any{"feat/*"}, branchCtx, false},
{"regex pattern matches branch", []any{"/^main.*/"}, branchCtx, true},
{"regex pattern no match", []any{"/^develop/"}, branchCtx, false},
{"invalid regex treated as glob", []any{"/[invalid/"}, branchCtx, false},
{"map form with refs key", map[string]any{"refs": []any{"branches"}}, branchCtx, true},
{"map form no refs — permissive", map[string]any{"variables": []any{}}, branchCtx, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := onlyMatches(tc.only, tc.ctx)
if got != tc.want {
t.Errorf("onlyMatches: got %v want %v", got, tc.want)
}
})
}
}
func TestExceptMatches(t *testing.T) {
branchCtx := New("main", "", "", nil)
cases := []struct {
name string
except any
want bool
}{
{"nil except never excludes", nil, false},
{"unrecognised form not excluded", 42, false},
{"branches excludes on branch push", []any{"branches"}, true},
{"tags does not exclude branch push", []any{"tags"}, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := exceptMatches(tc.except, branchCtx)
if got != tc.want {
t.Errorf("exceptMatches: got %v want %v", got, tc.want)
}
})
}
}
// ── extractRefList ────────────────────────────────────────────────────────────
func TestExtractRefList(t *testing.T) {
cases := []struct {
name string
input any
wantN int // -1 means nil expected
}{
{"string", "branches", 1},
{"slice of strings", []any{"a", "b", "c"}, 3},
{"slice with non-string items skipped", []any{"a", 42, "b"}, 2},
{"map with refs key", map[string]any{"refs": []any{"branches"}}, 1},
{"map without refs key returns nil", map[string]any{"variables": nil}, -1},
{"unknown type returns nil", 123, -1},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := extractRefList(tc.input)
if tc.wantN == -1 {
if got != nil { t.Errorf("expected nil, got %v", got) }
} else {
if len(got) != tc.wantN { t.Errorf("len: got %d want %d (%v)", len(got), tc.wantN, got) }
}
})
}
}
// ── globMatches / matchGlob ───────────────────────────────────────────────────
func TestGlobMatches(t *testing.T) {
cases := []struct {
pattern string
s string
want bool
}{
{"main", "main", true},
{"main", "develop", false},
{"feat/*", "feat/abc", true},
{"feat/*", "feat/", true},
{"feat/*", "main", false},
{"*", "anything", true},
{"*", "", true},
{"prefix*suffix", "prefixMIDDLEsuffix", true},
{"prefix*suffix", "prefixsuffix", true},
{"prefix*suffix", "prefixNO", false},
{"a*b*c", "aXbYc", true},
{"a*b*c", "abc", true},
}
for _, tc := range cases {
t.Run(tc.pattern+"/"+tc.s, func(t *testing.T) {
got := globMatches(tc.pattern, tc.s)
if got != tc.want {
t.Errorf("globMatches(%q, %q)=%v want %v", tc.pattern, tc.s, got, tc.want)
}
})
}
}
// ── refPatternMatches ─────────────────────────────────────────────────────────
func TestRefPatternMatches(t *testing.T) {
cases := []struct {
pattern string
ref string
want bool
}{
{"/^main$/", "main", true},
{"/^main$/", "develop", false},
{"/feature/", "feature/abc", true},
{"feat/*", "feat/x", true},
{"main", "main", true},
{"main", "develop", false},
{"", "main", false}, // empty ref for pattern with no wildcard
}
for _, tc := range cases {
t.Run(tc.pattern+"@"+tc.ref, func(t *testing.T) {
got := refPatternMatches(tc.pattern, tc.ref)
if got != tc.want {
t.Errorf("refPatternMatches(%q, %q)=%v want %v", tc.pattern, tc.ref, got, tc.want)
}
})
}
}
// TestRefListMatches_Schedule verifies the "schedules" keyword matches when
// CI_PIPELINE_SOURCE is "schedule".
func TestRefListMatches_Schedule(t *testing.T) {
ctx := New("", "", "schedule", nil)
if !refListMatches([]string{"schedules"}, ctx) {
t.Error("schedules keyword should match when source is 'schedule'")
}
}
+15
View File
@@ -100,6 +100,21 @@ func TestLoad_StopsAtGitRoot(t *testing.T) {
}
}
// TestLoad_ReadError covers the !os.IsNotExist(err) branch (config.go:59-61)
// when the file exists but is not readable.
func TestLoad_ReadError(t *testing.T) {
tmp := t.TempDir()
cfgPath := filepath.Join(tmp, Filename)
if err := os.WriteFile(cfgPath, []byte("ignore: []"), 0o000); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { os.Chmod(cfgPath, 0o644) })
_, err := Load(tmp)
if err == nil {
t.Error("expected error for unreadable config file")
}
}
func TestLoad_InvalidYAML(t *testing.T) {
tmp := t.TempDir()
if err := os.WriteFile(filepath.Join(tmp, Filename), []byte("ignore: [unclosed\n"), 0o644); err != nil {
+73
View File
@@ -0,0 +1,73 @@
package fetcher
import (
"os"
"path/filepath"
"testing"
)
func TestCachePath(t *testing.T) {
p1 := cachePath("/tmp/cache", "key1")
p2 := cachePath("/tmp/cache", "key2")
if p1 == p2 {
t.Error("different keys should produce different paths")
}
if filepath.Dir(p1) != "/tmp/cache" {
t.Errorf("expected /tmp/cache dir, got %q", filepath.Dir(p1))
}
}
func TestCacheReadMiss(t *testing.T) {
// empty dir → miss
data, ok := cacheRead("", "key")
if ok || data != nil {
t.Error("empty cacheDir should always miss")
}
// nonexistent entry → miss
data, ok = cacheRead(t.TempDir(), "nonexistent-key")
if ok || data != nil {
t.Error("missing cache entry should miss")
}
}
func TestCacheWriteAndRead(t *testing.T) {
dir := t.TempDir()
key := "https://example.com/template.yml"
content := []byte("stages: [build]")
cacheWrite(dir, key, content)
got, ok := cacheRead(dir, key)
if !ok {
t.Fatal("expected cache hit after write")
}
if string(got) != string(content) {
t.Errorf("cached content mismatch: got %q want %q", got, content)
}
}
func TestCacheWrite_EmptyDir(t *testing.T) {
// Should silently do nothing when dir is empty string.
cacheWrite("", "key", []byte("data"))
}
// TestCacheWrite_DirIsFile covers the os.MkdirAll error path (cache.go:29-31)
// when the cache dir path is occupied by a regular file.
func TestCacheWrite_DirIsFile(t *testing.T) {
f := filepath.Join(t.TempDir(), "file")
if err := os.WriteFile(f, []byte("occupied"), 0o644); err != nil {
t.Fatal(err)
}
// MkdirAll(f) fails because f is a file, not a directory.
cacheWrite(f, "key", []byte("data"))
// No panic, no error returned — the function silently returns.
}
func TestCacheWrite_MkdirAll(t *testing.T) {
dir := filepath.Join(t.TempDir(), "sub", "dir")
cacheWrite(dir, "k", []byte("v"))
if _, err := os.Stat(dir); err != nil {
t.Errorf("directory not created: %v", err)
}
}
+341
View File
@@ -0,0 +1,341 @@
package fetcher
import (
"errors"
"io"
"net/http"
"net/http/httptest"
"os"
"testing"
)
// roundTripFunc allows constructing a custom http.RoundTripper from a function.
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
// errReader is an io.Reader that always returns an error.
type errReader struct{}
func (e errReader) Read([]byte) (int, error) { return 0, errors.New("read error") }
// replaceTransport temporarily replaces http.DefaultTransport and restores it.
func replaceTransport(t *testing.T, rt http.RoundTripper) {
t.Helper()
orig := http.DefaultTransport
http.DefaultTransport = rt
t.Cleanup(func() { http.DefaultTransport = orig })
}
// ── firstNonEmpty ─────────────────────────────────────────────────────────────
func TestFirstNonEmpty(t *testing.T) {
if firstNonEmpty("", "", "c") != "c" { t.Error("should return first non-empty") }
if firstNonEmpty("a", "b") != "a" { t.Error("should return first") }
if firstNonEmpty("", "") != "" { t.Error("all empty → empty") }
}
// ── AutoConfig ────────────────────────────────────────────────────────────────
func TestAutoConfig(t *testing.T) {
// Clear relevant env vars
for _, k := range []string{"CI_SERVER_URL", "GITLAB_URL", "GITLAB_TOKEN", "CI_JOB_TOKEN", "GITLAB_PRIVATE_TOKEN"} {
os.Unsetenv(k)
}
cfg := AutoConfig()
if cfg.BaseURL != "https://gitlab.com" {
t.Errorf("default BaseURL: %q", cfg.BaseURL)
}
if cfg.Token != "" {
t.Error("expected no token")
}
}
func TestAutoConfig_EnvVars(t *testing.T) {
os.Setenv("CI_SERVER_URL", "https://my.gitlab.example.com/")
os.Setenv("GITLAB_TOKEN", "glpat-test")
defer func() {
os.Unsetenv("CI_SERVER_URL")
os.Unsetenv("GITLAB_TOKEN")
}()
cfg := AutoConfig()
if cfg.BaseURL != "https://my.gitlab.example.com" {
t.Errorf("trailing slash not trimmed: %q", cfg.BaseURL)
}
if cfg.Token != "glpat-test" || cfg.Source != TokenPrivate {
t.Errorf("token: %q source: %v", cfg.Token, cfg.Source)
}
}
func TestAutoConfig_CIJobToken(t *testing.T) {
os.Unsetenv("GITLAB_TOKEN")
os.Setenv("CI_JOB_TOKEN", "job-token-123")
defer os.Unsetenv("CI_JOB_TOKEN")
cfg := AutoConfig()
if cfg.Token != "job-token-123" || cfg.Source != TokenJobToken {
t.Errorf("unexpected: %+v", cfg)
}
}
func TestAutoConfig_PrivateToken(t *testing.T) {
os.Unsetenv("GITLAB_TOKEN")
os.Unsetenv("CI_JOB_TOKEN")
os.Setenv("GITLAB_PRIVATE_TOKEN", "legacy-token")
defer os.Unsetenv("GITLAB_PRIVATE_TOKEN")
cfg := AutoConfig()
if cfg.Token != "legacy-token" || cfg.Source != TokenPrivate {
t.Errorf("unexpected: %+v", cfg)
}
}
func TestAutoConfig_GitlabURL(t *testing.T) {
os.Unsetenv("CI_SERVER_URL")
os.Setenv("GITLAB_URL", "https://gl.local")
defer os.Unsetenv("GITLAB_URL")
cfg := AutoConfig()
if cfg.BaseURL != "https://gl.local" {
t.Errorf("unexpected BaseURL: %q", cfg.BaseURL)
}
}
// ── WithOverrides ─────────────────────────────────────────────────────────────
func TestWithOverrides(t *testing.T) {
base := GitLabConfig{BaseURL: "https://gitlab.com", Token: "old", Source: TokenPrivate}
got := base.WithOverrides("https://other.com/", "new-token", "/cache", true)
if got.BaseURL != "https://other.com" { t.Errorf("BaseURL: %q", got.BaseURL) }
if got.Token != "new-token" { t.Error("Token not overridden") }
if got.CacheDir != "/cache" { t.Error("CacheDir not set") }
if !got.Offline { t.Error("Offline not set") }
// empty string leaves BaseURL alone
got2 := base.WithOverrides("", "", "", false)
if got2.BaseURL != "https://gitlab.com" { t.Error("empty override should not change BaseURL") }
}
// ── ForHost ───────────────────────────────────────────────────────────────────
func TestForHost(t *testing.T) {
cfg := GitLabConfig{BaseURL: "https://gitlab.com"}
got := cfg.ForHost("my-host.example.com")
if got.BaseURL != "https://my-host.example.com" {
t.Errorf("unexpected BaseURL: %q", got.BaseURL)
}
}
// ── HasToken ──────────────────────────────────────────────────────────────────
func TestHasToken(t *testing.T) {
if (GitLabConfig{}).HasToken() { t.Error("empty config should have no token") }
if !(GitLabConfig{Token: "x"}).HasToken() { t.Error("config with token should have token") }
}
// ── FetchFile ─────────────────────────────────────────────────────────────────
func TestFetchFile_FromCache(t *testing.T) {
dir := t.TempDir()
key := "https://gitlab.com|my/project|/templates/ci.yml|HEAD"
cacheWrite(dir, key, []byte("cached: true"))
cfg := GitLabConfig{BaseURL: "https://gitlab.com", CacheDir: dir}
data, err := cfg.FetchFile("my/project", "/templates/ci.yml", "")
if err != nil { t.Fatalf("unexpected error: %v", err) }
if string(data) != "cached: true" { t.Errorf("got %q", data) }
}
func TestFetchFile_Offline_Miss(t *testing.T) {
cfg := GitLabConfig{BaseURL: "https://gitlab.com", Offline: true}
_, err := cfg.FetchFile("p/q", "/f.yml", "main")
if err == nil { t.Fatal("expected error in offline mode") }
}
func TestFetchFile_OK(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("script: echo ok"))
}))
defer srv.Close()
cfg := GitLabConfig{BaseURL: srv.URL, Token: "tok", Source: TokenPrivate}
data, err := cfg.FetchFile("ns/proj", "/ci.yml", "main")
if err != nil { t.Fatalf("unexpected error: %v", err) }
if string(data) != "script: echo ok" { t.Errorf("got %q", data) }
}
func TestFetchFile_Unauthorized_NoToken(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
}))
defer srv.Close()
cfg := GitLabConfig{BaseURL: srv.URL}
_, err := cfg.FetchFile("p/q", "/f.yml", "main")
if err == nil { t.Fatal("expected error") }
}
func TestFetchFile_Unauthorized_WithToken(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
}))
defer srv.Close()
cfg := GitLabConfig{BaseURL: srv.URL, Token: "tok", Source: TokenPrivate}
_, err := cfg.FetchFile("p/q", "/f.yml", "main")
if err == nil { t.Fatal("expected error") }
}
func TestFetchFile_NotFound(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
w.Write([]byte("not found"))
}))
defer srv.Close()
cfg := GitLabConfig{BaseURL: srv.URL}
_, err := cfg.FetchFile("p/q", "/f.yml", "main")
if err == nil { t.Fatal("expected error") }
}
func TestFetchFile_OtherStatus(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("server error"))
}))
defer srv.Close()
cfg := GitLabConfig{BaseURL: srv.URL}
_, err := cfg.FetchFile("p/q", "/f.yml", "main")
if err == nil { t.Fatal("expected error") }
}
func TestFetchFile_JobToken(t *testing.T) {
var gotHeader string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotHeader = r.Header.Get("JOB-TOKEN")
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
}))
defer srv.Close()
cfg := GitLabConfig{BaseURL: srv.URL, Token: "ci-job-tok", Source: TokenJobToken}
cfg.FetchFile("p/q", "/f.yml", "main")
if gotHeader != "ci-job-tok" {
t.Errorf("JOB-TOKEN header not sent, got %q", gotHeader)
}
}
func TestFetchFile_WritesToCache(t *testing.T) {
dir := t.TempDir()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("stage: build"))
}))
defer srv.Close()
cfg := GitLabConfig{BaseURL: srv.URL, CacheDir: dir}
_, err := cfg.FetchFile("p/q", "/ci.yml", "main")
if err != nil { t.Fatal(err) }
// Second call should hit cache
data, ok := cacheRead(dir, srv.URL+"|p/q|/ci.yml|main")
if !ok { t.Fatal("cache miss after fetch") }
if string(data) != "stage: build" { t.Errorf("unexpected cache content: %q", data) }
}
// ── FetchURL ──────────────────────────────────────────────────────────────────
func TestFetchURL_OK(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("remote: content"))
}))
defer srv.Close()
cfg := GitLabConfig{BaseURL: srv.URL}
data, err := cfg.FetchURL(srv.URL + "/template.yml")
if err != nil { t.Fatalf("unexpected error: %v", err) }
if string(data) != "remote: content" { t.Errorf("got %q", data) }
}
func TestFetchURL_FromCache(t *testing.T) {
dir := t.TempDir()
rawURL := "https://example.com/tmpl.yml"
cacheWrite(dir, rawURL, []byte("cached"))
cfg := GitLabConfig{CacheDir: dir}
data, err := cfg.FetchURL(rawURL)
if err != nil { t.Fatal(err) }
if string(data) != "cached" { t.Errorf("got %q", data) }
}
func TestFetchURL_Offline(t *testing.T) {
cfg := GitLabConfig{Offline: true}
_, err := cfg.FetchURL("https://example.com/tmpl.yml")
if err == nil { t.Fatal("expected error in offline mode") }
}
// TestFetchFile_NewRequestFails covers gitlab.go:113-115 — http.NewRequest error
// when the BaseURL contains a control character (null byte) making the URL invalid.
func TestFetchFile_NewRequestFails(t *testing.T) {
cfg := GitLabConfig{BaseURL: "https://example.com\x00"}
_, err := cfg.FetchFile("p/q", "/f.yml", "main")
if err == nil {
t.Fatal("expected error for URL with null byte")
}
}
// TestFetchFile_DoFails covers gitlab.go:132-134 — http.DefaultClient.Do error.
func TestFetchFile_DoFails(t *testing.T) {
replaceTransport(t, roundTripFunc(func(r *http.Request) (*http.Response, error) {
return nil, errors.New("transport error")
}))
cfg := GitLabConfig{BaseURL: "https://example.com"}
_, err := cfg.FetchFile("p/q", "/f.yml", "main")
if err == nil {
t.Fatal("expected error when transport fails")
}
}
// TestFetchFile_ReadBodyFails covers gitlab.go:138-140 — io.ReadAll error on the
// response body.
func TestFetchFile_ReadBodyFails(t *testing.T) {
replaceTransport(t, roundTripFunc(func(r *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(errReader{}),
Header: make(http.Header),
}, nil
}))
cfg := GitLabConfig{BaseURL: "https://example.com"}
_, err := cfg.FetchFile("p/q", "/f.yml", "main")
if err == nil {
t.Fatal("expected error when body read fails")
}
}
// TestFetchURL_GetFails covers gitlab.go:173-175 — http.Get error.
func TestFetchURL_GetFails(t *testing.T) {
replaceTransport(t, roundTripFunc(func(r *http.Request) (*http.Response, error) {
return nil, errors.New("get failed")
}))
cfg := GitLabConfig{}
_, err := cfg.FetchURL("https://example.com/template.yml")
if err == nil {
t.Fatal("expected error when http.Get fails")
}
}
// TestFetchURL_ReadBodyFails covers gitlab.go:178-180 — io.ReadAll error on the
// FetchURL response body.
func TestFetchURL_ReadBodyFails(t *testing.T) {
replaceTransport(t, roundTripFunc(func(r *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(errReader{}),
Header: make(http.Header),
}, nil
}))
cfg := GitLabConfig{}
_, err := cfg.FetchURL("https://example.com/template.yml")
if err == nil {
t.Fatal("expected error when FetchURL body read fails")
}
}
func TestFetchURL_NotOK(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusForbidden)
}))
defer srv.Close()
cfg := GitLabConfig{}
_, err := cfg.FetchURL(srv.URL)
if err == nil { t.Fatal("expected error for non-200 status") }
}
+622
View File
@@ -0,0 +1,622 @@
package graph
import (
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"git.k3nny.fr/glint/internal/fetcher"
"git.k3nny.fr/glint/internal/model"
)
// ── mermaidLabel ──────────────────────────────────────────────────────────────
func TestMermaidLabel(t *testing.T) {
cases := []struct{ in, want string }{
{"plain", "plain"},
{`has "quotes"`, "has 'quotes'"},
{"has#hash", "has&#35;hash"},
{`"quoted"#both`, "'quoted'&#35;both"},
}
for _, tc := range cases {
got := mermaidLabel(tc.in)
if got != tc.want {
t.Errorf("mermaidLabel(%q)=%q want %q", tc.in, got, tc.want)
}
}
}
// ── includeFileList ───────────────────────────────────────────────────────────
func TestIncludeFileList(t *testing.T) {
if got := includeFileList("file.yml"); len(got) != 1 || got[0] != "file.yml" {
t.Error("string form")
}
if got := includeFileList([]any{"a.yml", "b.yml"}); len(got) != 2 {
t.Error("slice form")
}
if got := includeFileList([]any{"ok.yml", 42}); len(got) != 1 {
t.Error("mixed slice drops non-strings")
}
if got := includeFileList(nil); got != nil {
t.Error("nil should return nil")
}
if got := includeFileList(123); got != nil {
t.Error("unknown type returns nil")
}
}
// ── parseComponentRef ─────────────────────────────────────────────────────────
func TestParseComponentRef(t *testing.T) {
cases := []struct {
ref string
wantHost string
wantProj string
wantComp string
wantVer string
wantErr bool
}{
{
ref: "gitlab.com/group/project/component@v1.0",
wantHost: "gitlab.com", wantProj: "group/project", wantComp: "component", wantVer: "v1.0",
},
{
ref: "gitlab.com/a/b/c/d@main",
wantHost: "gitlab.com", wantProj: "a/b/c", wantComp: "d", wantVer: "main",
},
{ref: "no-at-sign", wantErr: true},
{ref: "no-at-sign@", wantErr: true},
{ref: "host/comp@v1", wantErr: true}, // only 2 parts — need at least 3
}
for _, tc := range cases {
host, proj, comp, ver, err := parseComponentRef(tc.ref)
if tc.wantErr {
if err == nil { t.Errorf("parseComponentRef(%q): expected error", tc.ref) }
continue
}
if err != nil { t.Errorf("parseComponentRef(%q): %v", tc.ref, err) }
if host != tc.wantHost { t.Errorf("host: got %q want %q", host, tc.wantHost) }
if proj != tc.wantProj { t.Errorf("project: got %q want %q", proj, tc.wantProj) }
if comp != tc.wantComp { t.Errorf("component: got %q want %q", comp, tc.wantComp) }
if ver != tc.wantVer { t.Errorf("version: got %q want %q", ver, tc.wantVer) }
}
}
// ── jobNames ──────────────────────────────────────────────────────────────────
func TestJobNames_Empty(t *testing.T) {
p := &model.Pipeline{Jobs: map[string]model.Job{}}
if got := jobNames(p); got != nil {
t.Errorf("empty pipeline: expected nil, got %v", got)
}
}
// ── treeBuilder helpers ───────────────────────────────────────────────────────
func TestNextID(t *testing.T) {
b := &treeBuilder{visited: map[string]bool{}}
if b.nextID() != "inc1" { t.Error("first ID") }
if b.nextID() != "inc2" { t.Error("second ID") }
}
func TestParseEntry_String(t *testing.T) {
b := &treeBuilder{visited: map[string]bool{}, baseDir: "/tmp"}
nodes := b.parseEntry("some/local.yml")
if len(nodes) != 1 { t.Fatalf("expected 1 node, got %d", len(nodes)) }
if !strings.Contains(nodes[0].label, "local:") { t.Error("expected local: label") }
}
func TestParseEntry_Unknown(t *testing.T) {
b := &treeBuilder{visited: map[string]bool{}}
nodes := b.parseEntry(42) // unknown type
if len(nodes) != 0 { t.Error("expected no nodes for unknown type") }
}
func TestParseMap_Template(t *testing.T) {
b := &treeBuilder{visited: map[string]bool{}}
nodes := b.parseMap(map[string]any{"template": "Auto-DevOps"})
if len(nodes) != 1 { t.Fatalf("expected 1 node, got %d", len(nodes)) }
if !strings.Contains(nodes[0].label, "template:") { t.Error("expected template: label") }
}
func TestParseMap_Remote(t *testing.T) {
b := &treeBuilder{visited: map[string]bool{}, cfg: fetcher.GitLabConfig{}}
nodes := b.parseMap(map[string]any{"remote": "https://example.com/ci.yml"})
if len(nodes) != 1 { t.Fatalf("expected 1 node, got %d", len(nodes)) }
if !strings.Contains(nodes[0].label, "remote:") { t.Error("expected remote: label") }
}
func TestParseMap_Project_NoFiles(t *testing.T) {
b := &treeBuilder{visited: map[string]bool{}, cfg: fetcher.GitLabConfig{}}
nodes := b.parseMap(map[string]any{"project": "g/p"})
if len(nodes) != 1 { t.Fatalf("expected 1 node, got %d", len(nodes)) }
if !strings.Contains(nodes[0].label, "project:") { t.Error("expected project: label") }
}
func TestParseMap_Project_WithRef(t *testing.T) {
b := &treeBuilder{visited: map[string]bool{}, cfg: fetcher.GitLabConfig{}}
nodes := b.parseMap(map[string]any{
"project": "g/p",
"ref": "main",
"file": "templates/ci.yml",
})
if len(nodes) != 1 { t.Fatalf("expected 1 node, got %d", len(nodes)) }
}
func TestParseMap_Project_MultipleFiles(t *testing.T) {
b := &treeBuilder{visited: map[string]bool{}, cfg: fetcher.GitLabConfig{}}
nodes := b.parseMap(map[string]any{
"project": "g/p",
"file": []any{"a.yml", "b.yml"},
})
if len(nodes) != 2 { t.Fatalf("expected 2 nodes, got %d", len(nodes)) }
}
func TestParseMap_Component_WithDollar(t *testing.T) {
b := &treeBuilder{visited: map[string]bool{}}
// Component ref with $ — skipped (dynamic, can't resolve)
nodes := b.parseMap(map[string]any{"component": "${CI_SERVER_HOST}/group/project/comp@v1"})
if len(nodes) != 1 { t.Fatalf("expected 1 node, got %d", len(nodes)) }
if nodes[0].jobs != nil { t.Error("jobs should be nil for unresolvable component") }
}
func TestParseMap_Component_BadRef(t *testing.T) {
b := &treeBuilder{visited: map[string]bool{}, cfg: fetcher.GitLabConfig{}}
nodes := b.parseMap(map[string]any{"component": "no-at-sign"})
if len(nodes) != 1 { t.Fatalf("expected 1 node, got %d", len(nodes)) }
}
func TestParseMap_Unknown(t *testing.T) {
b := &treeBuilder{visited: map[string]bool{}}
nodes := b.parseMap(map[string]any{"unknown_key": "value"})
if len(nodes) != 0 { t.Error("expected no nodes for unknown map keys") }
}
// ── recurseLocal ──────────────────────────────────────────────────────────────
func TestRecurseLocal_FileNotFound(t *testing.T) {
b := &treeBuilder{visited: map[string]bool{}, baseDir: "/nonexistent"}
node := &treeNode{id: "n1"}
b.recurseLocal(node, "missing.yml")
if node.jobs != nil { t.Error("jobs should remain nil on missing file") }
}
func TestRecurseLocal_ValidFile(t *testing.T) {
dir := t.TempDir()
ciPath := filepath.Join(dir, "ci.yml")
if err := os.WriteFile(ciPath, []byte("job1:\n script: echo\n"), 0o644); err != nil {
t.Fatal(err)
}
b := &treeBuilder{visited: map[string]bool{}, baseDir: dir}
node := &treeNode{id: "n1"}
b.recurseLocal(node, "ci.yml")
if len(node.jobs) == 0 { t.Error("expected jobs to be populated") }
}
func TestRecurseLocal_VisitedPrevents_Loop(t *testing.T) {
dir := t.TempDir()
ciPath := filepath.Join(dir, "ci.yml")
if err := os.WriteFile(ciPath, []byte("job1:\n script: echo\n"), 0o644); err != nil {
t.Fatal(err)
}
b := &treeBuilder{
visited: map[string]bool{"local:" + ciPath: true},
baseDir: dir,
}
node := &treeNode{id: "n1"}
b.recurseLocal(node, "ci.yml")
if len(node.jobs) != 0 { t.Error("visited node should not be parsed again") }
}
func TestRecurseLocal_WithNestedIncludes(t *testing.T) {
dir := t.TempDir()
// parent.yml includes child.yml
childPath := filepath.Join(dir, "child.yml")
if err := os.WriteFile(childPath, []byte("child-job:\n script: echo\n"), 0o644); err != nil {
t.Fatal(err)
}
parentPath := filepath.Join(dir, "parent.yml")
parentContent := "include:\n - local: child.yml\nparent-job:\n script: echo\n"
if err := os.WriteFile(parentPath, []byte(parentContent), 0o644); err != nil {
t.Fatal(err)
}
b := &treeBuilder{visited: map[string]bool{}, baseDir: dir}
node := &treeNode{id: "n1"}
b.recurseLocal(node, "parent.yml")
if len(node.children) == 0 { t.Error("expected children from nested include") }
}
func TestRecurseLocal_InvalidYAML(t *testing.T) {
dir := t.TempDir()
ciPath := filepath.Join(dir, "bad.yml")
if err := os.WriteFile(ciPath, []byte(":\tbad\tyaml\n"), 0o644); err != nil {
t.Fatal(err)
}
b := &treeBuilder{visited: map[string]bool{}, baseDir: dir}
node := &treeNode{id: "n1"}
b.recurseLocal(node, "bad.yml")
if node.jobs != nil { t.Error("invalid YAML should not set jobs") }
}
// ── directJobs ────────────────────────────────────────────────────────────────
func TestDirectJobs_MissingFile(t *testing.T) {
if jobs := directJobs("/nonexistent/ci.yml"); jobs != nil {
t.Error("expected nil for missing file")
}
}
func TestDirectJobs_ValidFile(t *testing.T) {
dir := t.TempDir()
ciPath := filepath.Join(dir, "ci.yml")
if err := os.WriteFile(ciPath, []byte("build:\n script: make\n"), 0o644); err != nil {
t.Fatal(err)
}
jobs := directJobs(ciPath)
if len(jobs) == 0 { t.Error("expected jobs from valid file") }
}
// ── renderTree ────────────────────────────────────────────────────────────────
func TestRenderTree_Basic(t *testing.T) {
root := &treeNode{
id: "root",
label: "main.yml",
class: "main",
jobs: []string{"job-a", "job-b"},
children: []*treeNode{
{id: "inc1", label: "local: child.yml", class: "local"},
},
}
out := renderTree(root)
if !strings.Contains(out, "main.yml") { t.Error("expected root label") }
if !strings.Contains(out, "job-a") { t.Error("expected job-a") }
if !strings.Contains(out, "child.yml") { t.Error("expected child label") }
if !strings.Contains(out, "root --> inc1") { t.Error("expected edge root→inc1") }
}
// ── Includes (integration) ────────────────────────────────────────────────────
func TestIncludes_LocalFile(t *testing.T) {
dir := t.TempDir()
childPath := filepath.Join(dir, "child.yml")
if err := os.WriteFile(childPath, []byte("child-job:\n script: echo\n"), 0o644); err != nil {
t.Fatal(err)
}
sourcePath := filepath.Join(dir, "pipeline.yml")
rawIncludes := []any{map[string]any{"local": "child.yml"}}
out := Includes(sourcePath, rawIncludes, fetcher.GitLabConfig{})
if !strings.Contains(out, "local:") { t.Error("expected local: node") }
}
func TestIncludes_NoIncludes(t *testing.T) {
dir := t.TempDir()
sourcePath := filepath.Join(dir, "pipeline.yml")
out := Includes(sourcePath, nil, fetcher.GitLabConfig{})
if !strings.Contains(out, "flowchart") { t.Error("expected flowchart") }
}
// ── fetchComponentFile (graph package) ────────────────────────────────────────
func TestGraphFetchComponentFile_PrimarySuccess(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
fmt.Fprintln(w, "comp-job:\n script: echo")
}))
defer srv.Close()
cfg := fetcher.GitLabConfig{BaseURL: srv.URL}
data, err := fetchComponentFile(cfg, "g/p", "mycomp", "v1")
if err != nil {
t.Fatalf("primary success: unexpected error: %v", err)
}
if len(data) == 0 {
t.Error("expected non-empty data")
}
}
// ── recurseRemote ─────────────────────────────────────────────────────────────
func TestRecurseRemote_AlreadyVisited(t *testing.T) {
b := &treeBuilder{
visited: map[string]bool{"remote:http://example.com/ci.yml": true},
cfg: fetcher.GitLabConfig{},
baseDir: "/tmp",
}
node := &treeNode{id: "n1"}
b.recurseRemote(node, "http://example.com/ci.yml")
// Visited: nothing should be fetched or added.
}
func TestRecurseRemote_ValidWithJobs(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
fmt.Fprintln(w, "remote-job:\n script: echo")
}))
defer srv.Close()
b := &treeBuilder{
visited: map[string]bool{},
cfg: fetcher.GitLabConfig{},
baseDir: "/tmp",
}
node := &treeNode{id: "n1"}
b.recurseRemote(node, srv.URL+"/ci.yml")
if len(node.jobs) == 0 {
t.Error("expected job names from remote YAML")
}
}
func TestRecurseRemote_InvalidYAML(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
fmt.Fprintln(w, ":\tbad\tyaml")
}))
defer srv.Close()
b := &treeBuilder{
visited: map[string]bool{},
cfg: fetcher.GitLabConfig{},
baseDir: "/tmp",
}
node := &treeNode{id: "n1"}
b.recurseRemote(node, srv.URL+"/bad.yml")
// ParseBytes fails → early return; no panic.
}
func TestRecurseRemote_WithSubIncludes(t *testing.T) {
// The remote YAML includes a second URL (sub-includes path).
var callCount int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
callCount++
w.WriteHeader(200)
if callCount == 1 {
// First call: returns YAML with a remote sub-include.
fmt.Fprintln(w, "include:\n - remote: http://127.0.0.1:0/sub.yml\nparent-job:\n script: echo")
} else {
// Sub-include fetch will fail (connection refused) — that's OK; we
// just need the sub-include routing path to be executed.
w.WriteHeader(500)
}
}))
defer srv.Close()
b := &treeBuilder{
visited: map[string]bool{},
cfg: fetcher.GitLabConfig{},
baseDir: "/tmp",
counter: 0,
}
node := &treeNode{id: "n1"}
b.recurseRemote(node, srv.URL+"/ci.yml")
// parent-job should be in node.jobs even if sub-include fails.
if len(node.jobs) == 0 {
t.Error("expected job names from remote YAML")
}
}
// ── recurseComponent ─────────────────────────────────────────────────────────
// tlsHost returns a helper that creates a TLS test server, swaps http.DefaultTransport
// so the test client trusts the self-signed cert, and returns (close, host).
func tlsComponent(t *testing.T, handler http.HandlerFunc) (host string) {
t.Helper()
srv := httptest.NewTLSServer(handler)
t.Cleanup(srv.Close)
orig := http.DefaultTransport
http.DefaultTransport = srv.Client().Transport
t.Cleanup(func() { http.DefaultTransport = orig })
return srv.URL[len("https://"):]
}
func TestRecurseComponent_AlreadyVisited(t *testing.T) {
b := &treeBuilder{
visited: map[string]bool{"component:gitlab.com/g/p/comp@v1": true},
cfg: fetcher.GitLabConfig{},
baseDir: "/tmp",
}
node := &treeNode{id: "n1"}
b.recurseComponent(node, "gitlab.com/g/p/comp@v1")
// Already visited: nothing happens.
}
func TestRecurseComponent_DollarRef(t *testing.T) {
// ref containing $ → early return (line 196-197)
b := &treeBuilder{visited: map[string]bool{}, cfg: fetcher.GitLabConfig{}}
node := &treeNode{id: "n1"}
b.recurseComponent(node, "$CI_SERVER/g/p/comp@v1")
// no panic, node unchanged
}
func TestRecurseComponent_BadRef(t *testing.T) {
// ref with no @ → parseComponentRef fails (line 206-208)
b := &treeBuilder{visited: map[string]bool{}, cfg: fetcher.GitLabConfig{}}
node := &treeNode{id: "n1"}
b.recurseComponent(node, "gitlab.com/g/p/mycomp-no-version")
// no panic
}
func TestRecurseComponent_FetchError(t *testing.T) {
host := tlsComponent(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(404)
})
b := &treeBuilder{
visited: map[string]bool{},
cfg: fetcher.GitLabConfig{},
baseDir: "/tmp",
}
node := &treeNode{id: "n1"}
b.recurseComponent(node, host+"/g/p/mycomp@v1")
// 404 → fetch error → early return; no panic.
}
func TestRecurseComponent_InvalidYAML(t *testing.T) {
host := tlsComponent(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
fmt.Fprintln(w, ":\tbad\tyaml")
})
b := &treeBuilder{
visited: map[string]bool{},
cfg: fetcher.GitLabConfig{},
baseDir: "/tmp",
}
node := &treeNode{id: "n1"}
b.recurseComponent(node, host+"/g/p/mycomp@v1")
// fetch succeeds but ParseBytes fails → early return; no panic.
}
func TestRecurseComponent_ValidYAML(t *testing.T) {
host := tlsComponent(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
fmt.Fprintln(w, "comp-job:\n script: echo component")
})
b := &treeBuilder{
visited: map[string]bool{},
cfg: fetcher.GitLabConfig{},
baseDir: "/tmp",
}
node := &treeNode{id: "n1"}
b.recurseComponent(node, host+"/g/p/mycomp@v1")
if len(node.jobs) == 0 {
t.Error("expected job names from component YAML")
}
}
// ── recurseProject ────────────────────────────────────────────────────────────
func TestRecurseProject_AlreadyVisited(t *testing.T) {
b := &treeBuilder{
visited: map[string]bool{"project:g/p:ci.yml@main": true},
cfg: fetcher.GitLabConfig{Token: "tok"},
baseDir: "/tmp",
}
node := &treeNode{id: "n1"}
b.recurseProject(node, "g/p", "ci.yml", "main")
// Already visited: nothing happens.
}
func TestRecurseProject_WithToken(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
fmt.Fprintln(w, "proj-job:\n script: echo project")
}))
defer srv.Close()
b := &treeBuilder{
visited: map[string]bool{},
cfg: fetcher.GitLabConfig{BaseURL: srv.URL, Token: "tok"},
baseDir: "/tmp",
}
node := &treeNode{id: "n1"}
b.recurseProject(node, "g/p", "ci.yml", "main")
if len(node.jobs) == 0 {
t.Error("expected job names from project YAML")
}
}
func TestRecurseProject_FetchError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(404)
}))
defer srv.Close()
b := &treeBuilder{
visited: map[string]bool{},
cfg: fetcher.GitLabConfig{BaseURL: srv.URL, Token: "tok"},
baseDir: "/tmp",
}
node := &treeNode{id: "n1"}
b.recurseProject(node, "g/p", "ci.yml", "main")
// fetch fails → early return; no panic.
}
func TestRecurseProject_InvalidYAML(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
fmt.Fprintln(w, ":\tbad\tyaml")
}))
defer srv.Close()
b := &treeBuilder{
visited: map[string]bool{},
cfg: fetcher.GitLabConfig{BaseURL: srv.URL, Token: "tok"},
baseDir: "/tmp",
}
node := &treeNode{id: "n1"}
b.recurseProject(node, "g/p", "ci.yml", "main")
// ParseBytes fails → early return; no panic.
}
func TestRecurseProject_WithSubIncludes(t *testing.T) {
// Project YAML includes sub-includes → covers recurseProject line 170 (buildChildren).
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
fmt.Fprintln(w, "include:\n - remote: http://127.0.0.1:0/sub.yml\nproj-job:\n script: echo")
}))
defer srv.Close()
b := &treeBuilder{
visited: map[string]bool{},
cfg: fetcher.GitLabConfig{BaseURL: srv.URL, Token: "tok"},
baseDir: "/tmp",
}
node := &treeNode{id: "n1"}
b.recurseProject(node, "g/p", "ci.yml", "main")
// proj-job should be populated; sub-include fetch fails (port 0) — that's fine.
if len(node.jobs) == 0 {
t.Error("expected job names from project YAML")
}
}
func TestRecurseComponent_WithSubIncludes(t *testing.T) {
// Component YAML includes sub-includes → covers recurseComponent line 221 (buildChildren).
host := tlsComponent(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
fmt.Fprintln(w, "include:\n - remote: http://127.0.0.1:0/sub.yml\ncomp-job:\n script: echo")
})
b := &treeBuilder{
visited: map[string]bool{},
cfg: fetcher.GitLabConfig{},
baseDir: "/tmp",
}
node := &treeNode{id: "n1"}
b.recurseComponent(node, host+"/g/p/mycomp@v1")
if len(node.jobs) == 0 {
t.Error("expected job names from component YAML")
}
}
// ── fetchComponentFile (fallback path) ───────────────────────────────────────
func TestGraphFetchComponentFile_FallbackSuccess(t *testing.T) {
// Primary path (templates/comp.yml) returns 404; fallback (templates/comp/template.yml) returns 200.
var reqCount int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
reqCount++
if reqCount == 1 {
w.WriteHeader(404)
} else {
w.WriteHeader(200)
fmt.Fprintln(w, "fallback-job:\n script: echo")
}
}))
defer srv.Close()
cfg := fetcher.GitLabConfig{BaseURL: srv.URL}
data, err := fetchComponentFile(cfg, "g/p", "mycomp", "v1")
if err != nil {
t.Fatalf("fallback success: unexpected error: %v", err)
}
if len(data) == 0 {
t.Error("expected non-empty data from fallback path")
}
}
// Ensure model import is used.
var _ = model.Job{}
+3 -3
View File
@@ -27,6 +27,7 @@ func Pipeline(p *model.Pipeline) string {
w(" classDef manual fill:#fc6d26,stroke:#e56b1f,color:#fff")
w(" classDef trigger fill:#6b4fbb,stroke:#5a3fa0,color:#fff")
w(" classDef delayed fill:#fca326,stroke:#d98a1e,color:#333")
w(" classDef on_failure fill:#d9534f,stroke:#c0392b,color:#fff")
w("")
// Collect visible (non-template) job names; sort for stable output.
@@ -82,9 +83,6 @@ func Pipeline(p *model.Pipeline) string {
// Emit one subgraph per stage.
for _, stage := range stages {
jobs := byStage[stage]
if len(jobs) == 0 {
continue
}
sort.Strings(jobs)
wf(" subgraph %s[\"%s\"]", stageID(stage), stage)
for _, name := range jobs {
@@ -152,6 +150,8 @@ func jobClass(job model.Job) string {
return "manual"
case "delayed":
return "delayed"
case "on_failure":
return "on_failure"
}
return "regular"
}
+133
View File
@@ -0,0 +1,133 @@
package graph
import (
"strings"
"testing"
"git.k3nny.fr/glint/internal/model"
)
func TestPipeline_EmptyJobs(t *testing.T) {
p := &model.Pipeline{}
out := Pipeline(p)
if !strings.Contains(out, "no jobs defined") {
t.Errorf("expected 'no jobs defined', got:\n%s", out)
}
}
func TestPipeline_BasicTwoStages(t *testing.T) {
p := &model.Pipeline{
Stages: []string{"build", "test"},
Jobs: map[string]model.Job{
"build-job": {Name: "build-job", Stage: "build"},
"test-job": {Name: "test-job", Stage: "test"},
".tmpl": {Name: ".tmpl", Stage: "build"}, // hidden — excluded
},
}
out := Pipeline(p)
if !strings.Contains(out, "build-job") { t.Error("expected build-job") }
if !strings.Contains(out, "test-job") { t.Error("expected test-job") }
if strings.Contains(out, ".tmpl") { t.Error(".tmpl should be excluded") }
// Classic mode: stage → stage connector
if !strings.Contains(out, "Classic") { t.Error("expected classic mode comment") }
}
func TestPipeline_DAGMode(t *testing.T) {
p := &model.Pipeline{
Stages: []string{"build", "test"},
Jobs: map[string]model.Job{
"build-job": {Name: "build-job", Stage: "build"},
"test-job": {Name: "test-job", Stage: "test", Needs: []any{"build-job"}},
},
}
out := Pipeline(p)
if !strings.Contains(out, "DAG") { t.Error("expected DAG mode comment") }
}
func TestPipeline_DAGMode_CrossPipelineNeed(t *testing.T) {
p := &model.Pipeline{
Stages: []string{"build"},
Jobs: map[string]model.Job{
"build-job": {Name: "build-job", Stage: "build",
Needs: []any{map[string]any{"pipeline": "other", "job": "j"}}},
},
}
out := Pipeline(p)
// cross-pipeline need should not add an arrow (job not in local map)
if !strings.Contains(out, "DAG") { t.Error("expected DAG mode comment") }
}
func TestPipeline_JobNoStage(t *testing.T) {
p := &model.Pipeline{
Jobs: map[string]model.Job{
"myjob": {Name: "myjob"},
},
}
out := Pipeline(p)
if !strings.Contains(out, "myjob") { t.Error("expected myjob") }
}
func TestPipeline_SingleStage(t *testing.T) {
// Single stage → no connector
p := &model.Pipeline{
Stages: []string{"build"},
Jobs: map[string]model.Job{
"a": {Name: "a", Stage: "build"},
},
}
out := Pipeline(p)
if strings.Contains(out, "-->") { t.Error("single stage should not have connectors") }
}
// ── stageID / sanitizeID ──────────────────────────────────────────────────────
func TestStageID(t *testing.T) {
if stageID("build") != "stage_build" { t.Error("plain name") }
if stageID("my-stage") != "stage_my_stage" { t.Error("hyphens replaced") }
}
func TestSanitizeID(t *testing.T) {
cases := []struct{ in, want string }{
{"build", "build"},
{"my-stage", "my_stage"},
{"with space", "with_space"},
{"ABC_123", "ABC_123"},
}
for _, tc := range cases {
if sanitizeID(tc.in) != tc.want {
t.Errorf("sanitizeID(%q)=%q want %q", tc.in, sanitizeID(tc.in), tc.want)
}
}
}
// ── jobClass ──────────────────────────────────────────────────────────────────
func TestJobClass(t *testing.T) {
if jobClass(model.Job{When: "manual"}) != "manual" { t.Error("manual") }
if jobClass(model.Job{When: "delayed"}) != "delayed" { t.Error("delayed") }
if jobClass(model.Job{When: "on_failure"}) != "on_failure" { t.Error("on_failure") }
if jobClass(model.Job{Trigger: "x"}) != "trigger" { t.Error("trigger") }
if jobClass(model.Job{}) != "regular" { t.Error("regular") }
}
func TestPipeline_OnFailureClass(t *testing.T) {
p := &model.Pipeline{
Stages: []string{"cleanup"},
Jobs: map[string]model.Job{
"cleanup-job": {Name: "cleanup-job", Stage: "cleanup", When: "on_failure"},
},
}
out := Pipeline(p)
if !strings.Contains(out, "on_failure") { t.Error("expected on_failure class") }
if !strings.Contains(out, "#d9534f") { t.Error("expected on_failure color in classDef") }
}
// ── needsJobName ──────────────────────────────────────────────────────────────
func TestNeedsJobName(t *testing.T) {
if needsJobName("job-a") != "job-a" { t.Error("string form") }
if needsJobName(map[string]any{"job": "job-b"}) != "job-b" { t.Error("map form") }
if needsJobName(map[string]any{"pipeline": "other", "job": "j"}) != "" { t.Error("cross-pipeline should return empty") }
if needsJobName(map[string]any{"no-job": true}) != "" { t.Error("map without job key") }
if needsJobName(42) != "" { t.Error("unexpected type") }
}
+524 -107
View File
@@ -10,37 +10,138 @@ import (
"strings"
"time"
"git.k3nny.fr/glint/internal/cicontext"
"git.k3nny.fr/glint/internal/model"
)
// Layout constants (pixels) tuned to resemble GitLab's full pipeline graph view.
const (
chipW = 178 // job chip width
chipH = 34 // job chip height
chipGap = 6 // vertical gap between chips in a column
iconCX = 14 // status circle: x offset from chip left edge to circle centre
iconR = 7 // status circle radius (14 px diameter)
textLeft = 27 // job name text: x offset from chip left edge
labelH = 30 // stage-name label area height (text + bottom gap)
topPad = 40 // outer top padding
sidePad = 40 // outer left / right padding
stageGap = 50 // horizontal gap between stage columns (connector space)
botPad = 48 // outer bottom padding (legend lives here)
chipW = 178 // job chip width
chipH = 34 // job chip height
chipGap = 6 // vertical gap between chips in a column
iconCX = 14 // status circle: x offset from chip left edge to centre
iconR = 7 // status circle radius (14 px diameter)
textLeft = 27 // job name text: x offset from chip left edge
labelH = 30 // stage-name label area height (text + bottom gap)
topPad = 40 // outer top padding
sidePad = 40 // outer left / right padding
stageGap = 50 // horizontal gap between stage columns (connector space)
subStageGap = 20 // horizontal gap between sub-columns within the same stage
botPad = 48 // outer bottom padding (legend lives here)
)
type svgPt struct{ x, y int }
// column represents one vertical stack of job chips in the SVG.
// A declared GitLab stage maps to one column unless jobs within it have
// same-stage needs:, in which case it splits into topological sub-columns.
type column struct {
stage string // declared GitLab stage name
jobs []string // job names in this column, topologically ordered
}
// computeColumns assigns jobs to SVG columns.
// When jobs within the same declared stage have needs: relationships between
// each other, the stage is split into topological sub-columns so that
// depended-upon jobs appear to the left of the jobs that depend on them.
func computeColumns(stages []string, byStage map[string][]string, jobs map[string]model.Job) []column {
var cols []column
for _, stage := range stages {
stageJobs := byStage[stage]
sort.Strings(stageJobs)
if len(stageJobs) == 0 {
continue
}
// Index same-stage membership.
stageSet := make(map[string]bool, len(stageJobs))
for _, j := range stageJobs {
stageSet[j] = true
}
// Check whether any job has a same-stage need.
hasIntraNeeds := false
outer:
for _, j := range stageJobs {
for _, need := range jobs[j].Needs {
if dep := needsJobName(need); dep != "" && stageSet[dep] {
hasIntraNeeds = true
break outer
}
}
}
if !hasIntraNeeds {
cols = append(cols, column{stage: stage, jobs: stageJobs})
continue
}
// Compute topological depth within the stage.
depth := make(map[string]int, len(stageJobs))
for _, j := range stageJobs {
depth[j] = -1
}
inProgress := make(map[string]bool, len(stageJobs))
var computeDepth func(j string) int
computeDepth = func(j string) int {
if depth[j] >= 0 {
return depth[j]
}
if inProgress[j] {
return 0 // cycle guard (cycles already rejected by GL029)
}
inProgress[j] = true
maxDep := -1
for _, need := range jobs[j].Needs {
dep := needsJobName(need)
if dep == "" || !stageSet[dep] {
continue
}
d := computeDepth(dep)
if d > maxDep {
maxDep = d
}
}
depth[j] = maxDep + 1
return depth[j]
}
for _, j := range stageJobs {
if depth[j] < 0 {
computeDepth(j)
}
}
// One sub-column per topological depth level.
maxDepth := 0
for _, j := range stageJobs {
if depth[j] > maxDepth {
maxDepth = depth[j]
}
}
for l := 0; l <= maxDepth; l++ {
var levelJobs []string
for _, j := range stageJobs {
if depth[j] == l {
levelJobs = append(levelJobs, j)
}
}
if len(levelJobs) > 0 {
sort.Strings(levelJobs)
cols = append(cols, column{stage: stage, jobs: levelJobs})
}
}
}
return cols
}
// RenderPipeline writes a PNG (or SVG fallback) file with a GitLab CI-style
// pipeline layout and returns the path to the generated file.
func RenderPipeline(p *model.Pipeline, outDir string) (string, error) {
func RenderPipeline(p *model.Pipeline, outDir string, ctx *cicontext.Context) (string, error) {
if err := os.MkdirAll(outDir, 0o755); err != nil {
return "", fmt.Errorf("creating output directory %s: %w", outDir, err)
}
svg, err := pipelineSVG(p)
if err != nil {
return "", err
}
svg := pipelineSVG(p, ctx)
ts := time.Now().Format("20060102-150405")
svgPath := filepath.Join(outDir, "pipeline-"+ts+".svg")
@@ -56,23 +157,39 @@ func RenderPipeline(p *model.Pipeline, outDir string) (string, error) {
return svgPath, nil
}
// RenderHTML writes a self-contained HTML file with the pipeline graph embedded
// inline with pan/zoom and a job-detail sidebar, then returns the output path.
func RenderHTML(p *model.Pipeline, outDir string, ctx *cicontext.Context) (string, error) {
if err := os.MkdirAll(outDir, 0o755); err != nil {
return "", fmt.Errorf("creating output directory %s: %w", outDir, err)
}
svg := pipelineSVG(p, ctx)
html := htmlPage(svg)
ts := time.Now().Format("20060102-150405")
htmlPath := filepath.Join(outDir, "pipeline-"+ts+".html")
if err := os.WriteFile(htmlPath, []byte(html), 0o644); err != nil {
return "", fmt.Errorf("writing HTML: %w", err)
}
return htmlPath, nil
}
// convertToPNG tries rsvg-convert, Inkscape, magick, and convert (not on Windows).
func convertToPNG(svgPath, pngPath string) bool {
type cand struct {
args []string
skipWin bool
candidates := [][]string{
{"rsvg-convert", "--output", pngPath, svgPath},
{"inkscape", "--export-filename=" + pngPath, svgPath},
{"magick", svgPath, pngPath},
}
for _, c := range []cand{
{[]string{"rsvg-convert", "--output", pngPath, svgPath}, false},
{[]string{"inkscape", "--export-filename=" + pngPath, svgPath}, false},
{[]string{"magick", svgPath, pngPath}, false},
{[]string{"convert", svgPath, pngPath}, true},
} {
if c.skipWin && runtime.GOOS == "windows" {
continue
}
if bin, err := exec.LookPath(c.args[0]); err == nil {
if exec.Command(bin, c.args[1:]...).Run() == nil {
// `convert` is the legacy ImageMagick name; skip on Windows where the name
// collides with the built-in FAT→NTFS converter.
if runtime.GOOS != "windows" {
candidates = append(candidates, []string{"convert", svgPath, pngPath})
}
for _, args := range candidates {
if bin, err := exec.LookPath(args[0]); err == nil {
if exec.Command(bin, args[1:]...).Run() == nil {
return true
}
}
@@ -80,7 +197,7 @@ func convertToPNG(svgPath, pngPath string) bool {
return false
}
func pipelineSVG(p *model.Pipeline) (string, error) {
func pipelineSVG(p *model.Pipeline, ctx *cicontext.Context) string {
// Collect visible (non-template) job names in sorted order.
var visible []string
for name := range p.Jobs {
@@ -91,7 +208,17 @@ func pipelineSVG(p *model.Pipeline) (string, error) {
sort.Strings(visible)
if len(visible) == 0 {
return svgEmpty(), nil
return svgEmpty()
}
// Precompute which jobs are skipped in the given context.
skippedJobs := make(map[string]bool)
if ctx != nil {
for _, name := range visible {
if cicontext.EvalJob(p.Jobs[name], ctx) == cicontext.JobSkipped {
skippedJobs[name] = true
}
}
}
// Group by stage; fall back to "test" (GitLab default) when stage is unset.
@@ -124,32 +251,42 @@ func pipelineSVG(p *model.Pipeline) (string, error) {
}
}
// Compute per-stage column heights and job anchor points for connectors.
colH := make([]int, len(stages)) // height of the chip stack (no label)
colCtY := make([]int, len(stages)) // y centre of the chip stack
// ── Column layout ─────────────────────────────────────────────────────────
// Split stages into sub-columns when intra-stage needs exist.
cols := computeColumns(stages, byStage, p.Jobs)
// X position of each column's left edge.
colX := make([]int, len(cols))
if len(cols) > 0 {
colX[0] = sidePad
for i := 1; i < len(cols); i++ {
gap := stageGap
if cols[i].stage == cols[i-1].stage {
gap = subStageGap
}
colX[i] = colX[i-1] + chipW + gap
}
}
// Compute SVG dimensions and per-job connector anchor points.
maxColH := 0
rightMid := make(map[string]svgPt)
leftMid := make(map[string]svgPt)
for i, stage := range stages {
jobs := byStage[stage]
sort.Strings(jobs)
n := len(jobs)
for ci, col := range cols {
n := len(col.jobs)
h := n*chipH + max(0, n-1)*chipGap
colH[i] = h
if h > maxColH {
maxColH = h
}
cx := sidePad + i*(chipW+stageGap)
for j, name := range jobs {
for j, name := range col.jobs {
chy := topPad + labelH + j*(chipH+chipGap)
rightMid[name] = svgPt{cx + chipW, chy + chipH/2}
leftMid[name] = svgPt{cx, chy + chipH/2}
rightMid[name] = svgPt{colX[ci] + chipW, chy + chipH/2}
leftMid[name] = svgPt{colX[ci], chy + chipH/2}
}
colCtY[i] = topPad + labelH + h/2
}
svgW := sidePad*2 + len(stages)*chipW + max(0, len(stages)-1)*stageGap
svgW := colX[len(cols)-1] + chipW + sidePad
svgH := topPad + labelH + maxColH + botPad
// DAG mode: any visible job with a needs: list triggers job-to-job arrows.
@@ -179,49 +316,35 @@ func pipelineSVG(p *model.Pipeline) (string, error) {
// White page background.
wf(` <rect width="%d" height="%d" fill="#ffffff"/>`, svgW, svgH)
// ── Stage columns ─────────────────────────────────────────────────────────
for i, stage := range stages {
cx := sidePad + i*(chipW+stageGap)
jobs := byStage[stage]
sort.Strings(jobs)
// ── Stage headers ─────────────────────────────────────────────────────────
// Each declared stage may span multiple sub-columns; draw one header per stage.
drawnHeader := make(map[string]bool)
for ci, col := range cols {
if drawnHeader[col.stage] {
continue
}
// Span all sub-columns that belong to this stage.
x1 := colX[ci]
x2 := colX[ci] + chipW
for j := ci + 1; j < len(cols) && cols[j].stage == col.stage; j++ {
x2 = colX[j] + chipW
}
centerX := (x1 + x2) / 2
// Stage name small, gray, uppercase, centered above the chip stack.
// Stage name small, gray, uppercase.
wf(` <text x="%d" y="%d" text-anchor="middle" `+
`font-family="'GitLab Sans','Segoe UI',-apple-system,BlinkMacSystemFont,sans-serif" `+
`font-size="11" font-weight="600" letter-spacing="0.8" fill="#868686">%s</text>`,
cx+chipW/2, topPad+13, svgEsc(strings.ToUpper(stage)))
centerX, topPad+13, svgEsc(strings.ToUpper(col.stage)))
// Subtle separator line below stage name.
// Separator line spanning all sub-columns.
wf(` <line x1="%d" y1="%d" x2="%d" y2="%d" stroke="#eaeaea" stroke-width="1"/>`,
cx, topPad+21, cx+chipW, topPad+21)
x1, topPad+21, x2, topPad+21)
// Job chips.
for j, name := range jobs {
job := p.Jobs[name]
chy := topPad + labelH + j*(chipH+chipGap)
color := chipColor(job)
// Chip card (white, rounded, subtle border + shadow).
wf(` <rect x="%d" y="%d" width="%d" height="%d" rx="4" `+
`fill="#ffffff" stroke="#dde1e7" stroke-width="1" filter="url(#chip-shadow)"/>`,
cx, chy, chipW, chipH)
// Colored status indicator circle.
wf(` <circle cx="%d" cy="%d" r="%d" fill="%s"/>`,
cx+iconCX, chy+chipH/2, iconR, color)
// Icon symbol inside the circle.
drawChipIcon(&sb, job, cx+iconCX, chy+chipH/2)
// Job name text.
wf(` <text x="%d" y="%d" dominant-baseline="middle" `+
`font-family="'GitLab Sans','Segoe UI',-apple-system,BlinkMacSystemFont,sans-serif" `+
`font-size="13" fill="#303030">%s</text>`,
cx+textLeft, chy+chipH/2, svgEsc(svgTrunc(name, 20)))
}
drawnHeader[col.stage] = true
}
// ── Connectors ────────────────────────────────────────────────────────────
// ── Connectors (drawn before chips so they appear behind job blocks) ───────
const connStroke = "#dbdbdb"
if dagMode {
@@ -246,27 +369,123 @@ func pipelineSVG(p *model.Pipeline) (string, error) {
}
}
} else {
// Classic: one connector per adjacent stage pair.
for i := 0; i < len(stages)-1; i++ {
x1 := sidePad + i*(chipW+stageGap) + chipW
x2 := sidePad + (i+1)*(chipW+stageGap)
y1 := colCtY[i]
y2 := colCtY[i+1]
// Classic: bus-bar connectors between adjacent stage columns.
// Every job in stage[i] fans to a vertical bus at the midpoint gap,
// then fans out to every job in stage[i+1].
for ci := 0; ci < len(cols)-1; ci++ {
x1 := colX[ci] + chipW // right edge of current column
x2 := colX[ci+1] // left edge of next column
midX := (x1 + x2) / 2
if y1 == y2 {
// Straight horizontal line.
wf(` <line x1="%d" y1="%d" x2="%d" y2="%d" stroke="%s" stroke-width="2"/>`,
x1, y1, x2-7, y2, connStroke)
} else {
// L-shaped elbow: right → vertical → right.
wf(` <polyline points="%d,%d %d,%d %d,%d %d,%d" `+
`stroke="%s" stroke-width="2" fill="none" stroke-linejoin="round"/>`,
x1, y1, midX, y1, midX, y2, x2-7, y2, connStroke)
srcJobs := cols[ci].jobs
dstJobs := cols[ci+1].jobs
// Collect all Y midpoints to span the vertical bus bar.
var allYs []int
srcY := make([]int, len(srcJobs))
dstY := make([]int, len(dstJobs))
for j, name := range srcJobs {
srcY[j] = rightMid[name].y
allYs = append(allYs, srcY[j])
}
// Arrowhead at the destination.
wf(` <polygon points="%d,%d %d,%d %d,%d" fill="%s"/>`,
x2-7, y2-4, x2, y2, x2-7, y2+4, connStroke)
for j, name := range dstJobs {
dstY[j] = leftMid[name].y
allYs = append(allYs, dstY[j])
}
sort.Ints(allYs)
busMinY, busMaxY := allYs[0], allYs[len(allYs)-1]
// Vertical bus bar at midX (only when there are multiple Y levels).
if busMinY < busMaxY {
wf(` <line x1="%d" y1="%d" x2="%d" y2="%d" stroke="%s" stroke-width="2"/>`,
midX, busMinY, midX, busMaxY, connStroke)
}
// Horizontal stubs from each source job to the bus.
for _, y := range srcY {
wf(` <line x1="%d" y1="%d" x2="%d" y2="%d" stroke="%s" stroke-width="2"/>`,
x1, y, midX, y, connStroke)
}
// Horizontal stubs from bus to each destination job, with arrowhead.
for _, y := range dstY {
wf(` <line x1="%d" y1="%d" x2="%d" y2="%d" stroke="%s" stroke-width="2"/>`,
midX, y, x2-7, y, connStroke)
wf(` <polygon points="%d,%d %d,%d %d,%d" fill="%s"/>`,
x2-7, y-4, x2, y, x2-7, y+4, connStroke)
}
}
}
// ── Job chips (drawn after connectors so they appear on top) ─────────────
for ci, col := range cols {
for j, name := range col.jobs {
job := p.Jobs[name]
chy := topPad + labelH + j*(chipH+chipGap)
isSkipped := skippedJobs[name]
cx := colX[ci]
// Build <desc> content: shown in the HTML sidebar and SVG viewer tooltips.
desc := "stage: " + col.stage
if job.When != "" {
desc += "\nwhen: " + job.When
}
if img := imageString(job.Image); img != "" {
desc += "\nimage: " + img
}
var needNames []string
for _, n := range job.Needs {
if s := needsJobName(n); s != "" {
needNames = append(needNames, s)
}
}
if len(needNames) > 0 {
desc += "\nneeds: " + strings.Join(needNames, ", ")
}
if isSkipped {
desc += "\nstate: skipped"
}
// data-job attribute enables JS click detection in the HTML output.
wf(` <g data-job="%s">`, svgEsc(name))
wf(` <title>%s</title>`, svgEsc(name))
wf(` <desc>%s</desc>`, svgEsc(desc))
color := chipColor(job)
if isSkipped {
color = "#868686"
}
// Chip card (white, rounded, subtle border + shadow).
// on_failure jobs get a dashed border to signal the failure path.
dashAttr := ""
if !isSkipped && job.When == "on_failure" {
dashAttr = ` stroke-dasharray="4,3"`
}
wf(` <rect x="%d" y="%d" width="%d" height="%d" rx="4" `+
`fill="#ffffff" stroke="#dde1e7" stroke-width="1"%s filter="url(#chip-shadow)"/>`,
cx, chy, chipW, chipH, dashAttr)
// Colored status indicator circle.
wf(` <circle cx="%d" cy="%d" r="%d" fill="%s"/>`,
cx+iconCX, chy+chipH/2, iconR, color)
// Icon inside the circle (omitted for skipped — grey circle speaks for itself).
if !isSkipped {
drawChipIcon(&sb, job, cx+iconCX, chy+chipH/2)
}
// Job name text — dimmed for skipped jobs.
textColor := "#303030"
if isSkipped {
textColor = "#868686"
}
wf(` <text x="%d" y="%d" dominant-baseline="middle" `+
`font-family="'GitLab Sans','Segoe UI',-apple-system,BlinkMacSystemFont,sans-serif" `+
`font-size="13" fill="%s">%s</text>`,
cx+textLeft, chy+chipH/2, textColor, svgEsc(svgTrunc(name, 20)))
wf(` </g>`)
}
}
@@ -276,6 +495,7 @@ func pipelineSVG(p *model.Pipeline) (string, error) {
{"#fc6d26", "manual"},
{"#6b4fbb", "trigger"},
{"#fca326", "delayed"},
{"#d9534f", "on_failure"},
}
const legendItemW = 82
legendY := topPad + labelH + maxColH + botPad/2
@@ -290,7 +510,7 @@ func pipelineSVG(p *model.Pipeline) (string, error) {
}
w(`</svg>`)
return sb.String(), nil
return sb.String()
}
// drawChipIcon writes an SVG symbol inside the status circle to help identify
@@ -298,7 +518,7 @@ func pipelineSVG(p *model.Pipeline) (string, error) {
func drawChipIcon(sb *strings.Builder, job model.Job, cx, cy int) {
if job.Trigger != nil {
// Right-pointing chevron for trigger jobs.
fmt.Fprintf(sb, " <polyline points=\"%d,%d %d,%d %d,%d\" "+
fmt.Fprintf(sb, " <polyline points=\"%d,%d %d,%d %d,%d\" "+
"stroke=\"#fff\" stroke-width=\"1.5\" fill=\"none\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n",
cx-3, cy-3, cx+3, cy, cx-3, cy+3)
return
@@ -306,18 +526,24 @@ func drawChipIcon(sb *strings.Builder, job model.Job, cx, cy int) {
switch job.When {
case "manual":
// Filled play triangle.
fmt.Fprintf(sb, " <polygon points=\"%d,%d %d,%d %d,%d\" fill=\"#fff\"/>\n",
fmt.Fprintf(sb, " <polygon points=\"%d,%d %d,%d %d,%d\" fill=\"#fff\"/>\n",
cx-3, cy-4, cx+5, cy, cx-3, cy+4)
case "delayed":
// Clock hands: vertical + horizontal.
fmt.Fprintf(sb, " <circle cx=\"%d\" cy=\"%d\" r=\"5\" stroke=\"#fff\" stroke-width=\"1.2\" fill=\"none\"/>\n", cx, cy)
fmt.Fprintf(sb, " <line x1=\"%d\" y1=\"%d\" x2=\"%d\" y2=\"%d\" stroke=\"#fff\" stroke-width=\"1.2\" stroke-linecap=\"round\"/>\n",
// Clock: circle + hands.
fmt.Fprintf(sb, " <circle cx=\"%d\" cy=\"%d\" r=\"5\" stroke=\"#fff\" stroke-width=\"1.2\" fill=\"none\"/>\n", cx, cy)
fmt.Fprintf(sb, " <line x1=\"%d\" y1=\"%d\" x2=\"%d\" y2=\"%d\" stroke=\"#fff\" stroke-width=\"1.2\" stroke-linecap=\"round\"/>\n",
cx, cy, cx, cy-3)
fmt.Fprintf(sb, " <line x1=\"%d\" y1=\"%d\" x2=\"%d\" y2=\"%d\" stroke=\"#fff\" stroke-width=\"1.2\" stroke-linecap=\"round\"/>\n",
fmt.Fprintf(sb, " <line x1=\"%d\" y1=\"%d\" x2=\"%d\" y2=\"%d\" stroke=\"#fff\" stroke-width=\"1.2\" stroke-linecap=\"round\"/>\n",
cx, cy, cx+2, cy+1)
case "on_failure":
// X mark for failure-path jobs.
fmt.Fprintf(sb, " <line x1=\"%d\" y1=\"%d\" x2=\"%d\" y2=\"%d\" stroke=\"#fff\" stroke-width=\"1.5\" stroke-linecap=\"round\"/>\n",
cx-3, cy-3, cx+3, cy+3)
fmt.Fprintf(sb, " <line x1=\"%d\" y1=\"%d\" x2=\"%d\" y2=\"%d\" stroke=\"#fff\" stroke-width=\"1.5\" stroke-linecap=\"round\"/>\n",
cx+3, cy-3, cx-3, cy+3)
default:
// Regular job: small white checkmark outline.
fmt.Fprintf(sb, " <polyline points=\"%d,%d %d,%d %d,%d\" "+
// Regular job: small white checkmark.
fmt.Fprintf(sb, " <polyline points=\"%d,%d %d,%d %d,%d\" "+
"stroke=\"#fff\" stroke-width=\"1.5\" fill=\"none\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n",
cx-3, cy, cx-1, cy+3, cx+4, cy-3)
}
@@ -332,10 +558,25 @@ func chipColor(job model.Job) string {
return "#fc6d26"
case "delayed":
return "#fca326"
case "on_failure":
return "#d9534f"
}
return "#1f75cb"
}
// imageString extracts the image name from a job Image field (string or map form).
func imageString(img any) string {
switch v := img.(type) {
case string:
return v
case map[string]any:
if name, ok := v["name"].(string); ok {
return name
}
}
return ""
}
func svgEsc(s string) string {
s = strings.ReplaceAll(s, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
@@ -361,3 +602,179 @@ func svgEmpty() string {
`</svg>`,
w, h, w, h, w/2, h/2)
}
// htmlPage wraps an SVG pipeline graph in a self-contained HTML page with
// mouse pan/zoom and a job-detail sidebar that appears on chip click.
func htmlPage(svgContent string) string {
var sb strings.Builder
sb.WriteString(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Pipeline Graph</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Segoe UI', Arial, sans-serif;
background: #f5f5f5;
display: flex;
height: 100vh;
overflow: hidden;
}
#sidebar {
width: 260px;
flex-shrink: 0;
background: #fff;
border-right: 1px solid #e0e0e0;
display: none;
flex-direction: column;
overflow-y: auto;
}
#sidebar.open { display: flex; }
#sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: .6rem .8rem;
border-bottom: 1px solid #e0e0e0;
background: #fafafa;
flex-shrink: 0;
}
#sidebar-header h3 {
font-size: .9rem;
color: #303030;
word-break: break-all;
}
#close-btn {
background: none;
border: none;
cursor: pointer;
font-size: 1rem;
color: #868686;
padding: .2rem .4rem;
margin-left: .4rem;
flex-shrink: 0;
}
#close-btn:hover { color: #303030; }
#sidebar-body { padding: .8rem; flex: 1; }
#sidebar-body pre {
font-size: .82rem;
color: #555;
white-space: pre-wrap;
line-height: 1.6;
}
#viewport {
flex: 1;
overflow: hidden;
cursor: grab;
position: relative;
background: #f5f5f5;
}
#viewport.dragging { cursor: grabbing; }
#svg-wrap {
display: inline-block;
transform-origin: 0 0;
padding: 20px;
}
g[data-job] { cursor: pointer; }
g[data-job]:hover > rect { stroke: #1f75cb; stroke-width: 2; }
</style>
</head>
<body>
<div id="sidebar">
<div id="sidebar-header">
<h3 id="sidebar-title">Job details</h3>
<button id="close-btn" title="Close">&#x2715;</button>
</div>
<div id="sidebar-body">
<pre id="sidebar-details"></pre>
</div>
</div>
<div id="viewport">
<div id="svg-wrap">
`)
sb.WriteString(svgContent)
sb.WriteString(`
</div>
</div>
<script>
(function() {
'use strict';
var vp = document.getElementById('viewport');
var wrap = document.getElementById('svg-wrap');
var sidebar = document.getElementById('sidebar');
var titleEl = document.getElementById('sidebar-title');
var detailsEl = document.getElementById('sidebar-details');
var tx = 0, ty = 0, scale = 1;
function applyTransform() {
wrap.style.transform = 'translate(' + tx + 'px,' + ty + 'px) scale(' + scale + ')';
}
// Zoom toward the cursor position.
vp.addEventListener('wheel', function(e) {
e.preventDefault();
var factor = e.deltaY < 0 ? 1.1 : 1 / 1.1;
var r = vp.getBoundingClientRect();
var mx = e.clientX - r.left;
var my = e.clientY - r.top;
tx = mx - (mx - tx) * factor;
ty = my - (my - ty) * factor;
scale *= factor;
applyTransform();
}, { passive: false });
// Drag to pan (skip if clicking on a job chip).
var dragging = false, ox = 0, oy = 0;
vp.addEventListener('mousedown', function(e) {
if (e.target.closest('g[data-job]')) return;
dragging = true;
ox = e.clientX - tx;
oy = e.clientY - ty;
vp.classList.add('dragging');
});
window.addEventListener('mousemove', function(e) {
if (!dragging) return;
tx = e.clientX - ox;
ty = e.clientY - oy;
applyTransform();
});
window.addEventListener('mouseup', function() {
dragging = false;
vp.classList.remove('dragging');
});
// Double-click on the background to reset the view.
vp.addEventListener('dblclick', function(e) {
if (e.target.closest('g[data-job]')) return;
tx = 0; ty = 0; scale = 1;
applyTransform();
});
// Click a job chip to show its details in the sidebar.
document.addEventListener('click', function(e) {
var g = e.target.closest('g[data-job]');
if (!g) return;
var jobName = g.dataset.job;
var descEl = g.querySelector('desc');
var desc = descEl ? descEl.textContent : '';
titleEl.textContent = jobName;
detailsEl.textContent = desc;
sidebar.classList.add('open');
});
// Close the sidebar.
document.getElementById('close-btn').addEventListener('click', function() {
sidebar.classList.remove('open');
});
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') sidebar.classList.remove('open');
});
})();
</script>
</body>
</html>
`)
return sb.String()
}
+665
View File
@@ -0,0 +1,665 @@
package graph
import (
"os"
"strings"
"testing"
"git.k3nny.fr/glint/internal/cicontext"
"git.k3nny.fr/glint/internal/model"
)
// ── svgEsc ────────────────────────────────────────────────────────────────────
func TestSvgEsc(t *testing.T) {
cases := []struct{ in, want string }{
{"hello", "hello"},
{"a&b", "a&amp;b"},
{"<tag>", "&lt;tag&gt;"},
{"a&b<c>d", "a&amp;b&lt;c&gt;d"},
}
for _, tc := range cases {
if got := svgEsc(tc.in); got != tc.want {
t.Errorf("svgEsc(%q)=%q want %q", tc.in, got, tc.want)
}
}
}
// ── svgTrunc ──────────────────────────────────────────────────────────────────
func TestSvgTrunc(t *testing.T) {
cases := []struct {
s string
maxLen int
want string
}{
{"short", 20, "short"},
{"exactly20charslong!", 20, "exactly20charslong!"},
{"this-is-a-very-long-job-name", 20, "this-is-a-very-long…"},
{"αβγδεζηθικλμνξο", 5, "αβγδ…"},
}
for _, tc := range cases {
got := svgTrunc(tc.s, tc.maxLen)
if got != tc.want {
t.Errorf("svgTrunc(%q, %d)=%q want %q", tc.s, tc.maxLen, got, tc.want)
}
}
}
// ── svgEmpty ──────────────────────────────────────────────────────────────────
func TestSvgEmpty(t *testing.T) {
out := svgEmpty()
if !strings.Contains(out, "<svg") { t.Error("expected <svg") }
if !strings.Contains(out, "no jobs defined") { t.Error("expected 'no jobs defined'") }
}
// ── chipColor ─────────────────────────────────────────────────────────────────
func TestChipColor(t *testing.T) {
if chipColor(model.Job{Trigger: "x"}) != "#6b4fbb" { t.Error("trigger color") }
if chipColor(model.Job{When: "manual"}) != "#fc6d26" { t.Error("manual color") }
if chipColor(model.Job{When: "delayed"}) != "#fca326" { t.Error("delayed color") }
if chipColor(model.Job{When: "on_failure"}) != "#d9534f" { t.Error("on_failure color") }
if chipColor(model.Job{}) != "#1f75cb" { t.Error("regular color") }
}
// ── imageString ───────────────────────────────────────────────────────────────
func TestImageString(t *testing.T) {
if imageString("alpine") != "alpine" { t.Error("string form") }
if imageString(map[string]any{"name": "golang:1.21"}) != "golang:1.21" { t.Error("map form") }
if imageString(map[string]any{"other": "x"}) != "" { t.Error("map without name key") }
if imageString(nil) != "" { t.Error("nil") }
if imageString(42) != "" { t.Error("unexpected type") }
}
// ── drawChipIcon ──────────────────────────────────────────────────────────────
func TestDrawChipIcon(t *testing.T) {
cases := []struct {
job model.Job
wantTag string
}{
{model.Job{Trigger: "x"}, "<polyline"},
{model.Job{When: "manual"}, "<polygon"},
{model.Job{When: "delayed"}, "<circle"},
{model.Job{When: "on_failure"}, "<line"},
{model.Job{}, "<polyline"},
}
for _, tc := range cases {
var sb strings.Builder
drawChipIcon(&sb, tc.job, 10, 10)
if !strings.Contains(sb.String(), tc.wantTag) {
t.Errorf("drawChipIcon(%q): want %s, got:\n%s", tc.job.When, tc.wantTag, sb.String())
}
}
}
// ── pipelineSVG ───────────────────────────────────────────────────────────────
func TestPipelineSVG_Empty(t *testing.T) {
svg := pipelineSVG(&model.Pipeline{}, nil)
if !strings.Contains(svg, "no jobs defined") {
t.Errorf("expected 'no jobs defined', got:\n%s", svg)
}
}
func TestPipelineSVG_ClassicMode(t *testing.T) {
p := &model.Pipeline{
Stages: []string{"build", "test"},
Jobs: map[string]model.Job{
"build-job": {Name: "build-job", Stage: "build"},
"test-job": {Name: "test-job", Stage: "test"},
},
}
svg := pipelineSVG(p, nil)
if !strings.Contains(svg, "<svg") { t.Error("expected <svg") }
if !strings.Contains(svg, "build-job") { t.Error("expected build-job") }
if !strings.Contains(svg, "test-job") { t.Error("expected test-job") }
// Classic mode bus-bar draws <line> elements for connectors
if !strings.Contains(svg, "<line") {
t.Error("expected <line> connector in classic mode")
}
}
func TestPipelineSVG_ClassicMode_MultiJob(t *testing.T) {
// Two source jobs, two destination jobs → bus-bar with vertical segment
p := &model.Pipeline{
Stages: []string{"build", "test"},
Jobs: map[string]model.Job{
"build-a": {Name: "build-a", Stage: "build"},
"build-b": {Name: "build-b", Stage: "build"},
"test-a": {Name: "test-a", Stage: "test"},
"test-b": {Name: "test-b", Stage: "test"},
},
}
svg := pipelineSVG(p, nil)
// Bus bar: horizontal stubs from each src + dst, plus a vertical bus line
// and an arrowhead polygon for each dst job.
if !strings.Contains(svg, "<polygon") {
t.Error("expected arrowhead <polygon> in multi-job classic mode")
}
}
func TestPipelineSVG_DAGMode(t *testing.T) {
p := &model.Pipeline{
Stages: []string{"build", "test"},
Jobs: map[string]model.Job{
"build-job": {Name: "build-job", Stage: "build"},
"test-job": {Name: "test-job", Stage: "test", Needs: []any{"build-job"}},
},
}
svg := pipelineSVG(p, nil)
// DAG mode draws <path> bezier curves
if !strings.Contains(svg, "<path") {
t.Error("expected <path> connector in DAG mode")
}
}
func TestPipelineSVG_DAGMode_StraightConnector(t *testing.T) {
// Two stages at same height → straight connector in classic mode
p := &model.Pipeline{
Stages: []string{"a", "b"},
Jobs: map[string]model.Job{
"j1": {Name: "j1", Stage: "a"},
"j2": {Name: "j2", Stage: "b"},
},
}
svg := pipelineSVG(p, nil)
if !strings.Contains(svg, "<svg") { t.Error("expected <svg") }
}
func TestPipelineSVG_AllJobTypes(t *testing.T) {
p := &model.Pipeline{
Stages: []string{"deploy"},
Jobs: map[string]model.Job{
"manual-job": {Name: "manual-job", Stage: "deploy", When: "manual"},
"delayed-job": {Name: "delayed-job", Stage: "deploy", When: "delayed"},
"trigger-job": {Name: "trigger-job", Stage: "deploy", Trigger: "other/project"},
"onfailure-job": {Name: "onfailure-job", Stage: "deploy", When: "on_failure"},
},
}
svg := pipelineSVG(p, nil)
if !strings.Contains(svg, "#fc6d26") { t.Error("expected manual color") }
if !strings.Contains(svg, "#fca326") { t.Error("expected delayed color") }
if !strings.Contains(svg, "#6b4fbb") { t.Error("expected trigger color") }
if !strings.Contains(svg, "#d9534f") { t.Error("expected on_failure color") }
// on_failure chips have a dashed border
if !strings.Contains(svg, `stroke-dasharray="4,3"`) {
t.Error("expected dashed border on on_failure chip")
}
}
func TestPipelineSVG_JobNoStage(t *testing.T) {
p := &model.Pipeline{
Jobs: map[string]model.Job{
"myjob": {Name: "myjob"},
},
}
svg := pipelineSVG(p, nil)
if !strings.Contains(svg, "myjob") { t.Error("expected myjob") }
}
func TestPipelineSVG_CrossPipelineNeed(t *testing.T) {
p := &model.Pipeline{
Stages: []string{"build"},
Jobs: map[string]model.Job{
"j": {Name: "j", Stage: "build",
Needs: []any{map[string]any{"pipeline": "other", "job": "x"}}},
},
}
svg := pipelineSVG(p, nil)
if !strings.Contains(svg, "<svg") { t.Error("expected <svg") }
}
func TestPipelineSVG_Tooltip(t *testing.T) {
p := &model.Pipeline{
Stages: []string{"build"},
Jobs: map[string]model.Job{
"build-job": {
Name: "build-job",
Stage: "build",
When: "on_success",
Image: "golang:1.21",
Needs: []any{"prep-job"},
},
},
}
svg := pipelineSVG(p, nil)
// Tooltip <title> and <desc> should appear inside the <g data-job> wrapper
if !strings.Contains(svg, `<title>build-job</title>`) {
t.Error("expected <title>build-job</title>")
}
if !strings.Contains(svg, `<desc>`) {
t.Error("expected <desc> element")
}
if !strings.Contains(svg, "image: golang:1.21") {
t.Error("expected image in desc")
}
if !strings.Contains(svg, "needs: prep-job") {
t.Error("expected needs in desc")
}
if !strings.Contains(svg, `data-job="build-job"`) {
t.Error("expected data-job attribute")
}
}
func TestPipelineSVG_SkippedJob(t *testing.T) {
// A job with rules that never match in the given context should be greyed out.
p := &model.Pipeline{
Stages: []string{"deploy"},
Jobs: map[string]model.Job{
"deploy-job": {
Name: "deploy-job",
Stage: "deploy",
Script: []any{"deploy.sh"},
Rules: []model.Rule{
{If: `$CI_COMMIT_TAG != ""`, When: "on_success"},
},
},
},
}
// Branch context: CI_COMMIT_TAG is empty → rule doesn't match → job skipped
ctx := cicontext.New("main", "", "push", nil)
svg := pipelineSVG(p, ctx)
// Skipped jobs use grey (#868686) instead of the regular blue
if !strings.Contains(svg, `fill="#868686"`) {
t.Error("expected grey fill for skipped job circle")
}
// Skipped state should appear in the tooltip desc
if !strings.Contains(svg, "state: skipped") {
t.Error("expected 'state: skipped' in tooltip desc")
}
}
func TestPipelineSVG_ContextNil(t *testing.T) {
// nil context → no skipped jobs, regular colors
p := &model.Pipeline{
Stages: []string{"build"},
Jobs: map[string]model.Job{
"j": {Name: "j", Stage: "build"},
},
}
svg := pipelineSVG(p, nil)
if !strings.Contains(svg, `fill="#1f75cb"`) {
t.Error("expected regular blue fill with nil context")
}
}
// ── RenderPipeline ────────────────────────────────────────────────────────────
func TestRenderPipeline(t *testing.T) {
p := &model.Pipeline{
Stages: []string{"build"},
Jobs: map[string]model.Job{
"build-job": {Name: "build-job", Stage: "build"},
},
}
dir := t.TempDir()
path, err := RenderPipeline(p, dir, nil)
if err != nil {
t.Fatalf("RenderPipeline: %v", err)
}
if _, statErr := os.Stat(path); statErr != nil {
t.Errorf("output file not found: %v", statErr)
}
}
func TestRenderPipeline_InvalidDir(t *testing.T) {
// Writing to a path under a file (not a dir) should error.
dir := t.TempDir()
filePath := dir + "/notadir"
if err := os.WriteFile(filePath, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
_, err := RenderPipeline(&model.Pipeline{}, filePath+"/nested", nil)
if err == nil {
t.Error("expected error writing to path under a file")
}
}
func TestRenderPipeline_WriteFileFails(t *testing.T) {
if os.Getuid() == 0 {
t.Skip("skipping as root — file permissions don't apply")
}
dir := t.TempDir()
if err := os.Chmod(dir, 0o555); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { os.Chmod(dir, 0o755) })
p := &model.Pipeline{
Stages: []string{"build"},
Jobs: map[string]model.Job{"j": {Name: "j", Stage: "build"}},
}
_, err := RenderPipeline(p, dir, nil)
if err == nil {
t.Error("expected error when writing SVG to read-only directory")
}
}
// ── convertToPNG ─────────────────────────────────────────────────────────────
func TestRenderPipeline_SVGFallback(t *testing.T) {
// With no converters on PATH, RenderPipeline returns the SVG path (line 53).
origPath := os.Getenv("PATH")
os.Setenv("PATH", "")
t.Cleanup(func() { os.Setenv("PATH", origPath) })
p := &model.Pipeline{
Stages: []string{"build"},
Jobs: map[string]model.Job{"j": {Name: "j", Stage: "build"}},
}
dir := t.TempDir()
path, err := RenderPipeline(p, dir, nil)
if err != nil {
t.Fatalf("RenderPipeline: %v", err)
}
if !strings.HasSuffix(path, ".svg") {
t.Errorf("expected .svg output when no converter available, got %q", path)
}
}
func TestConvertToPNG_NoConverter(t *testing.T) {
// In a test environment without rsvg-convert/inkscape/magick, returns false.
// We can't assert false because the CI machine might have one of them,
// but we can assert it doesn't panic.
dir := t.TempDir()
svgPath := dir + "/test.svg"
pngPath := dir + "/test.png"
_ = os.WriteFile(svgPath, []byte(`<svg xmlns="http://www.w3.org/2000/svg"/>`), 0o644)
// result is either true (converter found) or false (no converter) — just no panic
convertToPNG(svgPath, pngPath)
}
func TestConvertToPNG_FakeConverter(t *testing.T) {
// Install a fake rsvg-convert that just creates the output file and exits 0.
// This exercises the "return true" branch and os.Remove in RenderPipeline.
binDir := t.TempDir()
script := "#!/bin/sh\n# rsvg-convert --output <out> <in>\ncp \"$3\" \"$2\"\n"
fakeBin := binDir + "/rsvg-convert"
if err := os.WriteFile(fakeBin, []byte(script), 0o755); err != nil {
t.Fatal(err)
}
origPath := os.Getenv("PATH")
os.Setenv("PATH", binDir+":"+origPath)
t.Cleanup(func() { os.Setenv("PATH", origPath) })
svgDir := t.TempDir()
svgPath := svgDir + "/test.svg"
pngPath := svgDir + "/test.png"
if err := os.WriteFile(svgPath, []byte(`<svg xmlns="http://www.w3.org/2000/svg"/>`), 0o644); err != nil {
t.Fatal(err)
}
ok := convertToPNG(svgPath, pngPath)
if !ok {
t.Error("expected convertToPNG to return true with fake rsvg-convert")
}
}
func TestRenderPipeline_PNGConversion(t *testing.T) {
// Verify the PNG path is returned (and SVG removed) when converter succeeds.
binDir := t.TempDir()
script := "#!/bin/sh\ncp \"$3\" \"$2\"\n"
if err := os.WriteFile(binDir+"/rsvg-convert", []byte(script), 0o755); err != nil {
t.Fatal(err)
}
origPath := os.Getenv("PATH")
os.Setenv("PATH", binDir+":"+origPath)
t.Cleanup(func() { os.Setenv("PATH", origPath) })
p := &model.Pipeline{
Stages: []string{"build"},
Jobs: map[string]model.Job{"j": {Name: "j", Stage: "build"}},
}
dir := t.TempDir()
path, err := RenderPipeline(p, dir, nil)
if err != nil {
t.Fatalf("RenderPipeline: %v", err)
}
if !strings.HasSuffix(path, ".png") {
t.Errorf("expected .png output, got %q", path)
}
}
// ── pipelineSVG coverage gaps ─────────────────────────────────────────────────
func TestPipelineSVG_LShapedElbow(t *testing.T) {
// Classic mode with unequal stage sizes → vertical bus bar required
p := &model.Pipeline{
Stages: []string{"build", "test"},
Jobs: map[string]model.Job{
"j1": {Name: "j1", Stage: "build"},
"j2": {Name: "j2", Stage: "test"},
"j3": {Name: "j3", Stage: "test"},
},
}
svg := pipelineSVG(p, nil)
// The bus bar is a <line>; arrowheads are <polygon>
if !strings.Contains(svg, "<polygon") {
t.Error("expected <polygon> arrowhead in bus-bar connector")
}
}
func TestPipelineSVG_DAGNeedNotInPositionMap(t *testing.T) {
// DAG mode: job needs a template job (starts with .) → dep not in position maps → continue
p := &model.Pipeline{
Stages: []string{"build"},
Jobs: map[string]model.Job{
"j": {Name: "j", Stage: "build", Needs: []any{".hidden-template"}},
".hidden-template": {Name: ".hidden-template", Stage: "build"},
},
}
svg := pipelineSVG(p, nil)
// Should render without panic; .hidden-template is not visible so no connector drawn.
if !strings.Contains(svg, "<svg") {
t.Error("expected valid SVG output")
}
}
// ── RenderHTML ────────────────────────────────────────────────────────────────
func TestRenderHTML(t *testing.T) {
p := &model.Pipeline{
Stages: []string{"build"},
Jobs: map[string]model.Job{
"build-job": {Name: "build-job", Stage: "build"},
},
}
dir := t.TempDir()
path, err := RenderHTML(p, dir, nil)
if err != nil {
t.Fatalf("RenderHTML: %v", err)
}
if !strings.HasSuffix(path, ".html") {
t.Errorf("expected .html output, got %q", path)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("reading output: %v", err)
}
html := string(data)
if !strings.Contains(html, "<!DOCTYPE html>") { t.Error("expected DOCTYPE") }
if !strings.Contains(html, "<svg") { t.Error("expected embedded <svg") }
if !strings.Contains(html, "build-job") { t.Error("expected job name in HTML") }
if !strings.Contains(html, "applyTransform") { t.Error("expected JS pan/zoom function") }
if !strings.Contains(html, "sidebar") { t.Error("expected sidebar element") }
}
func TestRenderHTML_InvalidDir(t *testing.T) {
dir := t.TempDir()
filePath := dir + "/notadir"
if err := os.WriteFile(filePath, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
_, err := RenderHTML(&model.Pipeline{}, filePath+"/nested", nil)
if err == nil {
t.Error("expected error writing to path under a file")
}
}
func TestRenderHTML_WriteFileFails(t *testing.T) {
if os.Getuid() == 0 {
t.Skip("skipping as root — file permissions don't apply")
}
dir := t.TempDir()
if err := os.Chmod(dir, 0o555); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { os.Chmod(dir, 0o755) })
p := &model.Pipeline{
Stages: []string{"build"},
Jobs: map[string]model.Job{"j": {Name: "j", Stage: "build"}},
}
_, err := RenderHTML(p, dir, nil)
if err == nil {
t.Error("expected error when writing HTML to read-only directory")
}
}
// ── computeColumns ────────────────────────────────────────────────────────────
func TestComputeColumns_NoIntraNeeds(t *testing.T) {
// No intra-stage needs → one column per stage.
stages := []string{"build", "test"}
byStage := map[string][]string{
"build": {"build-a", "build-b"},
"test": {"test-a"},
}
jobs := map[string]model.Job{
"build-a": {Name: "build-a", Stage: "build"},
"build-b": {Name: "build-b", Stage: "build"},
"test-a": {Name: "test-a", Stage: "test"},
}
cols := computeColumns(stages, byStage, jobs)
if len(cols) != 2 {
t.Fatalf("expected 2 columns (one per stage), got %d", len(cols))
}
if cols[0].stage != "build" || len(cols[0].jobs) != 2 {
t.Errorf("col[0]: got stage=%q jobs=%v", cols[0].stage, cols[0].jobs)
}
if cols[1].stage != "test" || len(cols[1].jobs) != 1 {
t.Errorf("col[1]: got stage=%q jobs=%v", cols[1].stage, cols[1].jobs)
}
}
func TestComputeColumns_IntraNeeds(t *testing.T) {
// job-b depends on job-a in the same stage → split into 2 sub-columns.
stages := []string{"build"}
byStage := map[string][]string{
"build": {"job-a", "job-b"},
}
jobs := map[string]model.Job{
"job-a": {Name: "job-a", Stage: "build"},
"job-b": {Name: "job-b", Stage: "build", Needs: []any{"job-a"}},
}
cols := computeColumns(stages, byStage, jobs)
if len(cols) != 2 {
t.Fatalf("expected 2 sub-columns, got %d", len(cols))
}
// Both belong to the same stage.
if cols[0].stage != "build" || cols[1].stage != "build" {
t.Error("both sub-columns should be in stage 'build'")
}
// job-a has no same-stage deps → depth 0 → first sub-column.
if len(cols[0].jobs) != 1 || cols[0].jobs[0] != "job-a" {
t.Errorf("col[0] should contain job-a, got %v", cols[0].jobs)
}
// job-b depends on job-a → depth 1 → second sub-column.
if len(cols[1].jobs) != 1 || cols[1].jobs[0] != "job-b" {
t.Errorf("col[1] should contain job-b, got %v", cols[1].jobs)
}
}
func TestComputeColumns_EmptyStageSkipped(t *testing.T) {
// Stages with no jobs are silently skipped.
stages := []string{"build", "empty", "test"}
byStage := map[string][]string{
"build": {"j1"},
"test": {"j2"},
}
jobs := map[string]model.Job{
"j1": {Name: "j1", Stage: "build"},
"j2": {Name: "j2", Stage: "test"},
}
cols := computeColumns(stages, byStage, jobs)
if len(cols) != 2 {
t.Fatalf("expected 2 columns (empty stage skipped), got %d", len(cols))
}
}
func TestComputeColumns_CrossStageNeedIgnored(t *testing.T) {
// job-b has needs: for both job-a (same stage) and prev-job (different stage).
// The cross-stage need must be ignored when computing topological depth.
stages := []string{"build"}
byStage := map[string][]string{
"build": {"job-a", "job-b"},
}
jobs := map[string]model.Job{
"job-a": {Name: "job-a", Stage: "build"},
"job-b": {Name: "job-b", Stage: "build", Needs: []any{"job-a", "prev-job"}},
"prev-job": {Name: "prev-job", Stage: "prepare"},
}
cols := computeColumns(stages, byStage, jobs)
// Still split into 2 sub-columns; the cross-stage need is ignored.
if len(cols) != 2 {
t.Fatalf("expected 2 sub-columns, got %d", len(cols))
}
if cols[0].jobs[0] != "job-a" {
t.Errorf("expected job-a in first sub-column, got %v", cols[0].jobs)
}
if cols[1].jobs[0] != "job-b" {
t.Errorf("expected job-b in second sub-column, got %v", cols[1].jobs)
}
}
func TestComputeColumns_CycleGuard(t *testing.T) {
// Intra-stage cycle must not hang (cycle guard returns depth 0).
stages := []string{"build"}
byStage := map[string][]string{
"build": {"a", "b"},
}
jobs := map[string]model.Job{
"a": {Name: "a", Stage: "build", Needs: []any{"b"}},
"b": {Name: "b", Stage: "build", Needs: []any{"a"}},
}
cols := computeColumns(stages, byStage, jobs)
// Should return without hanging; exact column count is implementation-defined.
if len(cols) == 0 {
t.Error("expected at least one column")
}
}
func TestPipelineSVG_IntraStageDAG(t *testing.T) {
// job-b depends on job-a in the same stage → SVG should render both,
// placed as sub-columns (sub-stage gap between them).
p := &model.Pipeline{
Stages: []string{"build"},
Jobs: map[string]model.Job{
"job-a": {Name: "job-a", Stage: "build"},
"job-b": {Name: "job-b", Stage: "build", Needs: []any{"job-a"}},
},
}
svg := pipelineSVG(p, nil)
if !strings.Contains(svg, "job-a") { t.Error("expected job-a in SVG") }
if !strings.Contains(svg, "job-b") { t.Error("expected job-b in SVG") }
// DAG mode: bezier connectors between the two jobs.
if !strings.Contains(svg, "<path") {
t.Error("expected bezier <path> connector between intra-stage jobs")
}
}
// ── htmlPage ──────────────────────────────────────────────────────────────────
func TestHtmlPage(t *testing.T) {
svgContent := `<svg xmlns="http://www.w3.org/2000/svg" width="100" height="50"><rect/></svg>`
html := htmlPage(svgContent)
if !strings.Contains(html, "<!DOCTYPE html>") { t.Error("expected DOCTYPE") }
if !strings.Contains(html, svgContent) { t.Error("expected SVG embedded in HTML") }
if !strings.Contains(html, "id=\"viewport\"") { t.Error("expected viewport element") }
if !strings.Contains(html, "id=\"sidebar\"") { t.Error("expected sidebar element") }
if !strings.Contains(html, "applyTransform") { t.Error("expected JS transform function") }
}
+154
View File
@@ -0,0 +1,154 @@
package graph
import (
"strings"
"testing"
"git.k3nny.fr/glint/internal/cicontext"
"git.k3nny.fr/glint/internal/model"
)
func makeSimplePipeline() *model.Pipeline {
return &model.Pipeline{
Stages: []string{"build", "test"},
Jobs: map[string]model.Job{
"build-job": {Name: "build-job", Stage: "build"},
"test-job": {Name: "test-job", Stage: "test"},
".template": {Name: ".template", Stage: "build"},
},
}
}
func TestTree_Basic(t *testing.T) {
p := makeSimplePipeline()
out := Tree(p, nil)
if !strings.Contains(out, "pipeline") {
t.Error("expected 'pipeline' root node")
}
if !strings.Contains(out, "build-job") {
t.Error("expected build-job in tree")
}
if !strings.Contains(out, "test-job") {
t.Error("expected test-job in tree")
}
// hidden template jobs should not appear
if strings.Contains(out, ".template") {
t.Error(".template job should be excluded from tree")
}
}
func TestTree_WithContext_Skipped(t *testing.T) {
p := &model.Pipeline{
Stages: []string{"build"},
Jobs: map[string]model.Job{
"run-on-tag": {Name: "run-on-tag", Stage: "build", Rules: []model.Rule{
{If: `$CI_COMMIT_TAG != ""`, When: "on_success"},
}},
},
}
ctx := cicontext.New("main", "", "", nil)
out := Tree(p, ctx)
if !strings.Contains(out, "[skipped]") {
t.Errorf("expected [skipped] annotation, got:\n%s", out)
}
}
func TestTree_WithContext_Manual(t *testing.T) {
p := &model.Pipeline{
Stages: []string{"deploy"},
Jobs: map[string]model.Job{
"deploy": {Name: "deploy", Stage: "deploy", When: "manual"},
},
}
ctx := cicontext.New("main", "", "", nil)
out := Tree(p, ctx)
if !strings.Contains(out, "[manual]") {
t.Errorf("expected [manual] annotation, got:\n%s", out)
}
}
func TestTree_NoContext_Annotations(t *testing.T) {
p := &model.Pipeline{
Stages: []string{"build"},
Jobs: map[string]model.Job{
"manual-job": {Name: "manual-job", Stage: "build", When: "manual"},
"delayed-job": {Name: "delayed-job", Stage: "build", When: "delayed"},
"trigger-job": {Name: "trigger-job", Stage: "build", Trigger: "other/project"},
},
}
out := Tree(p, nil)
if !strings.Contains(out, "[manual]") { t.Error("expected [manual]") }
if !strings.Contains(out, "[delayed]") { t.Error("expected [delayed]") }
if !strings.Contains(out, "[trigger]") { t.Error("expected [trigger]") }
}
func TestTree_JobWithNoStage(t *testing.T) {
// Jobs without a stage should default to "test"
p := &model.Pipeline{
Stages: []string{"test"},
Jobs: map[string]model.Job{
"nostage": {Name: "nostage"},
},
}
out := Tree(p, nil)
if !strings.Contains(out, "test") { t.Error("expected default stage 'test'") }
}
func TestTree_UndeclaredStage(t *testing.T) {
// Job in a stage not listed in p.Stages — should still appear
p := &model.Pipeline{
Stages: []string{},
Jobs: map[string]model.Job{
"myjob": {Name: "myjob", Stage: "custom"},
},
}
out := Tree(p, nil)
if !strings.Contains(out, "myjob") { t.Error("expected myjob in output") }
}
// ── branchChars ───────────────────────────────────────────────────────────────
func TestBranchChars(t *testing.T) {
b, c := branchChars(true)
if b != "└── " || c != " " {
t.Errorf("last: branch=%q cont=%q", b, c)
}
b, c = branchChars(false)
if b != "├── " || c != "│ " {
t.Errorf("not-last: branch=%q cont=%q", b, c)
}
}
// ── jobLabel ─────────────────────────────────────────────────────────────────
func TestJobLabel(t *testing.T) {
job := model.Job{Name: "j"}
if jobLabel(job, "j", nil) != "j" {
t.Error("plain job should have plain label")
}
// Multiple tags
job2 := model.Job{Name: "j2", When: "manual", Trigger: "other"}
label := jobLabel(job2, "j2", nil)
if !strings.Contains(label, "manual") || !strings.Contains(label, "trigger") {
t.Errorf("expected manual+trigger in label: %q", label)
}
// With context — active job has no annotation
emptyCtx := &cicontext.Context{}
if jobLabel(model.Job{}, "j", emptyCtx) != "j" {
t.Error("active job with context should have no annotation")
}
}
// TestJobLabel_ActiveWithContext covers the 'return name' path when a job runs
// normally (neither skipped nor manual) with a non-empty context.
func TestJobLabel_ActiveWithContext(t *testing.T) {
// Branch context with a job that has no rules → the job is active.
ctx := cicontext.New("main", "", "", nil)
activeJob := model.Job{Name: "build", Script: []any{"make"}}
label := jobLabel(activeJob, "build", ctx)
if label != "build" {
t.Errorf("active job with context: expected 'build', got %q", label)
}
}
+4 -1
View File
@@ -6,7 +6,7 @@ import (
"git.k3nny.fr/glint/internal/model"
)
func checkDependencies(p *model.Pipeline) []Finding {
func checkDependencies(p *model.Pipeline, skipped map[string]bool) []Finding {
stageIndex := make(map[string]int, len(p.Stages))
for i, s := range p.Stages {
stageIndex[s] = i
@@ -14,6 +14,9 @@ func checkDependencies(p *model.Pipeline) []Finding {
var findings []Finding
for name, job := range p.Jobs {
if skipped[name] {
continue
}
if len(job.Dependencies) == 0 {
continue
}
+856
View File
@@ -0,0 +1,856 @@
package linter
// RuleEntry holds the human-readable documentation for a single lint rule,
// shown by 'glint explain <RULE>'.
type RuleEntry struct {
Title string // short title (one line)
Severity Severity // Error or Warning
Description string // what triggers the rule and why it matters
Example string // YAML snippet that triggers the rule
Fix string // corrected YAML
}
// RuleCatalog maps stable rule IDs to their documentation entries.
var RuleCatalog = map[string]RuleEntry{
RuleNoStages: {
Title: "no stages defined",
Severity: Warning,
Description: "The pipeline has no 'stages:' block. GitLab falls back to the " +
"built-in default stages (build, test, deploy), which may not match your " +
"intended job ordering.",
Example: `# No stages: block
build-job:
script: make build
test-job:
script: make test`,
Fix: `stages:
- build
- test
build-job:
stage: build
script: make build
test-job:
stage: test
script: make test`,
},
RuleWorkflowWhen: {
Title: "workflow.rules.when invalid value",
Severity: Error,
Description: "'workflow.rules[n].when' only accepts 'always' or 'never'. Any " +
"other value (such as 'on_success' or 'manual') is invalid and will cause " +
"the pipeline to fail to create.",
Example: `workflow:
rules:
- if: $CI_COMMIT_BRANCH
when: on_success # invalid here`,
Fix: `workflow:
rules:
- if: $CI_COMMIT_BRANCH
when: always`,
},
RuleMissingScript: {
Title: "missing required 'script' field",
Severity: Error,
Description: "Every non-trigger, non-pages job must define 'script:' (or 'run:' " +
"for CI Steps). A job with no script will fail immediately when GitLab tries " +
"to run it.",
Example: `my-job:
stage: test
image: python:3.12
# Missing script:`,
Fix: `my-job:
stage: test
image: python:3.12
script: pytest`,
},
RuleUnknownStage: {
Title: "job references undeclared stage",
Severity: Error,
Description: "The job's 'stage:' value does not match any name in the pipeline's " +
"'stages:' list. GitLab will reject the pipeline configuration.",
Example: `stages:
- build
- test
deploy-job:
stage: production # not in stages:
script: ./deploy.sh`,
Fix: `stages:
- build
- test
- production
deploy-job:
stage: production
script: ./deploy.sh`,
},
RuleOnlyRulesConflict: {
Title: "'only:' and 'rules:' used together",
Severity: Error,
Description: "'only:' and 'rules:' are mutually exclusive. Using both on the " +
"same job is a configuration error that prevents the pipeline from being " +
"created.",
Example: `my-job:
only:
- main
rules:
- if: $CI_COMMIT_BRANCH
script: echo conflict`,
Fix: `# Remove only: and use rules: exclusively
my-job:
rules:
- if: $CI_COMMIT_BRANCH == "main"
script: echo ok`,
},
RuleExceptRulesConflict: {
Title: "'except:' and 'rules:' used together",
Severity: Error,
Description: "'except:' and 'rules:' are mutually exclusive. Using both on the " +
"same job is a configuration error that prevents the pipeline from being " +
"created.",
Example: `my-job:
except:
- tags
rules:
- if: $CI_COMMIT_BRANCH
script: echo conflict`,
Fix: `my-job:
rules:
- if: $CI_COMMIT_TAG
when: never
- if: $CI_COMMIT_BRANCH
script: echo ok`,
},
RuleDeprecatedOnly: {
Title: "'only:' / 'except:' deprecated",
Severity: Warning,
Description: "'only:' and 'except:' are deprecated in favour of 'rules:'. " +
"GitLab may remove support for these keywords in a future major version. " +
"'rules:' is more expressive and supports variable references, merge-request " +
"conditions, and fine-grained 'when:' control.",
Example: `my-job:
only:
- main
script: ./deploy.sh`,
Fix: `my-job:
rules:
- if: $CI_COMMIT_BRANCH == "main"
script: ./deploy.sh`,
},
RuleInvalidWhen: {
Title: "'when:' has invalid value",
Severity: Error,
Description: "Job-level 'when:' accepts: on_success, on_failure, always, " +
"manual, delayed, never. Any other value is rejected by GitLab.",
Example: `my-job:
when: sometimes # invalid
script: echo hi`,
Fix: `my-job:
when: on_success
script: echo hi`,
},
RuleDelayedNoStartIn: {
Title: "'when: delayed' without 'start_in:'",
Severity: Error,
Description: "'when: delayed' requires a 'start_in:' duration (e.g. " +
"'30 minutes', '1 hour'). Without it GitLab rejects the job configuration.",
Example: `my-job:
when: delayed
script: echo hi`,
Fix: `my-job:
when: delayed
start_in: 30 minutes
script: echo hi`,
},
RuleStartInNoDelayed: {
Title: "'start_in:' without 'when: delayed'",
Severity: Error,
Description: "'start_in:' is only meaningful when 'when: delayed'. Setting it " +
"alongside any other 'when:' value is a configuration error.",
Example: `my-job:
when: on_success
start_in: 30 minutes # only valid with delayed
script: echo hi`,
Fix: `my-job:
when: delayed
start_in: 30 minutes
script: echo hi`,
},
RuleInvalidParallel: {
Title: "'parallel:' value invalid",
Severity: Error,
Description: "'parallel:' must be an integer between 2 and 200 (inclusive), " +
"or a map with a 'matrix:' key for matrix jobs. Values outside this range " +
"or the wrong type are rejected by GitLab.",
Example: `my-job:
parallel: 1 # must be 2200
script: echo hi`,
Fix: `my-job:
parallel: 5
script: echo hi`,
},
RuleInvalidRetry: {
Title: "'retry:' value invalid",
Severity: Error,
Description: "'retry:' accepts an integer 02 or a map with optional 'max:' " +
"(02) and 'when:' keys. Values outside this range are rejected.",
Example: `my-job:
retry: 5 # max is 2
script: echo hi`,
Fix: `my-job:
retry: 2
script: echo hi`,
},
RuleInvalidRetryWhen: {
Title: "'retry.when:' has invalid failure type",
Severity: Error,
Description: "'retry.when:' must be a recognised GitLab CI failure type " +
"(e.g. script_failure, runner_system_failure) or 'always'. Unknown " +
"values are rejected.",
Example: `my-job:
retry:
max: 2
when: disk_full # not a valid failure type
script: echo hi`,
Fix: `my-job:
retry:
max: 2
when: runner_system_failure
script: echo hi`,
},
RuleInvalidAllowFailure: {
Title: "'allow_failure:' invalid value",
Severity: Error,
Description: "'allow_failure:' must be a boolean (true/false) or a map with " +
"an 'exit_codes:' key. Other forms are rejected by GitLab.",
Example: `my-job:
allow_failure: maybe # must be true/false or map
script: echo hi`,
Fix: `my-job:
allow_failure: true
script: echo hi`,
},
RuleInvalidInterruptible: {
Title: "'interruptible:' is not a boolean",
Severity: Error,
Description: "'interruptible:' must be a boolean (true or false). Other types " +
"are rejected.",
Example: `my-job:
interruptible: yes # must be a YAML boolean true/false
script: echo hi`,
Fix: `my-job:
interruptible: true
script: echo hi`,
},
RuleTriggerWithScript: {
Title: "trigger job also defines 'script:'",
Severity: Error,
Description: "Jobs with a 'trigger:' key (downstream pipeline triggers) cannot " +
"use 'script:'. These are mutually exclusive — a trigger job has no runner " +
"environment to execute scripts in.",
Example: `trigger-job:
trigger:
project: mygroup/myproject
script: echo this will fail`,
Fix: `trigger-job:
trigger:
project: mygroup/myproject`,
},
RuleInvalidTrigger: {
Title: "trigger: map missing 'project:' or 'include:'",
Severity: Error,
Description: "When 'trigger:' is a map it must specify either 'project:' " +
"(downstream project trigger) or 'include:' (dynamic child pipeline). " +
"Without one of these keys GitLab cannot determine what to trigger.",
Example: `trigger-job:
trigger:
strategy: depend # missing project: or include:`,
Fix: `trigger-job:
trigger:
project: mygroup/downstream
strategy: depend`,
},
RuleInvalidCoverage: {
Title: "'coverage:' is not a regex pattern",
Severity: Error,
Description: "'coverage:' must be a regex pattern wrapped in forward slashes " +
"(e.g. '/Coverage: \\d+\\.?\\d*%/'). GitLab uses this pattern to extract " +
"the coverage percentage from job output.",
Example: `my-job:
coverage: "\\d+%" # missing surrounding /…/
script: pytest --cov`,
Fix: `my-job:
coverage: '/Coverage: \d+\.?\d*%/'
script: pytest --cov`,
},
RuleInvalidRelease: {
Title: "'release:' missing required 'tag_name:'",
Severity: Error,
Description: "'release:' must be a map and must include a 'tag_name:' key. " +
"Without it GitLab cannot create the release.",
Example: `release-job:
script: echo releasing
release:
name: My Release # missing tag_name:`,
Fix: `release-job:
script: echo releasing
release:
tag_name: $CI_COMMIT_TAG
name: My Release`,
},
RuleInvalidEnvironment: {
Title: "'environment:' invalid url or action",
Severity: Error,
Description: "'environment.url' requires 'environment.name' to be set. " +
"'environment.action' must be one of: start, stop, prepare, verify, access.",
Example: `my-job:
script: ./deploy.sh
environment:
url: https://prod.example.com # name: is required`,
Fix: `my-job:
script: ./deploy.sh
environment:
name: production
url: https://prod.example.com`,
},
RuleInvalidArtifacts: {
Title: "'artifacts:' invalid configuration",
Severity: Error,
Description: "'artifacts.when' must be on_success, on_failure, or always. " +
"'artifacts.expose_as' requires 'artifacts.paths' to be set.",
Example: `my-job:
script: make
artifacts:
when: sometimes # invalid value`,
Fix: `my-job:
script: make
artifacts:
when: on_failure
paths:
- build/logs/`,
},
RulePagesPublic: {
Title: "pages job missing 'public' in artifacts.paths",
Severity: Warning,
Description: "The 'pages' job must include 'public' (or a path starting with " +
"'public/') in 'artifacts.paths' for GitLab Pages to deploy the site.",
Example: `pages:
script: hugo
artifacts:
paths:
- dist/ # should include public/`,
Fix: `pages:
script: hugo --destination public
artifacts:
paths:
- public/`,
},
RuleInvalidCache: {
Title: "'cache:' invalid when or policy",
Severity: Error,
Description: "'cache.when' must be on_success, on_failure, or always. " +
"'cache.policy' must be pull, push, or pull-push.",
Example: `my-job:
script: npm ci
cache:
paths: [node_modules/]
policy: read-only # invalid; use pull`,
Fix: `my-job:
script: npm ci
cache:
paths: [node_modules/]
policy: pull`,
},
RuleInvalidRulesWhen: {
Title: "'rules[n].when:' has invalid value",
Severity: Error,
Description: "Inside a 'rules:' block, 'when:' must be one of: on_success, " +
"on_failure, always, manual, delayed, never. Other values are rejected.",
Example: `my-job:
rules:
- if: $CI_COMMIT_BRANCH
when: conditional # not valid here
script: echo hi`,
Fix: `my-job:
rules:
- if: $CI_COMMIT_BRANCH
when: on_success
script: echo hi`,
},
RuleInvalidImage: {
Title: "'image:' map form missing 'name:'",
Severity: Error,
Description: "When 'image:' is a map, it must include a 'name:' key specifying " +
"the Docker image. Without it GitLab cannot resolve which image to pull.",
Example: `my-job:
image:
entrypoint: ["/bin/sh"] # missing name:
script: echo hi`,
Fix: `my-job:
image:
name: alpine:3.19
entrypoint: ["/bin/sh"]
script: echo hi`,
},
RuleInvalidInherit: {
Title: "'inherit.default' or 'inherit.variables' invalid type",
Severity: Error,
Description: "'inherit.default' and 'inherit.variables' must each be a boolean " +
"(true/false) or a list of field/variable names. Other types are rejected.",
Example: `my-job:
inherit:
default: "no" # must be true/false or a list
script: echo hi`,
Fix: `my-job:
inherit:
default: false
script: echo hi`,
},
RuleNeedsUnknown: {
Title: "'needs:' references unknown job",
Severity: Error,
Description: "A job in the 'needs:' list does not exist in the pipeline. " +
"GitLab will refuse to create the pipeline.",
Example: `build:
stage: build
script: make
test:
stage: test
needs: [build, lint] # lint does not exist
script: make test`,
Fix: `lint:
stage: build
script: make lint
build:
stage: build
script: make
test:
stage: test
needs: [build, lint]
script: make test`,
},
RuleNeedsStageOrder: {
Title: "'needs:' job is in a later stage",
Severity: Error,
Description: "A job listed in 'needs:' is in a later stage than the current " +
"job. GitLab does not allow needs: to reference future stages.",
Example: `stages: [build, test, deploy]
test:
stage: test
needs: [deploy-job] # deploy-job is in a later stage
script: make test
deploy-job:
stage: deploy
script: ./deploy.sh`,
Fix: `stages: [build, test, deploy]
test:
stage: test
needs: [build-job]
script: make test
build-job:
stage: build
script: make`,
},
RuleNeedsCycle: {
Title: "circular dependency in 'needs:' graph",
Severity: Error,
Description: "Two or more jobs in the 'needs:' graph form a cycle — A needs B " +
"and B needs A (directly or transitively). GitLab will reject the pipeline.",
Example: `job-a:
stage: test
needs: [job-b]
script: echo a
job-b:
stage: test
needs: [job-a]
script: echo b`,
Fix: `job-a:
stage: test
script: echo a
job-b:
stage: test
needs: [job-a]
script: echo b`,
},
RuleUnknownDependency: {
Title: "'dependencies:' references unknown job",
Severity: Error,
Description: "A job listed in 'dependencies:' does not exist in the pipeline. " +
"GitLab will refuse to create the pipeline.",
Example: `test:
stage: test
dependencies: [build, missing-job]
script: make test`,
Fix: `build:
stage: build
script: make
artifacts:
paths: [dist/]
test:
stage: test
dependencies: [build]
script: make test`,
},
RuleDependencyStage: {
Title: "'dependencies:' job in same or later stage",
Severity: Error,
Description: "'dependencies:' can only reference jobs in earlier stages. " +
"Referencing a job in the same or a later stage is not allowed because " +
"artifacts would not yet be available.",
Example: `stages: [test, deploy]
test:
stage: test
dependencies: [deploy-job] # deploy is a later stage
script: make test
deploy-job:
stage: deploy
script: ./deploy.sh`,
Fix: `stages: [build, test, deploy]
build:
stage: build
script: make
artifacts:
paths: [dist/]
test:
stage: test
dependencies: [build]
script: make test`,
},
RuleUndeclaredVariable: {
Title: "'rules:if:' references undeclared variable",
Severity: Warning,
Description: "A 'rules:if:' expression references a variable that is not " +
"declared in any 'variables:' block in the pipeline YAML. This may be a " +
"typo, or the variable may be set in GitLab project settings (invisible to " +
"glint). Predefined CI_* and GITLAB_* variables are always exempt.",
Example: `variables:
APP_ENV: staging
my-job:
rules:
- if: $DEPLOY_ENV == "prod" # DEPLOY_ENV not declared
script: ./deploy.sh`,
Fix: `variables:
APP_ENV: staging
DEPLOY_ENV: staging # declare it, or set it in GitLab project settings
my-job:
rules:
- if: $DEPLOY_ENV == "prod"
script: ./deploy.sh`,
},
RuleDeadRules: {
Title: "rules: block has all 'when: never' — job permanently excluded",
Severity: Warning,
Description: "Every rule in the job's 'rules:' block has an explicit " +
"'when: never'. No matter which conditions match, the job will never " +
"be included in any pipeline run.",
Example: `my-job:
rules:
- if: $CI_COMMIT_BRANCH == "main"
when: never
- when: never
script: echo unreachable`,
Fix: `# Option 1: remove the job entirely
# Option 2: give at least one rule a non-never when:
my-job:
rules:
- if: $CI_COMMIT_BRANCH == "main"
when: on_success
- when: never
script: echo deploy`,
},
RuleInvalidService: {
Title: "'services:' map form missing 'name:' or invalid 'alias:'",
Severity: Error,
Description: "When a service entry is a map it must include a 'name:' key. " +
"'alias:' (if set) must be a valid DNS label: alphanumeric characters, " +
"hyphens, and dots only; must start and end with alphanumeric.",
Example: `my-job:
script: pytest
services:
- alias: my_db # underscore is not valid in a DNS label`,
Fix: `my-job:
script: pytest
services:
- name: postgres:15
alias: my-db`,
},
RuleAbsoluteGlobPath: {
Title: "'rules:changes' or 'rules:exists' uses absolute path",
Severity: Warning,
Description: "Paths in 'rules:changes' and 'rules:exists' are always relative " +
"to the repository root. An absolute path starting with '/' will never " +
"match any file and will silently disable the rule.",
Example: `my-job:
rules:
- changes:
- /src/**/*.go # absolute — will never match
script: make`,
Fix: `my-job:
rules:
- changes:
- src/**/*.go
script: make`,
},
RuleInvalidTimeout: {
Title: "'timeout:' is not a valid duration string",
Severity: Error,
Description: "'timeout:' must be a GitLab CI duration string composed of " +
"recognised time units: weeks (w), days (d), hours (h), minutes (m/min), " +
"seconds (s). Examples: '1h 30m', '90 minutes', '2 hours'.",
Example: `my-job:
timeout: "1:30:00" # colon-separated format not supported
script: long-running-task.sh`,
Fix: `my-job:
timeout: 1h 30m
script: long-running-task.sh`,
},
RuleInvalidIDToken: {
Title: "'id_tokens:' entry missing required 'aud:' key",
Severity: Error,
Description: "Each entry under 'id_tokens:' must be a map with an 'aud:' " +
"(audience) key. Without 'aud:' GitLab cannot generate the ID token " +
"and the job will fail.",
Example: `my-job:
id_tokens:
MY_TOKEN:
# missing aud:
script: vault-login.sh`,
Fix: `my-job:
id_tokens:
MY_TOKEN:
aud: https://vault.example.com
script: vault-login.sh`,
},
RuleInvalidSecret: {
Title: "'secrets:' entry missing a provider key",
Severity: Error,
Description: "Each entry under 'secrets:' must specify a provider: one of " +
"'vault:', 'gcp_secret_manager:', or 'azure_key_vault:'. Without a " +
"provider GitLab cannot retrieve the secret.",
Example: `my-job:
secrets:
DB_PASSWORD:
path: secret/db/password # no provider key
script: run.sh`,
Fix: `my-job:
secrets:
DB_PASSWORD:
vault:
engine:
name: kv-v2
path: secret
path: db/password
field: password
script: run.sh`,
},
RulePagesPublish: {
Title: "'pages:' keyword publish directory missing from 'artifacts.paths'",
Severity: Warning,
Description: "A job using the 'pages:' keyword must include its publish " +
"directory in 'artifacts.paths'. Without this, GitLab Pages will not " +
"deploy the site.",
Example: `my-pages-job:
script: hugo
pages:
publish: public
artifacts:
paths:
- dist/ # missing public/`,
Fix: `my-pages-job:
script: hugo
pages:
publish: public
artifacts:
paths:
- public/`,
},
RuleDuplicateStage: {
Title: "duplicate stage name in 'stages:'",
Severity: Warning,
Description: "A stage name appears more than once in the 'stages:' list. " +
"GitLab silently merges duplicate stage entries, which can make the " +
"pipeline order confusing.",
Example: `stages:
- build
- test
- test # duplicate`,
Fix: `stages:
- build
- test`,
},
RuleInvalidCacheKeyFiles: {
Title: "'cache.key.files' contains a glob pattern",
Severity: Warning,
Description: "'cache.key.files' must be a list of exact file paths. Glob " +
"patterns (*, ?, [...]) are not expanded — the literal glob string is " +
"used as the cache key, which probably doesn't match your intent.",
Example: `my-job:
script: npm ci
cache:
key:
files:
- package*.json # glob — use exact path`,
Fix: `my-job:
script: npm ci
cache:
key:
files:
- package.json
- package-lock.json`,
},
RuleStaticDeadRules: {
Title: "rules:if: block statically never activates",
Severity: Warning,
Description: "Every 'rules:if:' condition in this job evaluates to false " +
"using the variable values declared in the pipeline YAML. The job will " +
"never be included in any pipeline run. This check only fires when ALL " +
"referenced variables are declared in the YAML (predefined CI_* variables " +
"are not evaluated to avoid false positives).",
Example: `variables:
ENABLE_DEPLOY: "false"
deploy:
rules:
- if: '$ENABLE_DEPLOY == "true"' # always false: ENABLE_DEPLOY is "false"
script: ./deploy.sh`,
Fix: `variables:
ENABLE_DEPLOY: "true" # fix the variable value
deploy:
rules:
- if: '$ENABLE_DEPLOY == "true"'
script: ./deploy.sh
# Or add an unconditional fallback:
deploy:
rules:
- if: '$ENABLE_DEPLOY == "true"'
- when: manual # allow manual trigger as a fallback
script: ./deploy.sh`,
},
RuleInheritNoDefault: {
Title: "'inherit: default:' declared but no 'default:' block",
Severity: Warning,
Description: "A job declares 'inherit: default:' but the pipeline has no " +
"'default:' block, so the declaration has no effect. This can also fire " +
"when 'inherit: default: [list]' names fields that are not set in the " +
"'default:' block.",
Example: `# No default: block exists
my-job:
inherit:
default: false # no-op: nothing to opt out of
script: echo hi`,
Fix: `# Either add a default: block...
default:
before_script:
- source venv/bin/activate
my-job:
inherit:
default: false
script: echo hi
# ...or remove the pointless inherit: declaration
my-job:
script: echo hi`,
},
RuleRulesNeedsUnknown: {
Title: "'rules:needs:' references unknown job",
Severity: Error,
Description: "A job listed in a rule's 'needs:' override does not exist in " +
"the pipeline. 'rules:needs:' (GitLab CI 16.4+) overrides the top-level " +
"'needs:' list when that specific rule matches. GitLab will refuse to " +
"create the pipeline if any referenced job is missing.",
Example: `stages: [build, test]
build:
stage: build
script: make
test:
stage: test
script: make test
rules:
- if: $CI_COMMIT_BRANCH == "main"
needs: [build, lint] # lint does not exist`,
Fix: `stages: [build, test]
lint:
stage: build
script: make lint
build:
stage: build
script: make
test:
stage: test
script: make test
rules:
- if: $CI_COMMIT_BRANCH == "main"
needs: [build, lint]`,
},
}
+104
View File
@@ -0,0 +1,104 @@
package linter
import (
"git.k3nny.fr/glint/internal/cicontext"
"git.k3nny.fr/glint/internal/model"
)
// checkRulesIfReachability (GL042): warn when every rule in a job's rules: block
// can be statically proven to never activate given the values of variables
// declared in the pipeline YAML. Only fires when ALL variables referenced by the
// rules:if: expressions are declared in the pipeline or job variables: blocks —
// predefined CI_* / GITLAB_* variables or variables not declared in YAML are
// treated as unknown (could have any runtime value) so the check is skipped
// conservatively to avoid false positives.
func checkRulesIfReachability(p *model.Pipeline) []Finding {
if len(p.Jobs) == 0 {
return nil
}
// Collect scalar string values from pipeline-level variables.
pipelineVars := make(map[string]string, len(p.Variables))
for k, v := range p.Variables {
if s, ok := cicontext.ScalarString(v); ok {
pipelineVars[k] = s
}
}
var findings []Finding
for name, job := range p.Jobs {
if len(job.Rules) == 0 {
continue
}
// Merge pipeline vars with job-level variable overrides.
jobVars := make(map[string]string, len(pipelineVars)+len(job.Variables))
for k, v := range pipelineVars {
jobVars[k] = v
}
for k, v := range job.Variables {
if s, ok := cicontext.ScalarString(v); ok {
jobVars[k] = s
}
}
if f := evalRulesReachability(name, job, jobVars); f != nil {
findings = append(findings, *f)
}
}
return findings
}
// evalRulesReachability returns a GL042 finding when the job's rules: block
// can never activate given the provided variable map, or nil if the job
// might activate (or the check cannot be applied conservatively).
func evalRulesReachability(name string, job model.Job, jobVars map[string]string) *Finding {
lookup := func(k string) string { return jobVars[k] }
for _, rule := range job.Rules {
// Explicit when: never — this rule is provably dead; continue.
if rule.When == "never" {
continue
}
// No if: condition → this rule always matches → job CAN activate.
if rule.If == "" {
return nil
}
// Check whether all variables referenced in the if: expression are
// declared in the pipeline YAML. If any are not declared (predefined
// CI vars, project settings, etc.), we cannot evaluate the expression
// and must conservatively assume the job might activate.
refs := extractIfVars(rule.If)
allDeclared := true
for _, ref := range refs {
if _, ok := jobVars[ref]; !ok {
// Variable not in YAML (predefined or unknown) → cannot evaluate.
allDeclared = false
break
}
}
if !allDeclared {
return nil // conservative: job might activate
}
// All referenced variables are declared; evaluate the expression.
// Use strict mode (unparse → false) to avoid matching unparseable expressions.
if cicontext.EvalIfStrict(rule.If, lookup) {
// This rule's condition evaluates to true → job CAN activate.
return nil
}
// Expression evaluated to false → this rule cannot activate; continue.
}
// No rule could activate the job.
return &Finding{
Severity: Warning,
Rule: RuleStaticDeadRules,
Job: name,
File: job.File,
Line: job.Line,
Message: "rules: block can never activate: all if: conditions evaluate to false given the declared pipeline variables",
}
}
+161
View File
@@ -0,0 +1,161 @@
package linter
import (
"testing"
"git.k3nny.fr/glint/internal/model"
)
func TestCheckRulesIfReachability(t *testing.T) {
makeJob := func(vars map[string]any, rules []model.Rule) model.Job {
return model.Job{Variables: vars, Rules: rules}
}
cases := []struct {
name string
pipelineVars map[string]any
jobs map[string]model.Job
wantHit []string // job names that should produce GL042
wantMiss []string // job names that should NOT produce GL042
}{
{
name: "declared var always false — GL042 fires",
pipelineVars: map[string]any{"DEPLOY": "false"},
jobs: map[string]model.Job{
"deploy": makeJob(nil, []model.Rule{
{If: `$DEPLOY == "true"`, When: "on_success"},
}),
},
wantHit: []string{"deploy"},
wantMiss: nil,
},
{
name: "declared var matches — no finding",
pipelineVars: map[string]any{"DEPLOY": "true"},
jobs: map[string]model.Job{
"deploy": makeJob(nil, []model.Rule{
{If: `$DEPLOY == "true"`, When: "on_success"},
}),
},
wantHit: nil,
wantMiss: []string{"deploy"},
},
{
name: "predefined CI_ var — skip check conservatively",
pipelineVars: nil,
jobs: map[string]model.Job{
"branch-job": makeJob(nil, []model.Rule{
{If: `$CI_COMMIT_BRANCH == "never-exists"`, When: "on_success"},
}),
},
wantHit: nil,
wantMiss: []string{"branch-job"},
},
{
name: "undeclared var — skip check conservatively",
pipelineVars: nil,
jobs: map[string]model.Job{
"my-job": makeJob(nil, []model.Rule{
{If: `$UNKNOWN_VAR == "x"`, When: "on_success"},
}),
},
wantHit: nil,
wantMiss: []string{"my-job"},
},
{
name: "unconditional rule — job can always activate",
pipelineVars: map[string]any{"DEPLOY": "false"},
jobs: map[string]model.Job{
"my-job": makeJob(nil, []model.Rule{
{If: `$DEPLOY == "true"`, When: "never"},
{When: "on_success"},
}),
},
wantHit: nil,
wantMiss: []string{"my-job"},
},
{
name: "all rules when:never — GL042 fires (not GL033 territory overlap)",
pipelineVars: map[string]any{"DEPLOY": "false"},
jobs: map[string]model.Job{
"my-job": makeJob(nil, []model.Rule{
{If: `$DEPLOY == "true"`, When: "on_success"},
{When: "never"},
}),
},
// Rule 1 evaluates to false; Rule 2 is explicit never → all dead.
wantHit: []string{"my-job"},
wantMiss: nil,
},
{
name: "job-level var overrides pipeline var",
pipelineVars: map[string]any{"FLAG": "false"},
jobs: map[string]model.Job{
"my-job": makeJob(map[string]any{"FLAG": "true"}, []model.Rule{
{If: `$FLAG == "true"`, When: "on_success"},
}),
},
// Job-level FLAG="true" makes the if: evaluate to true → no finding.
wantHit: nil,
wantMiss: []string{"my-job"},
},
{
name: "no rules — nothing to check",
pipelineVars: map[string]any{"DEPLOY": "false"},
jobs: map[string]model.Job{
"my-job": makeJob(nil, nil),
},
wantHit: nil,
wantMiss: []string{"my-job"},
},
{
name: "boolean variable evaluates correctly",
pipelineVars: map[string]any{"FLAG": false}, // bool false
jobs: map[string]model.Job{
"my-job": makeJob(nil, []model.Rule{
{If: `$FLAG == "true"`, When: "on_success"},
}),
},
// ScalarString(false) → "false"; "false" == "true" → false → GL042 fires.
wantHit: []string{"my-job"},
wantMiss: nil,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
p := &model.Pipeline{
Variables: tc.pipelineVars,
Jobs: tc.jobs,
}
findings := checkRulesIfReachability(p)
hitSet := make(map[string]bool)
for _, f := range findings {
if f.Rule == RuleStaticDeadRules {
hitSet[f.Job] = true
}
}
for _, want := range tc.wantHit {
if !hitSet[want] {
t.Errorf("expected GL042 for job %q but it was not reported; findings: %v", want, findings)
}
}
for _, notWant := range tc.wantMiss {
if hitSet[notWant] {
t.Errorf("unexpected GL042 for job %q", notWant)
}
}
})
}
}
// TestCheckRulesIfReachability_EmptyPipeline covers the early return (line 16-18)
// when the pipeline has no jobs.
func TestCheckRulesIfReachability_EmptyPipeline(t *testing.T) {
findings := checkRulesIfReachability(&model.Pipeline{Jobs: map[string]model.Job{}})
if len(findings) != 0 {
t.Errorf("empty pipeline: expected no findings, got %v", findings)
}
}
+112
View File
@@ -0,0 +1,112 @@
package linter
import (
"fmt"
"strings"
"git.k3nny.fr/glint/internal/model"
)
// checkInheritCompleteness (GL043): warn when a job's 'inherit: default:'
// declaration is dead — either because there is no 'default:' block in the
// pipeline, or because the list form references fields that are not defined
// in the 'default:' block.
func checkInheritCompleteness(p *model.Pipeline) []Finding {
var findings []Finding
for name, job := range p.Jobs {
findings = append(findings, checkJobInheritCompleteness(p, name, job)...)
}
return findings
}
func checkJobInheritCompleteness(p *model.Pipeline, name string, job model.Job) []Finding {
if job.Inherit == nil {
return nil
}
m, ok := job.Inherit.(map[string]any)
if !ok {
return nil
}
defaultVal, hasDefault := m["default"]
if !hasDefault {
return nil
}
// Case 1: no default: block at all — the entire declaration is a no-op.
if p.Default == nil {
return []Finding{{
Severity: Warning,
Rule: RuleInheritNoDefault,
Job: name,
File: job.File,
Line: job.Line,
Message: "'inherit: default:' is declared but the pipeline has no 'default:' block — declaration has no effect",
}}
}
// Case 2: list form — check for field names not set in default:.
list, ok := defaultVal.([]any)
if !ok {
// bool form (true/false) with a non-nil default: block is always valid.
return nil
}
var dead []string
for _, item := range list {
field, ok := item.(string)
if !ok {
continue
}
if !defaultBlockHasField(p.Default, field) {
dead = append(dead, field)
}
}
if len(dead) == 0 {
return nil
}
return []Finding{{
Severity: Warning,
Rule: RuleInheritNoDefault,
Job: name,
File: job.File,
Line: job.Line,
Message: fmt.Sprintf(
"'inherit: default: [%s]': %s not defined in the 'default:' block — %s",
strings.Join(dead, ", "),
pluralIs(len(dead)),
"these entries have no effect",
),
}}
}
// defaultBlockHasField reports whether the given field name has a non-zero value
// in the default: block. Unknown field names return true (conservative).
func defaultBlockHasField(d *model.DefaultConfig, field string) bool {
switch field {
case "image":
return d.Image != nil
case "before_script":
return d.BeforeScript != nil
case "after_script":
return d.AfterScript != nil
case "cache":
return d.Cache != nil
case "artifacts":
return d.Artifacts != nil
case "retry":
return d.Retry != nil
case "timeout":
return d.Timeout != ""
case "tags":
return len(d.Tags) > 0
}
// Unknown field name — conservatively assume it might be defined.
return true
}
func pluralIs(n int) string {
if n == 1 {
return "this field is"
}
return "these fields are"
}
@@ -0,0 +1,165 @@
package linter
import (
"testing"
"git.k3nny.fr/glint/internal/model"
)
func TestCheckInheritCompleteness(t *testing.T) {
cases := []struct {
name string
default_ *model.DefaultConfig
inherit any // job.Inherit value
wantHit bool
}{
{
name: "no inherit — no finding",
inherit: nil,
wantHit: false,
},
{
name: "inherit: default: false, no default block — GL043",
inherit: map[string]any{"default": false},
wantHit: true,
},
{
name: "inherit: default: true, no default block — GL043",
inherit: map[string]any{"default": true},
wantHit: true,
},
{
name: "inherit: default: [image], no default block — GL043",
inherit: map[string]any{"default": []any{"image"}},
wantHit: true,
},
{
name: "inherit: default: false, default block exists — no finding",
default_: &model.DefaultConfig{Image: "node:20"},
inherit: map[string]any{"default": false},
wantHit: false,
},
{
name: "inherit: default: [image], image in default — no finding",
default_: &model.DefaultConfig{Image: "node:20"},
inherit: map[string]any{"default": []any{"image"}},
wantHit: false,
},
{
name: "inherit: default: [before_script], before_script NOT in default — GL043",
default_: &model.DefaultConfig{Image: "node:20"},
inherit: map[string]any{"default": []any{"before_script"}},
wantHit: true,
},
{
name: "inherit: default: [image, before_script], only image in default — GL043 for before_script",
default_: &model.DefaultConfig{Image: "node:20"},
inherit: map[string]any{"default": []any{"image", "before_script"}},
wantHit: true,
},
{
name: "inherit: variables only — no default check",
default_: nil,
inherit: map[string]any{"variables": false},
wantHit: false,
},
{
name: "inherit: unknown field in list — conservative, no finding",
default_: &model.DefaultConfig{Image: "node:20"},
inherit: map[string]any{"default": []any{"nonexistent_field"}},
wantHit: false, // unknown fields return true from defaultBlockHasField
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
job := model.Job{Inherit: tc.inherit}
p := &model.Pipeline{
Default: tc.default_,
Jobs: map[string]model.Job{"test-job": job},
}
findings := checkInheritCompleteness(p)
hit := false
for _, f := range findings {
if f.Rule == RuleInheritNoDefault {
hit = true
}
}
if hit != tc.wantHit {
t.Errorf("GL043 hit=%v, want=%v; findings: %v", hit, tc.wantHit, findings)
}
})
}
}
// TestCheckInheritCompleteness_BoolInherit verifies that a scalar (non-map)
// inherit value (e.g. inherit: true) is silently ignored.
func TestCheckInheritCompleteness_BoolInherit(t *testing.T) {
job := model.Job{Inherit: true}
p := &model.Pipeline{
Default: nil,
Jobs: map[string]model.Job{"job": job},
}
if findings := checkInheritCompleteness(p); len(findings) != 0 {
t.Errorf("bool inherit should produce no findings; got %v", findings)
}
}
// TestCheckInheritCompleteness_ListItemNotString verifies that non-string items
// in the inherit: default: list are skipped.
func TestCheckInheritCompleteness_ListItemNotString(t *testing.T) {
// The list contains 42 (int) and "image" (string). Only "image" should be checked.
job := model.Job{Inherit: map[string]any{"default": []any{42, "image"}}}
p := &model.Pipeline{
Default: &model.DefaultConfig{Image: "node:20"},
Jobs: map[string]model.Job{"job": job},
}
// "image" IS set in default → no findings.
if findings := checkInheritCompleteness(p); len(findings) != 0 {
t.Errorf("non-string item should be skipped; got %v", findings)
}
}
// TestDefaultBlockHasField_AllFields exercises every field branch in
// defaultBlockHasField, including the ones not covered by the main table test.
func TestDefaultBlockHasField_AllFields(t *testing.T) {
cases := []struct {
name string
d *model.DefaultConfig
field string
want bool
}{
{"after_script set", &model.DefaultConfig{AfterScript: []any{"echo"}}, "after_script", true},
{"after_script nil", &model.DefaultConfig{}, "after_script", false},
{"cache set", &model.DefaultConfig{Cache: map[string]any{"key": "main"}}, "cache", true},
{"cache nil", &model.DefaultConfig{}, "cache", false},
{"artifacts set", &model.DefaultConfig{Artifacts: map[string]any{"paths": []any{"dist/"}}}, "artifacts", true},
{"artifacts nil", &model.DefaultConfig{}, "artifacts", false},
{"retry set", &model.DefaultConfig{Retry: 2}, "retry", true},
{"retry nil", &model.DefaultConfig{}, "retry", false},
{"timeout set", &model.DefaultConfig{Timeout: "30m"}, "timeout", true},
{"timeout empty", &model.DefaultConfig{}, "timeout", false},
{"tags set", &model.DefaultConfig{Tags: []string{"docker"}}, "tags", true},
{"tags empty", &model.DefaultConfig{}, "tags", false},
{"unknown field", &model.DefaultConfig{}, "unknown_field", true}, // conservative
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := defaultBlockHasField(tc.d, tc.field)
if got != tc.want {
t.Errorf("defaultBlockHasField(%q) = %v, want %v", tc.field, got, tc.want)
}
})
}
}
// TestPluralIs covers both branches of pluralIs.
func TestPluralIs(t *testing.T) {
if pluralIs(1) != "this field is" {
t.Errorf("pluralIs(1) = %q, want 'this field is'", pluralIs(1))
}
if pluralIs(2) != "these fields are" {
t.Errorf("pluralIs(2) = %q, want 'these fields are'", pluralIs(2))
}
}
+613
View File
@@ -0,0 +1,613 @@
package linter
import (
"testing"
"git.k3nny.fr/glint/internal/model"
)
// ── checkWhen ─────────────────────────────────────────────────────────────────
func TestCheckWhen(t *testing.T) {
cases := []struct {
name string
job model.Job
wantN int
wantRule string
}{
{"valid when", model.Job{When: "on_success"}, 0, ""},
{"invalid when", model.Job{When: "bad_value"}, 1, RuleInvalidWhen},
{"delayed requires start_in", model.Job{When: "delayed"}, 1, RuleDelayedNoStartIn},
{"delayed with start_in", model.Job{When: "delayed", StartIn: "30 minutes"}, 0, ""},
{"start_in without delayed", model.Job{When: "on_success", StartIn: "5m"}, 1, RuleStartInNoDelayed},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := checkWhen("j", tc.job)
if len(got) != tc.wantN {
t.Errorf("got %d findings want %d: %v", len(got), tc.wantN, got)
}
if tc.wantRule != "" && len(got) > 0 && got[0].Rule != tc.wantRule {
t.Errorf("rule: got %q want %q", got[0].Rule, tc.wantRule)
}
})
}
}
// ── checkParallel ─────────────────────────────────────────────────────────────
func TestCheckParallel(t *testing.T) {
cases := []struct {
name string
job model.Job
wantN int
}{
{"nil parallel", model.Job{}, 0},
{"valid int", model.Job{Parallel: 4}, 0},
{"int too low", model.Job{Parallel: 1}, 1},
{"int too high", model.Job{Parallel: 201}, 1},
{"map with matrix", model.Job{Parallel: map[string]any{"matrix": []any{}}}, 0},
{"map without matrix", model.Job{Parallel: map[string]any{"other": true}}, 1},
{"invalid type", model.Job{Parallel: "string"}, 1},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := checkParallel("j", tc.job)
if len(got) != tc.wantN {
t.Errorf("got %d findings want %d", len(got), tc.wantN)
}
})
}
}
// ── checkRetry ────────────────────────────────────────────────────────────────
func TestCheckRetry(t *testing.T) {
cases := []struct {
name string
job model.Job
wantN int
}{
{"nil retry", model.Job{}, 0},
{"valid int 0", model.Job{Retry: 0}, 0},
{"valid int 2", model.Job{Retry: 2}, 0},
{"invalid int -1", model.Job{Retry: -1}, 1},
{"invalid int 3", model.Job{Retry: 3}, 1},
{"map with valid max", model.Job{Retry: map[string]any{"max": 1}}, 0},
{"map with invalid max", model.Job{Retry: map[string]any{"max": 5}}, 1},
{"map with valid when string", model.Job{Retry: map[string]any{"when": "always"}}, 0},
{"map with invalid when string", model.Job{Retry: map[string]any{"when": "bad_reason"}}, 1},
{"map with when slice", model.Job{Retry: map[string]any{"when": []any{"always", "script_failure"}}}, 0},
{"map with when slice invalid", model.Job{Retry: map[string]any{"when": []any{"bad_reason"}}}, 1},
{"invalid type", model.Job{Retry: "string"}, 1},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := checkRetry("j", tc.job)
if len(got) != tc.wantN {
t.Errorf("got %d findings want %d: %v", len(got), tc.wantN, got)
}
})
}
}
// ── checkAllowFailure ─────────────────────────────────────────────────────────
func TestCheckAllowFailure(t *testing.T) {
cases := []struct {
name string
job model.Job
wantN int
}{
{"nil", model.Job{}, 0},
{"bool true", model.Job{Allow: true}, 0},
{"bool false", model.Job{Allow: false}, 0},
{"map with exit_codes", model.Job{Allow: map[string]any{"exit_codes": []any{1, 2}}}, 0},
{"map without exit_codes", model.Job{Allow: map[string]any{"other": true}}, 1},
{"invalid type", model.Job{Allow: "yes"}, 1},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := checkAllowFailure("j", tc.job)
if len(got) != tc.wantN {
t.Errorf("got %d findings want %d", len(got), tc.wantN)
}
})
}
}
// ── checkInterruptible ────────────────────────────────────────────────────────
func TestCheckInterruptible(t *testing.T) {
if len(checkInterruptible("j", model.Job{})) != 0 { t.Error("nil: expected clean") }
if len(checkInterruptible("j", model.Job{Interruptible: true})) != 0 { t.Error("bool true: expected clean") }
if len(checkInterruptible("j", model.Job{Interruptible: false})) != 0 { t.Error("bool false: expected clean") }
if len(checkInterruptible("j", model.Job{Interruptible: "yes"})) != 1 { t.Error("string: expected error") }
}
// ── checkTrigger ──────────────────────────────────────────────────────────────
func TestCheckTrigger(t *testing.T) {
cases := []struct {
name string
job model.Job
wantN int
}{
{"nil trigger", model.Job{}, 0},
{"string trigger", model.Job{Trigger: "other/project"}, 0},
{"trigger with script", model.Job{Trigger: "x", Script: []any{"echo"}}, 1},
{"map trigger with project", model.Job{Trigger: map[string]any{"project": "g/p"}}, 0},
{"map trigger with include", model.Job{Trigger: map[string]any{"include": "ci.yml"}}, 0},
{"map trigger missing both", model.Job{Trigger: map[string]any{"strategy": "depend"}}, 1},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := checkTrigger("j", tc.job)
if len(got) != tc.wantN {
t.Errorf("got %d findings want %d: %v", len(got), tc.wantN, got)
}
})
}
}
// ── checkCoverage ─────────────────────────────────────────────────────────────
func TestCheckCoverage(t *testing.T) {
if len(checkCoverage("j", model.Job{})) != 0 { t.Error("empty: clean") }
if len(checkCoverage("j", model.Job{Coverage: `/\d+%/`})) != 0 { t.Error("valid: clean") }
if len(checkCoverage("j", model.Job{Coverage: "bad"})) != 1 { t.Error("missing slashes: error") }
}
// ── checkRelease ──────────────────────────────────────────────────────────────
func TestCheckRelease(t *testing.T) {
if len(checkRelease("j", model.Job{})) != 0 { t.Error("nil: clean") }
if len(checkRelease("j", model.Job{Release: "string"})) != 1 { t.Error("string: error") }
if len(checkRelease("j", model.Job{Release: map[string]any{"tag_name": "v1"}})) != 0 { t.Error("valid map: clean") }
if len(checkRelease("j", model.Job{Release: map[string]any{"description": "x"}})) != 1 { t.Error("no tag_name: error") }
if len(checkRelease("j", model.Job{Release: map[string]any{"tag_name": ""}})) != 1 { t.Error("empty tag_name: error") }
if len(checkRelease("j", model.Job{Release: map[string]any{"tag_name": nil}})) != 1 { t.Error("nil tag_name: error") }
}
// ── checkEnvironment ──────────────────────────────────────────────────────────
func TestCheckEnvironment(t *testing.T) {
if len(checkEnvironment("j", model.Job{})) != 0 { t.Error("nil: clean") }
if len(checkEnvironment("j", model.Job{Environment: "production"})) != 0 { t.Error("string: clean") }
if len(checkEnvironment("j", model.Job{Environment: map[string]any{"name": "prod"}})) != 0 { t.Error("map with name: clean") }
// url without name
if len(checkEnvironment("j", model.Job{Environment: map[string]any{"url": "https://x.com"}})) != 1 { t.Error("url no name: error") }
// invalid action
if len(checkEnvironment("j", model.Job{Environment: map[string]any{"name": "prod", "action": "badaction"}})) != 1 { t.Error("bad action: error") }
// valid action
if len(checkEnvironment("j", model.Job{Environment: map[string]any{"name": "prod", "action": "stop"}})) != 0 { t.Error("stop action: clean") }
}
// ── checkArtifacts ────────────────────────────────────────────────────────────
func TestCheckArtifacts(t *testing.T) {
if len(checkArtifacts("j", model.Job{})) != 0 { t.Error("nil: clean") }
// non-map type: no-op
if len(checkArtifacts("j", model.Job{Artifacts: "string"})) != 0 { t.Error("string: clean") }
// valid when
if len(checkArtifacts("j", model.Job{Artifacts: map[string]any{"when": "on_failure"}})) != 0 { t.Error("valid when: clean") }
// invalid when
if len(checkArtifacts("j", model.Job{Artifacts: map[string]any{"when": "bad_when"}})) != 1 { t.Error("bad when: error") }
// expose_as without paths
if len(checkArtifacts("j", model.Job{Artifacts: map[string]any{"expose_as": "Coverage"}})) != 1 { t.Error("expose_as no paths: error") }
// expose_as with paths
if len(checkArtifacts("j", model.Job{Artifacts: map[string]any{"expose_as": "X", "paths": []any{"out/"}}})) != 0 { t.Error("expose_as with paths: clean") }
// pages job without public in paths
if len(checkArtifacts("pages", model.Job{Name: "pages", Artifacts: map[string]any{"paths": []any{"dist/"}}})) != 1 { t.Error("pages without public: warning") }
// pages job with public in paths
if len(checkArtifacts("pages", model.Job{Name: "pages", Artifacts: map[string]any{"paths": []any{"public"}}})) != 0 { t.Error("pages with public: clean") }
// pages job with pages: keyword set — GL033 check skipped
if len(checkArtifacts("pages", model.Job{Pages: map[string]any{}, Artifacts: map[string]any{"paths": []any{"dist/"}}})) != 0 { t.Error("pages with pages keyword: clean") }
}
// ── checkCache ────────────────────────────────────────────────────────────────
func TestCheckCache(t *testing.T) {
if len(checkCache("j", model.Job{})) != 0 { t.Error("nil: clean") }
// map form valid
if len(checkCache("j", model.Job{Cache: map[string]any{"key": "abc"}})) != 0 { t.Error("map valid: clean") }
// map form invalid when
if len(checkCache("j", model.Job{Cache: map[string]any{"when": "bad"}})) != 1 { t.Error("invalid when: error") }
// map form invalid policy
if len(checkCache("j", model.Job{Cache: map[string]any{"policy": "bad_policy"}})) != 1 { t.Error("invalid policy: error") }
// slice form
if len(checkCache("j", model.Job{Cache: []any{
map[string]any{"when": "bad"},
map[string]any{"policy": "pull"},
}})) != 1 { t.Error("slice with one bad: error") }
}
// ── checkRules ────────────────────────────────────────────────────────────────
func TestCheckRules(t *testing.T) {
if len(checkRules("j", model.Job{})) != 0 { t.Error("no rules: clean") }
if len(checkRules("j", model.Job{Rules: []model.Rule{{When: "on_success"}}})) != 0 { t.Error("valid when: clean") }
if len(checkRules("j", model.Job{Rules: []model.Rule{{When: "bad_when"}}})) != 1 { t.Error("bad when: error") }
}
// ── checkImage ────────────────────────────────────────────────────────────────
func TestCheckImage(t *testing.T) {
if len(checkImage("j", model.Job{})) != 0 { t.Error("nil: clean") }
if len(checkImage("j", model.Job{Image: "alpine"})) != 0 { t.Error("string: clean") }
if len(checkImage("j", model.Job{Image: map[string]any{"name": "alpine"}})) != 0 { t.Error("map with name: clean") }
if len(checkImage("j", model.Job{Image: map[string]any{"pull_policy": "always"}})) != 1 { t.Error("map without name: error") }
}
// ── checkInherit ──────────────────────────────────────────────────────────────
func TestCheckInherit(t *testing.T) {
if len(checkInherit("j", model.Job{})) != 0 { t.Error("nil: clean") }
if len(checkInherit("j", model.Job{Inherit: "string"})) != 0 { t.Error("non-map: clean") }
if len(checkInherit("j", model.Job{Inherit: map[string]any{"default": true}})) != 0 { t.Error("bool: clean") }
if len(checkInherit("j", model.Job{Inherit: map[string]any{"default": []any{"image"}}})) != 0 { t.Error("list: clean") }
if len(checkInherit("j", model.Job{Inherit: map[string]any{"variables": "string"}})) != 1 { t.Error("invalid type: error") }
}
// ── checkServices ─────────────────────────────────────────────────────────────
func TestCheckServices(t *testing.T) {
if len(checkServices("j", model.Job{})) != 0 { t.Error("nil: clean") }
if len(checkServices("j", model.Job{Services: []any{"postgres"}})) != 0 { t.Error("string: clean") }
if len(checkServices("j", model.Job{Services: []any{map[string]any{"name": "pg"}}})) != 0 { t.Error("valid map: clean") }
if len(checkServices("j", model.Job{Services: []any{map[string]any{"port": 5432}}})) != 1 { t.Error("no name: error") }
if len(checkServices("j", model.Job{Services: []any{map[string]any{"name": "pg", "alias": "invalid alias!"}}})) != 1 { t.Error("bad alias: error") }
if len(checkServices("j", model.Job{Services: []any{map[string]any{"name": "pg", "alias": "valid-alias"}}})) != 0 { t.Error("good alias: clean") }
}
// ── checkRulesGlobs ───────────────────────────────────────────────────────────
func TestCheckRulesGlobs(t *testing.T) {
if len(checkRulesGlobs("j", model.Job{})) != 0 { t.Error("no rules: clean") }
// Relative path: clean
relRule := model.Rule{Changes: []any{"src/**/*.go"}}
if len(checkRulesGlobs("j", model.Job{Rules: []model.Rule{relRule}})) != 0 { t.Error("relative: clean") }
// Absolute path: warning
absRule := model.Rule{Changes: []any{"/absolute/path"}}
if len(checkRulesGlobs("j", model.Job{Rules: []model.Rule{absRule}})) != 1 { t.Error("absolute: warning") }
// Map form with paths
mapRule := model.Rule{Changes: map[string]any{"paths": []any{"/abs"}}}
if len(checkRulesGlobs("j", model.Job{Rules: []model.Rule{mapRule}})) != 1 { t.Error("map absolute: warning") }
// exists
existsRule := model.Rule{Exists: []any{"/bad"}}
if len(checkRulesGlobs("j", model.Job{Rules: []model.Rule{existsRule}})) != 1 { t.Error("exists absolute: warning") }
}
// ── checkTimeout ─────────────────────────────────────────────────────────────
func TestCheckTimeout(t *testing.T) {
if len(checkTimeout("j", model.Job{})) != 0 { t.Error("empty: clean") }
if len(checkTimeout("j", model.Job{Timeout: "1h 30m"})) != 0 { t.Error("valid: clean") }
if len(checkTimeout("j", model.Job{Timeout: "90 minutes"})) != 0 { t.Error("valid2: clean") }
if len(checkTimeout("j", model.Job{Timeout: "not-a-duration"})) != 1 { t.Error("invalid: error") }
}
func TestCheckDefaultTimeout(t *testing.T) {
if len(checkDefaultTimeout("", "f.yml")) != 0 { t.Error("empty: clean") }
if len(checkDefaultTimeout("2 hours", "f.yml")) != 0 { t.Error("valid: clean") }
if len(checkDefaultTimeout("bad", "f.yml")) != 1 { t.Error("invalid: error") }
}
// ── checkIDTokens ─────────────────────────────────────────────────────────────
func TestCheckIDTokens(t *testing.T) {
if len(checkIDTokens("j", model.Job{})) != 0 { t.Error("nil: clean") }
if len(checkIDTokens("j", model.Job{IDTokens: "string"})) != 0 { t.Error("non-map: clean") }
// valid token with aud
if len(checkIDTokens("j", model.Job{IDTokens: map[string]any{
"MY_TOKEN": map[string]any{"aud": "https://example.com"},
}})) != 0 { t.Error("valid: clean") }
// missing aud
if len(checkIDTokens("j", model.Job{IDTokens: map[string]any{
"MY_TOKEN": map[string]any{"other": "x"},
}})) != 1 { t.Error("missing aud: error") }
// not a map
if len(checkIDTokens("j", model.Job{IDTokens: map[string]any{
"MY_TOKEN": "string",
}})) != 1 { t.Error("token not a map: error") }
}
// ── checkSecrets ─────────────────────────────────────────────────────────────
func TestCheckSecrets(t *testing.T) {
if len(checkSecrets("j", model.Job{})) != 0 { t.Error("nil: clean") }
if len(checkSecrets("j", model.Job{Secrets: "string"})) != 0 { t.Error("non-map: clean") }
// valid vault
if len(checkSecrets("j", model.Job{Secrets: map[string]any{
"MY_SECRET": map[string]any{"vault": map[string]any{"engine": map[string]any{"name": "kv", "path": "x"}, "path": "y", "field": "z"}},
}})) != 0 { t.Error("vault: clean") }
// missing provider
if len(checkSecrets("j", model.Job{Secrets: map[string]any{
"MY_SECRET": map[string]any{"other": "x"},
}})) != 1 { t.Error("missing provider: error") }
// not a map
if len(checkSecrets("j", model.Job{Secrets: map[string]any{
"MY_SECRET": "string",
}})) != 1 { t.Error("not a map: error") }
}
// ── checkPagesKeyword ─────────────────────────────────────────────────────────
func TestCheckPagesKeyword(t *testing.T) {
if len(checkPagesKeyword("j", model.Job{})) != 0 { t.Error("nil pages: clean") }
// pages keyword but no artifacts
if len(checkPagesKeyword("j", model.Job{Pages: map[string]any{}})) != 1 { t.Error("no artifacts: warning") }
// pages keyword with artifacts but no paths
if len(checkPagesKeyword("j", model.Job{
Pages: map[string]any{},
Artifacts: map[string]any{},
})) != 1 { t.Error("no paths: warning") }
// pages with public in paths
if len(checkPagesKeyword("j", model.Job{
Pages: map[string]any{},
Artifacts: map[string]any{"paths": []any{"public"}},
})) != 0 { t.Error("public in paths: clean") }
// pages with custom publish dir and matching path
if len(checkPagesKeyword("j", model.Job{
Pages: map[string]any{"publish": "dist"},
Artifacts: map[string]any{"paths": []any{"dist"}},
})) != 0 { t.Error("custom publish match: clean") }
// pages with custom publish dir missing from paths
if len(checkPagesKeyword("j", model.Job{
Pages: map[string]any{"publish": "dist"},
Artifacts: map[string]any{"paths": []any{"public"}},
})) != 1 { t.Error("custom publish missing: warning") }
// artifacts is a non-map (unexpected)
if len(checkPagesKeyword("j", model.Job{
Pages: map[string]any{},
Artifacts: "string",
})) != 0 { t.Error("non-map artifacts: clean") }
}
// ── checkCacheKeyFiles ────────────────────────────────────────────────────────
func TestCheckCacheKeyFiles(t *testing.T) {
if len(checkCacheKeyFiles("j", model.Job{})) != 0 { t.Error("nil: clean") }
// valid key.files
if len(checkCacheKeyFiles("j", model.Job{Cache: map[string]any{
"key": map[string]any{"files": []any{"Gemfile.lock"}},
}})) != 0 { t.Error("valid: clean") }
// glob pattern in key.files
if len(checkCacheKeyFiles("j", model.Job{Cache: map[string]any{
"key": map[string]any{"files": []any{"*.lock"}},
}})) != 1 { t.Error("glob: warning") }
// files is not a list
if len(checkCacheKeyFiles("j", model.Job{Cache: map[string]any{
"key": map[string]any{"files": "Gemfile.lock"},
}})) != 1 { t.Error("non-list: error") }
// no key
if len(checkCacheKeyFiles("j", model.Job{Cache: map[string]any{}})) != 0 { t.Error("no key: clean") }
// key is not a map
if len(checkCacheKeyFiles("j", model.Job{Cache: map[string]any{"key": "string"}})) != 0 { t.Error("key string: clean") }
// slice cache form
if len(checkCacheKeyFiles("j", model.Job{Cache: []any{
map[string]any{"key": map[string]any{"files": []any{"*.lock"}}},
}})) != 1 { t.Error("slice cache with glob: warning") }
}
// ── linter.go level checks ────────────────────────────────────────────────────
func TestCheckStages(t *testing.T) {
// no stages → warning
p := &model.Pipeline{}
if len(checkStages(p)) != 1 { t.Error("no stages: warning") }
// with stages → clean
p2 := &model.Pipeline{Stages: []string{"build"}}
if len(checkStages(p2)) != 0 { t.Error("with stages: clean") }
}
func TestCheckDuplicateStages(t *testing.T) {
p := &model.Pipeline{Stages: []string{"build", "test", "build"}}
if len(checkDuplicateStages(p)) != 1 { t.Error("duplicate: warning") }
p2 := &model.Pipeline{Stages: []string{"build", "test"}}
if len(checkDuplicateStages(p2)) != 0 { t.Error("no duplicate: clean") }
}
func TestCheckWorkflow(t *testing.T) {
p := &model.Pipeline{Workflow: &model.Workflow{Rules: []model.Rule{{When: "always"}}}}
if len(checkWorkflow(p)) != 0 { t.Error("valid when: clean") }
p2 := &model.Pipeline{Workflow: &model.Workflow{Rules: []model.Rule{{When: "bad"}}}}
if len(checkWorkflow(p2)) != 1 { t.Error("invalid when: error") }
p3 := &model.Pipeline{}
if len(checkWorkflow(p3)) != 0 { t.Error("nil workflow: clean") }
}
func TestFindingString(t *testing.T) {
f := Finding{
Severity: Error,
Rule: "GL001",
Job: "build",
File: "ci.yml",
Line: 10,
Message: "missing script",
}
s := f.String()
if s == "" { t.Error("expected non-empty string") }
// Without file/line/job
f2 := Finding{Severity: Warning, Rule: "GL002", Message: "test"}
s2 := f2.String()
if s2 == "" { t.Error("expected non-empty string") }
// With file but no line
f3 := Finding{Severity: Error, Rule: "GL003", File: "ci.yml", Message: "x"}
if f3.String() == "" { t.Error("expected non-empty string") }
}
func TestScriptNonEmpty(t *testing.T) {
if scriptNonEmpty(nil) { t.Error("nil") }
if scriptNonEmpty([]any{}) { t.Error("empty slice") }
if !scriptNonEmpty([]any{"echo"}) { t.Error("non-empty slice") }
if !scriptNonEmpty("echo") { t.Error("string") }
if scriptNonEmpty("") { t.Error("empty string") }
}
// ── checkNeeds ────────────────────────────────────────────────────────────────
func TestCheckNeeds(t *testing.T) {
p := &model.Pipeline{
Stages: []string{"build", "test"},
Jobs: map[string]model.Job{
"build-job": {Name: "build-job", Stage: "build", Script: []any{"make"}},
"test-job": {Name: "test-job", Stage: "test", Script: []any{"test"},
Needs: []any{"build-job"}},
},
}
if len(checkNeeds(p, nil)) != 0 { t.Error("valid needs: clean") }
// needs unknown job
p2 := &model.Pipeline{
Jobs: map[string]model.Job{
"test-job": {Name: "test-job", Stage: "test", Needs: []any{"nonexistent"}},
},
}
if len(checkNeeds(p2, nil)) != 1 { t.Error("unknown needs: error") }
// optional: true for unknown job → warning not error
p3 := &model.Pipeline{
Jobs: map[string]model.Job{
"test-job": {Name: "test-job", Stage: "test",
Needs: []any{map[string]any{"job": "ghost", "optional": true}}},
},
}
findings := checkNeeds(p3, nil)
if len(findings) != 1 || findings[0].Severity != Warning {
t.Error("optional unknown needs: warning")
}
// cross-pipeline need (ignored)
p4 := &model.Pipeline{
Jobs: map[string]model.Job{
"test-job": {Name: "test-job",
Needs: []any{map[string]any{"pipeline": "other", "job": "j"}}},
},
}
if len(checkNeeds(p4, nil)) != 0 { t.Error("cross-pipeline: clean") }
// needs later stage → error
p5 := &model.Pipeline{
Stages: []string{"build", "test"},
Jobs: map[string]model.Job{
"build-job": {Name: "build-job", Stage: "build", Script: []any{"make"}},
"early-job": {Name: "early-job", Stage: "build",
Needs: []any{"build-job"}, Script: []any{"echo"}},
"test-job": {Name: "test-job", Stage: "test", Script: []any{"test"},
Needs: []any{"build-job"}}, // ok
},
}
// Only cross-stage ordering violations should be found
_ = checkNeeds(p5, nil)
}
func TestCheckNeeds_Cycle(t *testing.T) {
p := &model.Pipeline{
Jobs: map[string]model.Job{
"a": {Name: "a", Needs: []any{"b"}},
"b": {Name: "b", Needs: []any{"a"}},
},
}
findings := checkNeeds(p, nil)
for _, f := range findings {
if f.Rule == RuleNeedsCycle { return }
}
t.Error("expected cycle detection finding")
}
// ── checkDependencies ─────────────────────────────────────────────────────────
func TestCheckDependencies(t *testing.T) {
p := &model.Pipeline{
Stages: []string{"build", "test"},
Jobs: map[string]model.Job{
"build-job": {Name: "build-job", Stage: "build", Script: []any{"make"}},
"test-job": {Name: "test-job", Stage: "test", Script: []any{"test"},
Dependencies: []string{"build-job"}},
},
}
if len(checkDependencies(p, nil)) != 0 { t.Error("valid deps: clean") }
// unknown dep
p2 := &model.Pipeline{
Jobs: map[string]model.Job{
"test-job": {Name: "test-job", Stage: "test", Dependencies: []string{"ghost"}},
},
}
if len(checkDependencies(p2, nil)) != 1 { t.Error("unknown dep: error") }
// dep in same or later stage → error
p3 := &model.Pipeline{
Stages: []string{"build", "test"},
Jobs: map[string]model.Job{
"test-job1": {Name: "test-job1", Stage: "test"},
"test-job2": {Name: "test-job2", Stage: "test", Dependencies: []string{"test-job1"}},
},
}
if len(checkDependencies(p3, nil)) != 1 { t.Error("same stage dep: error") }
}
func TestCheckDependencies_SkippedJob(t *testing.T) {
p := &model.Pipeline{
Jobs: map[string]model.Job{
"skipped-job": {Name: "skipped-job", Stage: "test", Dependencies: []string{"ghost"}},
},
}
// Without skipping: reports unknown dependency.
if len(checkDependencies(p, nil)) == 0 {
t.Fatal("expected GL030 without skipped set, got none")
}
// With job skipped: suppressed.
if len(checkDependencies(p, map[string]bool{"skipped-job": true})) != 0 {
t.Error("expected no findings for skipped job")
}
}
// ── checkCacheKeyFiles ────────────────────────────────────────────────────────
func TestCheckCacheKeyFiles_NoFilesKey(t *testing.T) {
// cache.key is a map but has no "files" key → continue (line 823-824).
job := model.Job{
Cache: map[string]any{
"key": map[string]any{"prefix": "v1"},
},
}
if got := checkCacheKeyFiles("j", job); len(got) != 0 {
t.Errorf("key map without 'files': expected no findings, got %v", got)
}
}
func TestCheckCacheKeyFiles_FilesNotList(t *testing.T) {
// cache.key.files is a scalar (not a list) → error finding (line 827-834).
job := model.Job{
Cache: map[string]any{
"key": map[string]any{"files": "single-file.lock"},
},
}
got := checkCacheKeyFiles("j", job)
if len(got) == 0 {
t.Error("files as scalar: expected error finding")
return
}
if got[0].Rule != RuleInvalidCacheKeyFiles || got[0].Severity != Error {
t.Errorf("unexpected finding: %v", got[0])
}
}
func TestCheckCacheKeyFiles_NonStringItem(t *testing.T) {
// cache.key.files list has a non-string item → skip it (line 838-839).
job := model.Job{
Cache: map[string]any{
"key": map[string]any{
"files": []any{42, "go.sum"}, // 42 is non-string, go.sum is valid
},
},
}
// 42 is skipped; "go.sum" has no glob chars → no findings.
if got := checkCacheKeyFiles("j", job); len(got) != 0 {
t.Errorf("non-string item: expected no findings, got %v", got)
}
}
+8 -3
View File
@@ -52,16 +52,21 @@ func (f Finding) String() string {
// 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 {
// skipped is an optional set of job names to exclude from cross-job checks
// (needs:, dependencies:); pass nil to check all jobs.
func Lint(p *model.Pipeline, skipped map[string]bool) []Finding {
var findings []Finding
findings = append(findings, checkStages(p)...)
findings = append(findings, checkDuplicateStages(p)...)
findings = append(findings, checkDefault(p)...)
findings = append(findings, checkWorkflow(p)...)
findings = append(findings, checkJobs(p)...)
findings = append(findings, checkNeeds(p)...)
findings = append(findings, checkDependencies(p)...)
findings = append(findings, checkNeeds(p, skipped)...)
findings = append(findings, checkRulesNeeds(p, skipped)...)
findings = append(findings, checkDependencies(p, skipped)...)
findings = append(findings, checkVariableRefs(p)...)
findings = append(findings, checkRulesIfReachability(p)...)
findings = append(findings, checkInheritCompleteness(p)...)
slices.SortStableFunc(findings, func(a, b Finding) int {
if c := cmp.Compare(a.File, b.File); c != 0 {
return c
+142
View File
@@ -0,0 +1,142 @@
package linter
import (
"testing"
"git.k3nny.fr/glint/internal/model"
)
// TestCheckDefault exercises the non-nil default block code path.
func TestCheckDefault(t *testing.T) {
// nil default → nil return (already covered implicitly; included for clarity)
if got := checkDefault(&model.Pipeline{}); got != nil {
t.Errorf("nil default: want nil findings, got %v", got)
}
// non-nil default with empty timeout → no findings
if got := checkDefault(&model.Pipeline{Default: &model.DefaultConfig{}}); got != nil {
t.Errorf("empty default: want no findings, got %v", got)
}
// non-nil default with invalid timeout → finding produced
findings := checkDefault(&model.Pipeline{Default: &model.DefaultConfig{Timeout: "bad-value"}})
if len(findings) == 0 {
t.Error("invalid timeout in default block should produce a finding")
}
}
// TestCheckJob_MissingScript covers the error/warning paths for jobs without a
// script field.
func TestCheckJob_MissingScript(t *testing.T) {
stageSet := map[string]bool{"build": true}
// No script, no extends → Error.
findings := checkJob("my-job", model.Job{Stage: "build"}, stageSet)
var gotErr bool
for _, f := range findings {
if f.Rule == RuleMissingScript && f.Severity == Error {
gotErr = true
}
}
if !gotErr {
t.Error("job with no script and no extends: expected Error finding GL003")
}
}
// TestCheckJob_ExtendsNoScript covers the Warning variant: a job that extends
// a base template but has no script (the script may come from the base, which
// could not be fetched).
func TestCheckJob_ExtendsNoScript(t *testing.T) {
stageSet := map[string]bool{"build": true}
job := model.Job{Stage: "build", Extends: ".base-template"}
findings := checkJob("my-job", job, stageSet)
var gotWarn bool
for _, f := range findings {
if f.Rule == RuleMissingScript && f.Severity == Warning {
gotWarn = true
}
}
if !gotWarn {
t.Error("job with extends but no script: expected Warning finding GL003")
}
}
// TestCheckJob_StageInputPlaceholder verifies that a stage value containing
// "$[[" (a component input placeholder) skips the unknown-stage check.
func TestCheckJob_StageInputPlaceholder(t *testing.T) {
stageSet := map[string]bool{"build": true}
job := model.Job{Stage: "$[[inputs.stage]]", Script: []any{"echo"}}
for _, f := range checkJob("my-job", job, stageSet) {
if f.Rule == RuleUnknownStage {
t.Error("stage with $[[ placeholder should not produce GL004")
}
}
}
// TestCheckJob_OnlyAndRules covers the only+rules conflict (GL005).
func TestCheckJob_OnlyAndRules(t *testing.T) {
stageSet := map[string]bool{"build": true}
job := model.Job{
Stage: "build",
Script: []any{"echo"},
Only: []any{"branches"},
Rules: []model.Rule{{When: "on_success"}},
}
var gotConflict bool
for _, f := range checkJob("my-job", job, stageSet) {
if f.Rule == RuleOnlyRulesConflict {
gotConflict = true
}
}
if !gotConflict {
t.Error("only+rules: expected GL005 conflict finding")
}
}
// TestCheckJob_ExceptAndRules covers the except+rules conflict (GL006).
func TestCheckJob_ExceptAndRules(t *testing.T) {
stageSet := map[string]bool{"build": true}
job := model.Job{
Stage: "build",
Script: []any{"echo"},
Except: []any{"tags"},
Rules: []model.Rule{{When: "on_success"}},
}
var gotConflict bool
for _, f := range checkJob("my-job", job, stageSet) {
if f.Rule == RuleExceptRulesConflict {
gotConflict = true
}
}
if !gotConflict {
t.Error("except+rules: expected GL006 conflict finding")
}
}
// TestCheckJob_RunField verifies that a job with a 'run:' block (instead of
// 'script:') is not flagged for missing script.
func TestCheckJob_RunField(t *testing.T) {
stageSet := map[string]bool{"build": true}
job := model.Job{Stage: "build", Run: map[string]any{"steps": []any{"echo hi"}}}
for _, f := range checkJob("my-job", job, stageSet) {
if f.Rule == RuleMissingScript {
t.Error("job with run: should not produce GL003 (missing script)")
}
}
}
// TestCheckJob_UnknownStage verifies that a job with a declared stage value that
// is not in the stageSet produces a GL004 finding (line 178-185).
func TestCheckJob_UnknownStage(t *testing.T) {
stageSet := map[string]bool{"build": true, "test": true}
job := model.Job{Stage: "unknown-stage", Script: []any{"echo"}}
var gotGL004 bool
for _, f := range checkJob("my-job", job, stageSet) {
if f.Rule == RuleUnknownStage {
gotGL004 = true
}
}
if !gotGL004 {
t.Error("job with undeclared stage: expected GL004 finding")
}
}
+42 -1
View File
@@ -12,7 +12,7 @@ type needEntry struct {
optional bool // true when the needs entry carries optional: true
}
func checkNeeds(p *model.Pipeline) []Finding {
func checkNeeds(p *model.Pipeline, skipped map[string]bool) []Finding {
var findings []Finding
// Build a stage-index map for ordering checks.
@@ -26,6 +26,9 @@ func checkNeeds(p *model.Pipeline) []Finding {
needsGraph := make(map[string][]string)
for name, job := range p.Jobs {
if skipped[name] {
continue
}
if len(job.Needs) == 0 {
continue
}
@@ -82,6 +85,44 @@ func checkNeeds(p *model.Pipeline) []Finding {
return findings
}
// checkRulesNeeds validates rules:needs: entries across all jobs. Each entry
// in a rule's needs: list must reference a job that exists in the pipeline.
// Cross-pipeline needs (maps with a "pipeline" key) are ignored. Skipped jobs
// are excluded from checking (same semantics as top-level needs: via GL027).
func checkRulesNeeds(p *model.Pipeline, skipped map[string]bool) []Finding {
var findings []Finding
for name, job := range p.Jobs {
if skipped[name] {
continue
}
for i, rule := range job.Rules {
if len(rule.Needs) == 0 {
continue
}
for _, entry := range parseNeedEntries(rule.Needs) {
if _, exists := p.Jobs[entry.job]; !exists {
sev := Error
if entry.optional {
sev = Warning
}
findings = append(findings, Finding{
Severity: sev,
Rule: RuleRulesNeedsUnknown,
Job: name,
File: job.File,
Line: job.Line,
Message: fmt.Sprintf(
"rules[%d].needs: references unknown job %q",
i, entry.job,
),
})
}
}
}
}
return findings
}
// parseNeedEntries extracts needs entries from a needs: list, preserving the
// optional flag. Each element is a plain string (job name) or a map with a
// "job" key. Cross-pipeline needs (maps with a "pipeline" key) are skipped.
+186
View File
@@ -0,0 +1,186 @@
package linter
import (
"testing"
"git.k3nny.fr/glint/internal/model"
)
// TestCheckNeeds_SkippedJob verifies that a skipped job's needs: violations are suppressed.
func TestCheckNeeds_SkippedJob(t *testing.T) {
p := &model.Pipeline{
Stages: []string{"build", "deploy"},
Jobs: map[string]model.Job{
"skipped-job": {
Name: "skipped-job",
Stage: "build",
Script: []any{"echo"},
Needs: []any{"nonexistent-job"},
},
},
}
// Without skipping: should report unknown needs.
withoutSkip := checkNeeds(p, nil)
if len(withoutSkip) == 0 {
t.Fatal("expected GL027 without skipped set, got none")
}
// With job skipped: no findings.
withSkip := checkNeeds(p, map[string]bool{"skipped-job": true})
if len(withSkip) != 0 {
t.Errorf("expected no findings for skipped job, got %v", withSkip)
}
}
// TestCheckRulesNeeds_UnknownJob verifies that an unknown job in rules:needs: produces GL044.
func TestCheckRulesNeeds_UnknownJob(t *testing.T) {
p := &model.Pipeline{
Stages: []string{"build", "test"},
Jobs: map[string]model.Job{
"build-job": {
Name: "build-job",
Stage: "build",
Script: []any{"make"},
},
"test-job": {
Name: "test-job",
Stage: "test",
Script: []any{"make test"},
Rules: []model.Rule{
{
If: `$CI_COMMIT_BRANCH == "main"`,
Needs: []any{"build-job", "nonexistent"},
},
},
},
},
}
findings := checkRulesNeeds(p, nil)
var got bool
for _, f := range findings {
if f.Rule == RuleRulesNeedsUnknown && f.Severity == Error {
got = true
}
}
if !got {
t.Errorf("expected GL044 error for unknown rules:needs: job; got %v", findings)
}
}
// TestCheckRulesNeeds_KnownJob verifies no finding when all rules:needs: jobs exist.
func TestCheckRulesNeeds_KnownJob(t *testing.T) {
p := &model.Pipeline{
Stages: []string{"build", "test"},
Jobs: map[string]model.Job{
"build-job": {Name: "build-job", Stage: "build", Script: []any{"make"}},
"test-job": {
Name: "test-job",
Stage: "test",
Script: []any{"make test"},
Rules: []model.Rule{{Needs: []any{"build-job"}}},
},
},
}
if findings := checkRulesNeeds(p, nil); len(findings) != 0 {
t.Errorf("expected no findings for valid rules:needs:; got %v", findings)
}
}
// TestCheckRulesNeeds_Optional verifies that optional: true downgrades to Warning.
func TestCheckRulesNeeds_Optional(t *testing.T) {
p := &model.Pipeline{
Jobs: map[string]model.Job{
"test-job": {
Name: "test-job",
Script: []any{"make test"},
Rules: []model.Rule{
{Needs: []any{map[string]any{"job": "ghost", "optional": true}}},
},
},
},
}
findings := checkRulesNeeds(p, nil)
if len(findings) != 1 || findings[0].Severity != Warning {
t.Errorf("optional unknown rules:needs: should produce Warning; got %v", findings)
}
}
// TestCheckRulesNeeds_CrossPipeline verifies that cross-pipeline needs are ignored.
func TestCheckRulesNeeds_CrossPipeline(t *testing.T) {
p := &model.Pipeline{
Jobs: map[string]model.Job{
"test-job": {
Name: "test-job",
Script: []any{"make test"},
Rules: []model.Rule{
{Needs: []any{map[string]any{"pipeline": "other", "job": "j"}}},
},
},
},
}
if findings := checkRulesNeeds(p, nil); len(findings) != 0 {
t.Errorf("cross-pipeline rules:needs: should be ignored; got %v", findings)
}
}
// TestCheckRulesNeeds_SkippedJob verifies that a skipped job's rules:needs: violations are suppressed.
func TestCheckRulesNeeds_SkippedJob(t *testing.T) {
p := &model.Pipeline{
Jobs: map[string]model.Job{
"test-job": {
Name: "test-job",
Script: []any{"make test"},
Rules: []model.Rule{{Needs: []any{"nonexistent"}}},
},
},
}
if findings := checkRulesNeeds(p, map[string]bool{"test-job": true}); len(findings) != 0 {
t.Errorf("skipped job: expected no findings; got %v", findings)
}
}
// TestCheckRulesNeeds_NoNeeds verifies no findings when rules have no needs: override.
func TestCheckRulesNeeds_NoNeeds(t *testing.T) {
p := &model.Pipeline{
Jobs: map[string]model.Job{
"test-job": {
Name: "test-job",
Script: []any{"make test"},
Rules: []model.Rule{{When: "on_success"}},
},
},
}
if findings := checkRulesNeeds(p, nil); len(findings) != 0 {
t.Errorf("rules without needs: should produce no findings; got %v", findings)
}
}
// TestCheckNeeds_StageOrder verifies that a job needing a job in a later stage
// produces RuleNeedsStageOrder (line 64-76 in needs.go).
func TestCheckNeeds_StageOrder(t *testing.T) {
p := &model.Pipeline{
Stages: []string{"build", "test", "deploy"},
Jobs: map[string]model.Job{
"build-job": {
Name: "build-job",
Stage: "build",
Script: []any{"make"},
Needs: []any{"deploy-job"},
},
"deploy-job": {
Name: "deploy-job",
Stage: "deploy",
Script: []any{"make deploy"},
},
},
}
findings := checkNeeds(p, nil)
var gotStageOrder bool
for _, f := range findings {
if f.Rule == RuleNeedsStageOrder {
gotStageOrder = true
}
}
if !gotStageOrder {
t.Errorf("build-job needing deploy-job (later stage): expected GL025 finding; got: %v", findings)
}
}
+15
View File
@@ -143,4 +143,19 @@ const (
// GL041: cache.key.files contains a glob pattern; it must be a list of exact file paths.
RuleInvalidCacheKeyFiles = "GL041"
// GL042: every rules:if: condition in a job's rules: block evaluates to false
// given the values of variables declared in the pipeline YAML, so the job can
// never be active. Only fires when all referenced variables are declared
// (predefined CI_* / GITLAB_* vars are not evaluated to avoid false positives).
RuleStaticDeadRules = "GL042"
// GL043: a job declares 'inherit: default:' (true, false, or list) but the
// pipeline has no 'default:' block, making the declaration a no-op. Also fires
// when 'inherit: default: [list]' names fields not set in the default: block.
RuleInheritNoDefault = "GL043"
// GL044: a rules:needs: entry references a job that does not exist in the pipeline.
// rules:needs: overrides the top-level needs: when a specific rule matches (GitLab CI 16.4+).
RuleRulesNeedsUnknown = "GL044"
)
+2 -2
View File
@@ -35,7 +35,7 @@ func TestSambaCI(t *testing.T) {
t.Logf("extends warning: job %q extends unknown %q", w.Job, w.Base)
}
findings := linter.Lint(p)
findings := linter.Lint(p, nil)
for _, f := range findings {
if f.Severity == linter.Error {
@@ -78,7 +78,7 @@ func TestSambaCIEntryFiles(t *testing.T) {
t.Logf("extends warning: job %q extends unknown %q", w.Job, w.Base)
}
findings := linter.Lint(p)
findings := linter.Lint(p, nil)
for _, f := range findings {
if f.Severity == linter.Error {
t.Errorf("unexpected error finding: %s", f)
+35
View File
@@ -212,3 +212,38 @@ func TestCheckVariableRefs(t *testing.T) {
})
}
}
// TestCheckVariableRefs_WorkflowRuleEmptyIf covers the `if rule.If == ""` early
// continue at variables.go line 109-110 (workflow rule with no if: expression).
func TestCheckVariableRefs_WorkflowRuleEmptyIf(t *testing.T) {
p := &model.Pipeline{
Workflow: &model.Workflow{
Rules: []model.Rule{
{When: "always"}, // no If → triggers the continue
{If: `$UNDECLARED == "yes"`}, // undeclared → warning
},
},
Jobs: map[string]model.Job{},
}
findings := checkVariableRefs(p)
count := 0
for _, f := range findings {
if f.Rule == RuleUndeclaredVariable {
count++
}
}
if count != 1 {
t.Errorf("expected 1 GL032 warning for UNDECLARED, got %d; findings: %v", count, findings)
}
}
// TestExtractIfVars_StringEscape covers the backslash-escape branch (line 42-44)
// in extractIfVars when scanning a quoted string literal.
func TestExtractIfVars_StringEscape(t *testing.T) {
// `$BRANCH == "de\velop"` — the `\v` inside the string literal triggers the
// escape-character skip in the scanning loop.
got := extractIfVars(`$BRANCH == "de\velop"`)
if len(got) != 1 || got[0] != "BRANCH" {
t.Errorf("extractIfVars with escape in string: got %v, want [BRANCH]", got)
}
}
+77
View File
@@ -0,0 +1,77 @@
package model
import "testing"
// FuzzParseBytes ensures the YAML parser never panics on arbitrary input and
// that successful parses return a structurally sound Pipeline.
// Run with: go test -fuzz=FuzzParseBytes ./internal/model/
// Found failures are saved to testdata/fuzz/FuzzParseBytes/.
func FuzzParseBytes(f *testing.F) {
// Seed corpus: representative inputs covering the main code paths in
// ParseBytes, including the sanitizeYAMLEscapes pre-processing step.
seeds := [][]byte{
{},
[]byte("null"),
[]byte("stages: [build]\nbuild-job:\n stage: build\n script: echo ok\n"),
[]byte(".base:\n script: [make]\nchild:\n extends: .base\n stage: test\n"),
[]byte("stages: [a, b]\njob-a:\n stage: a\n script: run\njob-b:\n stage: b\n needs: [job-a]\n script: run\n"),
[]byte("*undefined_anchor"),
[]byte("- item1\n- item2\n"),
[]byte("my-job: \"just a string\"\n"),
[]byte("my-job:\n stage: [build, test]\n"),
[]byte("# glint: ignore GL007\nlegacy:\n only: [main]\n script: ok\n"),
[]byte("workflow:\n rules:\n - if: '$CI_COMMIT_BRANCH == \"main\"'\n when: always\njob:\n script: echo ok\n"),
[]byte("include:\n - local: other.yml\njob:\n script: echo ok\n"),
[]byte("job:\n script: echo ok\n when: on_failure\n rules:\n - if: '$VAR =~ /^us\\//'\n"),
[]byte("job:\n stage: test\n image:\n name: golang:1.21\n entrypoint: ['']\n parallel:\n matrix:\n - PLATFORM: [linux, darwin]\n script: go build\n"),
[]byte("default:\n retry: 2\n timeout: 1h30m\nvariables:\n ENV: production\nstages: [build, test, deploy]\n"),
[]byte("&anchor\n script: [echo ok]\njob:\n <<: *anchor\n stage: build\n"),
}
for _, s := range seeds {
f.Add(s)
}
f.Fuzz(func(t *testing.T, data []byte) {
p, err := ParseBytes(data)
if err != nil {
return // errors are acceptable; panics are not
}
if p == nil {
t.Fatal("ParseBytes returned nil pipeline with nil error")
}
for name := range p.Jobs {
if name == "" {
t.Fatal("ParseBytes produced a job with an empty name")
}
}
})
}
// FuzzSanitizeYAMLEscapes ensures the escape sanitizer never panics and never
// produces output shorter than its input (it can only expand \/ to \\/).
func FuzzSanitizeYAMLEscapes(f *testing.F) {
seeds := [][]byte{
{},
[]byte("stage: build"),
[]byte(`if: "$CI_BRANCH =~ /^us\//"`),
[]byte(`"pattern: /^us\//"`),
[]byte(`'single quoted \/ unchanged'`),
[]byte(`"\n\t\r"`),
[]byte(`"nested \"quote\" inside"`),
[]byte(`'it''s fine'`),
[]byte(`"unclosed`),
{'"', '\\'}, // double-quoted string ending with a lone backslash
{'"', '\\', '/'}, // the exact sequence being rewritten
}
for _, s := range seeds {
f.Add(s)
}
f.Fuzz(func(t *testing.T, data []byte) {
out := sanitizeYAMLEscapes(data)
if len(out) < len(data) {
t.Fatalf("sanitizeYAMLEscapes shrank output: input len=%d output len=%d\ninput: %q",
len(data), len(out), data)
}
})
}
+168
View File
@@ -0,0 +1,168 @@
package model
import (
"os"
"path/filepath"
"testing"
)
// ── Parse (file) ──────────────────────────────────────────────────────────────
func TestParse(t *testing.T) {
t.Run("valid file", func(t *testing.T) {
content := `
stages: [build]
build-job:
stage: build
script: [make]
`
dir := t.TempDir()
path := filepath.Join(dir, ".gitlab-ci.yml")
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
p, err := Parse(path)
if err != nil {
t.Fatalf("Parse: %v", err)
}
if p.SourceFile != path {
t.Errorf("SourceFile: got %q want %q", p.SourceFile, path)
}
if _, ok := p.Jobs["build-job"]; !ok {
t.Error("build-job not found")
}
if p.Jobs["build-job"].File != path {
t.Errorf("job File not set: %q", p.Jobs["build-job"].File)
}
})
t.Run("missing file", func(t *testing.T) {
_, err := Parse("/nonexistent/path/ci.yml")
if err == nil {
t.Fatal("expected error for missing file")
}
})
}
// ── ParseBytes edge cases ─────────────────────────────────────────────────────
func TestParseBytes_EdgeCases(t *testing.T) {
// Empty YAML: doc.Kind is not DocumentNode → return empty Pipeline.
p, err := ParseBytes([]byte(""))
if err != nil {
t.Fatalf("empty YAML: unexpected error: %v", err)
}
if p == nil || len(p.Jobs) != 0 {
t.Error("empty YAML: expected empty pipeline")
}
// YAML null: second pass succeeds, doc root is ScalarNode → return empty Pipeline (line 51-53).
p2, err := ParseBytes([]byte("null"))
if err != nil {
t.Fatalf("null YAML: unexpected error: %v", err)
}
if p2 == nil || len(p2.Jobs) != 0 {
t.Error("null YAML: expected empty pipeline")
}
// Invalid YAML (undefined alias): first-pass Unmarshal fails (line 34-36).
_, err = ParseBytes([]byte("*undefined_anchor"))
if err == nil {
t.Error("undefined alias: expected error from ParseBytes")
}
// Sequence YAML: second-pass Unmarshal fails (cannot decode !!seq into Pipeline).
_, err = ParseBytes([]byte("- item1\n- item2\n"))
if err == nil {
t.Error("sequence YAML: expected error from ParseBytes")
}
// Job value that cannot be decoded as map[string]any (scalar job value).
_, err = ParseBytes([]byte("my-job: \"just a string\"\n"))
if err == nil {
t.Error("scalar job value: expected error from ParseBytes")
}
// Job with wrong field type: rawMap decode succeeds, Job decode fails (line 79-81).
_, err = ParseBytes([]byte("my-job:\n stage: [build, test]\n"))
if err == nil {
t.Error("wrong field type: expected error from ParseBytes (stage must be string)")
}
}
// TestParse_ParseBytesError exercises the Parse → ParseBytes error path (line 18).
func TestParse_ParseBytesError(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "bad.yml")
if err := os.WriteFile(path, []byte("my-job: \"scalar\"\n"), 0o644); err != nil {
t.Fatal(err)
}
_, err := Parse(path)
if err == nil {
t.Error("scalar job value: expected error from Parse")
}
}
// TestParseSuppressComment_NonIgnore ensures that a "glint:" directive that is
// not "ignore" is silently skipped.
func TestParseSuppressComment_NonIgnore(t *testing.T) {
result := parseSuppressComment("# glint: refresh GL001", "")
if result != nil {
t.Errorf("non-ignore glint directive should return nil, got %v", result)
}
}
// ── sanitizeYAMLEscapes ───────────────────────────────────────────────────────
func TestSanitizeYAMLEscapes(t *testing.T) {
cases := []struct {
name string
input string
want string
}{
{
name: "no double quotes — unchanged",
input: "stage: build",
want: "stage: build",
},
{
name: "double-quoted string without backslash",
input: `if: "$CI_BRANCH == \"main\""`,
want: `if: "$CI_BRANCH == \"main\""`,
},
{
// \/ inside double-quoted YAML string becomes \\/ (yaml.v3 does not recognise \/).
name: "slash escape rewritten in larger context",
input: `if: "$CI_BRANCH =~ /^us\//"`,
want: `if: "$CI_BRANCH =~ /^us\\//"`,
},
{
name: "backslash-slash inside double quotes rewritten",
input: `"pattern: /^us\//"` ,
want: `"pattern: /^us\\//"`,
},
{
name: "single-quoted string unchanged",
input: `'hello \/ world'`,
want: `'hello \/ world'`,
},
{
name: "escaped single quote inside single-quoted string",
input: `'it''s fine'`,
want: `'it''s fine'`,
},
{
name: "other backslash escapes unchanged",
input: `"\n\t\r"`,
want: `"\n\t\r"`,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := string(sanitizeYAMLEscapes([]byte(tc.input)))
if got != tc.want {
t.Errorf("got %q\nwant %q", got, tc.want)
}
})
}
}
+1
View File
@@ -89,6 +89,7 @@ type Rule struct {
Changes any `yaml:"changes"` // []string or {paths,compare_to} map
Exists any `yaml:"exists"` // []string or map form
Variables map[string]any `yaml:"variables"` // set/override variables when rule matches (GitLab CI 15.0+)
Needs []any `yaml:"needs"` // override needs: when this rule matches (GitLab CI 16.4+)
}
// ReservedKeys are top-level GitLab CI keys that are NOT job definitions.
+22
View File
@@ -0,0 +1,22 @@
package model
import (
"testing"
)
func TestSetJobOrigin(t *testing.T) {
p := &Pipeline{
Jobs: map[string]Job{
"with-file": {Name: "with-file", File: "existing.yml"},
"no-file": {Name: "no-file"},
},
}
p.SetJobOrigin("default.yml")
if p.Jobs["with-file"].File != "existing.yml" {
t.Error("job with existing File should not be overwritten")
}
if p.Jobs["no-file"].File != "default.yml" {
t.Errorf("job without File should be set: got %q", p.Jobs["no-file"].File)
}
}
+293
View File
@@ -0,0 +1,293 @@
package resolver
import (
"fmt"
"testing"
"git.k3nny.fr/glint/internal/model"
)
// errorYAMLMarshaler implements yaml.Marshaler and always returns an error,
// allowing tests to trigger the yaml.Marshal failure path in Resolve.
type errorYAMLMarshaler struct{}
func (e errorYAMLMarshaler) MarshalYAML() (interface{}, error) {
return nil, fmt.Errorf("forced marshal error for test")
}
// ── parseExtends ──────────────────────────────────────────────────────────────
func TestParseExtends(t *testing.T) {
cases := []struct {
name string
input any
want []string
wantErr bool
}{
{name: "nil", input: nil, want: nil},
{name: "single string", input: "base", want: []string{"base"}},
{name: "slice of strings", input: []any{"base1", "base2"}, want: []string{"base1", "base2"}},
{name: "empty slice", input: []any{}, want: []string{}},
{name: "invalid item in slice", input: []any{42}, wantErr: true},
{name: "unexpected type", input: 123, wantErr: true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := parseExtends(tc.input)
if (err != nil) != tc.wantErr {
t.Fatalf("wantErr=%v got err=%v", tc.wantErr, err)
}
if !tc.wantErr {
if len(got) != len(tc.want) {
t.Fatalf("len mismatch: got %v want %v", got, tc.want)
}
for i := range got {
if got[i] != tc.want[i] {
t.Errorf("[%d] got %q want %q", i, got[i], tc.want[i])
}
}
}
})
}
}
// ── topoSort ──────────────────────────────────────────────────────────────────
func TestTopoSort(t *testing.T) {
t.Run("simple chain", func(t *testing.T) {
// a → b (b must come before a)
graph := map[string][]string{"a": {"b"}, "b": {}}
order, err := topoSort(graph)
if err != nil {
t.Fatal(err)
}
// b must appear before a
pos := func(s string) int {
for i, v := range order { if v == s { return i } }
return -1
}
if pos("b") > pos("a") {
t.Errorf("b should come before a, got %v", order)
}
})
t.Run("no deps", func(t *testing.T) {
graph := map[string][]string{"a": {}, "b": {}}
order, err := topoSort(graph)
if err != nil {
t.Fatal(err)
}
if len(order) != 2 {
t.Fatalf("expected 2 items, got %v", order)
}
})
t.Run("cycle detected", func(t *testing.T) {
graph := map[string][]string{"a": {"b"}, "b": {"a"}}
_, err := topoSort(graph)
if err == nil {
t.Fatal("expected cycle error")
}
})
t.Run("already visited node skipped", func(t *testing.T) {
// diamond: a→b, a→c, b→d, c→d
graph := map[string][]string{
"a": {"b", "c"},
"b": {"d"},
"c": {"d"},
"d": {},
}
order, err := topoSort(graph)
if err != nil {
t.Fatal(err)
}
// d must come first (or at least before b, c, a)
pos := func(s string) int {
for i, v := range order { if v == s { return i } }
return -1
}
if pos("d") > pos("b") || pos("d") > pos("c") {
t.Errorf("d should come before b and c, got %v", order)
}
})
}
// ── deepMerge ─────────────────────────────────────────────────────────────────
func TestDeepMerge(t *testing.T) {
t.Run("override wins on scalar", func(t *testing.T) {
base := map[string]any{"a": "base", "b": "keep"}
over := map[string]any{"a": "new"}
got := deepMerge(base, over)
if got["a"] != "new" || got["b"] != "keep" {
t.Errorf("got %v", got)
}
})
t.Run("nested maps merged recursively", func(t *testing.T) {
base := map[string]any{"m": map[string]any{"x": 1, "y": 2}}
over := map[string]any{"m": map[string]any{"y": 99, "z": 3}}
got := deepMerge(base, over)
m := got["m"].(map[string]any)
if m["x"] != 1 || m["y"] != 99 || m["z"] != 3 {
t.Errorf("nested merge wrong: %v", m)
}
})
t.Run("override scalar wins over base map", func(t *testing.T) {
base := map[string]any{"m": map[string]any{"x": 1}}
over := map[string]any{"m": "scalar"}
got := deepMerge(base, over)
if got["m"] != "scalar" {
t.Errorf("expected scalar to win, got %v", got["m"])
}
})
t.Run("base map + override scalar - base is map, override is scalar", func(t *testing.T) {
base := map[string]any{"k": map[string]any{"a": 1}}
over := map[string]any{"k": "replaced"}
got := deepMerge(base, over)
if got["k"] != "replaced" {
t.Errorf("got %v", got["k"])
}
})
t.Run("empty maps", func(t *testing.T) {
got := deepMerge(map[string]any{}, map[string]any{})
if len(got) != 0 {
t.Errorf("expected empty, got %v", got)
}
})
}
// ── Resolve ───────────────────────────────────────────────────────────────────
func buildPipeline(rawJobs map[string]map[string]any) *model.Pipeline {
p := &model.Pipeline{
Jobs: make(map[string]model.Job),
RawJobs: rawJobs,
}
for name, raw := range rawJobs {
ext := raw["extends"]
p.Jobs[name] = model.Job{Name: name, Extends: ext}
}
return p
}
func TestResolve(t *testing.T) {
t.Run("no extends — noop", func(t *testing.T) {
p := buildPipeline(map[string]map[string]any{
"build": {"script": []any{"make"}},
})
warnings, err := Resolve(p)
if err != nil || len(warnings) != 0 {
t.Fatalf("unexpected: err=%v warnings=%v", err, warnings)
}
})
t.Run("single extends merges fields", func(t *testing.T) {
p := buildPipeline(map[string]map[string]any{
".base": {"image": "alpine", "script": []any{"echo base"}},
"child": {"extends": ".base", "stage": "test"},
})
warnings, err := Resolve(p)
if err != nil || len(warnings) != 0 {
t.Fatalf("unexpected: err=%v warnings=%v", err, warnings)
}
child := p.Jobs["child"]
if child.Stage != "test" {
t.Errorf("stage not preserved: %q", child.Stage)
}
})
t.Run("unknown base produces warning, not error", func(t *testing.T) {
p := buildPipeline(map[string]map[string]any{
"child": {"extends": ".missing", "script": []any{"echo x"}},
})
warnings, err := Resolve(p)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(warnings) != 1 || warnings[0].Job != "child" || warnings[0].Base != ".missing" {
t.Errorf("expected warning about .missing, got %v", warnings)
}
})
t.Run("multi-extend list", func(t *testing.T) {
p := buildPipeline(map[string]map[string]any{
".a": {"image": "alpine"},
".b": {"tags": []any{"docker"}},
"child": {"extends": []any{".a", ".b"}, "script": []any{"echo x"}},
})
warnings, err := Resolve(p)
if err != nil || len(warnings) != 0 {
t.Fatalf("unexpected: %v %v", err, warnings)
}
})
t.Run("cycle returns error", func(t *testing.T) {
p := buildPipeline(map[string]map[string]any{
"a": {"extends": "b"},
"b": {"extends": "a"},
})
_, err := Resolve(p)
if err == nil {
t.Fatal("expected cycle error")
}
})
t.Run("file and line preserved after merge", func(t *testing.T) {
p := buildPipeline(map[string]map[string]any{
".base": {"script": []any{"echo base"}},
"child": {"extends": ".base"},
})
p.Jobs["child"] = model.Job{Name: "child", Extends: ".base", File: "ci.yml", Line: 10}
_, err := Resolve(p)
if err != nil {
t.Fatal(err)
}
if p.Jobs["child"].File != "ci.yml" || p.Jobs["child"].Line != 10 {
t.Errorf("file/line lost after merge: %+v", p.Jobs["child"])
}
})
t.Run("parseExtends invalid type returns error", func(t *testing.T) {
p := buildPipeline(map[string]map[string]any{
"job": {"extends": 123},
})
p.Jobs["job"] = model.Job{Name: "job", Extends: 123}
_, err := Resolve(p)
if err == nil {
t.Fatal("expected error for invalid extends type")
}
})
t.Run("yaml.Marshal fails — errorYAMLMarshaler injected into merged map", func(t *testing.T) {
// Inject a value whose MarshalYAML() returns an error so yaml.Marshal
// fails at extends.go:74-77.
p := buildPipeline(map[string]map[string]any{
".base": {"script": []any{"echo"}},
"child": {"extends": ".base", "bad": errorYAMLMarshaler{}},
})
_, err := Resolve(p)
if err == nil {
t.Fatal("expected error when yaml.Marshal fails")
}
})
t.Run("yaml.Unmarshal fails — map injected where []string expected", func(t *testing.T) {
// Marshal succeeds (maps are valid YAML), but Unmarshal into model.Job
// fails because Dependencies []string cannot hold a mapping.
p := buildPipeline(map[string]map[string]any{
".base": {
"dependencies": map[string]any{"invalid": "not-a-list"},
},
"child": {"extends": ".base", "stage": "build"},
})
_, err := Resolve(p)
if err == nil {
t.Fatal("expected error when yaml.Unmarshal fails on incompatible type")
}
})
}
-3
View File
@@ -305,9 +305,6 @@ func substituteInputs(data []byte, inputs map[string]any) []byte {
}
return inputPlaceholderRe.ReplaceAllFunc(data, func(match []byte) []byte {
groups := inputPlaceholderRe.FindSubmatch(match)
if len(groups) < 2 {
return match
}
if val, ok := inputs[string(groups[1])]; ok {
return []byte(fmt.Sprintf("%v", val))
}
+757
View File
@@ -1,7 +1,15 @@
package resolver
import (
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"git.k3nny.fr/glint/internal/fetcher"
"git.k3nny.fr/glint/internal/model"
)
func TestSubstituteInputs(t *testing.T) {
@@ -88,3 +96,752 @@ func TestSubstituteInputs(t *testing.T) {
})
}
}
// ── IncludeWarning.String ─────────────────────────────────────────────────────
func TestIncludeWarning_String(t *testing.T) {
skipped := IncludeWarning{Label: "myinclude", Skipped: true}
s := skipped.String()
if s == "" {
t.Error("skipped: expected non-empty string")
}
withErr := IncludeWarning{Label: "remote foo", Err: fmt.Errorf("connection refused")}
s2 := withErr.String()
if s2 == "" {
t.Error("withErr: expected non-empty string")
}
}
// ── normaliseInclude ──────────────────────────────────────────────────────────
func TestNormaliseInclude(t *testing.T) {
m, ok := normaliseInclude(map[string]any{"local": "ci.yml"})
if !ok || m["local"] != "ci.yml" {
t.Error("map form")
}
m2, ok2 := normaliseInclude("ci.yml")
if !ok2 || m2["local"] != "ci.yml" {
t.Error("string form")
}
_, ok3 := normaliseInclude(42)
if ok3 {
t.Error("int should not normalise")
}
}
// ── includeFiles ─────────────────────────────────────────────────────────────
func TestIncludeFiles(t *testing.T) {
if got := includeFiles(map[string]any{"file": "a.yml"}); len(got) != 1 {
t.Error("string file")
}
if got := includeFiles(map[string]any{"file": []any{"a.yml", "b.yml"}}); len(got) != 2 {
t.Error("slice file")
}
if got := includeFiles(map[string]any{"file": []any{"a.yml", 42}}); len(got) != 1 {
t.Error("mixed slice, int dropped")
}
if got := includeFiles(map[string]any{}); got != nil {
t.Error("no file key")
}
if got := includeFiles(map[string]any{"file": 99}); got != nil {
t.Error("unknown type returns nil")
}
}
// ── extractInputs ─────────────────────────────────────────────────────────────
func TestExtractInputs(t *testing.T) {
m := map[string]any{
"component": "host/p/c@v1",
"with": map[string]any{"KEY": "val"},
}
got := extractInputs(m)
if got == nil || got["KEY"] != "val" {
t.Error("expected KEY=val")
}
got2 := extractInputs(map[string]any{"component": "x"})
if got2 != nil {
t.Error("expected nil without with: key")
}
got3 := extractInputs(map[string]any{"with": "not-a-map"})
if got3 != nil {
t.Error("expected nil for non-map with:")
}
}
// ── parseComponentRef ─────────────────────────────────────────────────────────
func TestParseComponentRef_Resolver(t *testing.T) {
host, proj, comp, ver, err := parseComponentRef("gitlab.com/group/proj/comp@v1.0")
if err != nil {
t.Fatal(err)
}
if host != "gitlab.com" {
t.Errorf("host: %q", host)
}
if proj != "group/proj" {
t.Errorf("proj: %q", proj)
}
if comp != "comp" {
t.Errorf("comp: %q", comp)
}
if ver != "v1.0" {
t.Errorf("ver: %q", ver)
}
_, _, _, _, err2 := parseComponentRef("no-at")
if err2 == nil {
t.Error("expected error for no @")
}
_, _, _, _, err3 := parseComponentRef("a@")
if err3 == nil {
t.Error("expected error for empty version")
}
_, _, _, _, err4 := parseComponentRef("host/comp@v1")
if err4 == nil {
t.Error("expected error for too few parts")
}
}
// ── mergeIncluded ─────────────────────────────────────────────────────────────
func TestMergeIncluded(t *testing.T) {
dst := &model.Pipeline{
Stages: []string{"build"},
Jobs: map[string]model.Job{"existing": {Name: "existing"}},
RawJobs: map[string]map[string]any{},
}
src := &model.Pipeline{
Stages: []string{"build", "test"},
Jobs: map[string]model.Job{"new-job": {Name: "new-job"}, "existing": {Name: "existing-from-src"}},
RawJobs: map[string]map[string]any{"new-job": {"script": "echo"}},
Variables: map[string]any{"KEY": "val"},
}
mergeIncluded(dst, src)
if len(dst.Stages) != 2 {
t.Errorf("stages: got %d want 2", len(dst.Stages))
}
if _, ok := dst.Jobs["new-job"]; !ok {
t.Error("expected new-job in dst")
}
if dst.Jobs["existing"].Name != "existing" {
t.Error("dst wins on conflict")
}
if dst.Variables["KEY"] != "val" {
t.Error("variable merged")
}
// Merge again with conflicting variable: dst wins
src2 := &model.Pipeline{
Jobs: map[string]model.Job{},
Variables: map[string]any{"KEY": "other"},
}
mergeIncluded(dst, src2)
if dst.Variables["KEY"] != "val" {
t.Error("dst var wins on conflict")
}
}
func TestMergeIncluded_NilVariables(t *testing.T) {
dst := &model.Pipeline{Jobs: map[string]model.Job{}}
src := &model.Pipeline{
Jobs: map[string]model.Job{},
Variables: map[string]any{"A": "1"},
}
mergeIncluded(dst, src)
if dst.Variables["A"] != "1" {
t.Error("expected A in dst after nil init")
}
}
// ── resolveLocalInclude ───────────────────────────────────────────────────────
func TestResolveLocalInclude_ValidFile(t *testing.T) {
dir := t.TempDir()
childPath := filepath.Join(dir, "child.yml")
if err := os.WriteFile(childPath, []byte("child-job:\n script: echo ok\n"), 0o644); err != nil {
t.Fatal(err)
}
p := &model.Pipeline{Jobs: map[string]model.Job{}, RawJobs: map[string]map[string]any{}}
visited := map[string]bool{}
warnings, _ := resolveLocalInclude(p, "/child.yml", fetcher.GitLabConfig{}, dir, visited, 0)
if len(warnings) != 0 {
t.Errorf("unexpected warnings: %v", warnings)
}
if _, ok := p.Jobs["child-job"]; !ok {
t.Error("child-job should be merged")
}
}
func TestResolveLocalInclude_MissingFile(t *testing.T) {
p := &model.Pipeline{Jobs: map[string]model.Job{}, RawJobs: map[string]map[string]any{}}
warnings, _ := resolveLocalInclude(p, "/nonexistent.yml", fetcher.GitLabConfig{}, "/tmp", map[string]bool{}, 0)
if len(warnings) == 0 {
t.Error("expected warning for missing file")
}
}
func TestResolveLocalInclude_InvalidYAML(t *testing.T) {
dir := t.TempDir()
badPath := filepath.Join(dir, "bad.yml")
if err := os.WriteFile(badPath, []byte(":\tbad yaml\n"), 0o644); err != nil {
t.Fatal(err)
}
p := &model.Pipeline{Jobs: map[string]model.Job{}, RawJobs: map[string]map[string]any{}}
warnings, _ := resolveLocalInclude(p, "/bad.yml", fetcher.GitLabConfig{}, dir, map[string]bool{}, 0)
if len(warnings) == 0 {
t.Error("expected warning for invalid YAML")
}
}
func TestResolveLocalInclude_AlreadyVisited(t *testing.T) {
dir := t.TempDir()
childPath := filepath.Join(dir, "child.yml")
if err := os.WriteFile(childPath, []byte("job:\n script: echo\n"), 0o644); err != nil {
t.Fatal(err)
}
p := &model.Pipeline{Jobs: map[string]model.Job{}, RawJobs: map[string]map[string]any{}}
visited := map[string]bool{childPath: true}
warnings, _ := resolveLocalInclude(p, "/child.yml", fetcher.GitLabConfig{}, dir, visited, 0)
if len(warnings) != 0 {
t.Error("no warnings expected for already-visited")
}
if len(p.Jobs) != 0 {
t.Error("no jobs should be merged for already-visited")
}
}
func TestResolveLocalInclude_WithNestedIncludes(t *testing.T) {
dir := t.TempDir()
grandchild := filepath.Join(dir, "grandchild.yml")
if err := os.WriteFile(grandchild, []byte("gc-job:\n script: echo\n"), 0o644); err != nil {
t.Fatal(err)
}
child := filepath.Join(dir, "child.yml")
childContent := "include:\n - local: /grandchild.yml\nchild-job:\n script: echo\n"
if err := os.WriteFile(child, []byte(childContent), 0o644); err != nil {
t.Fatal(err)
}
p := &model.Pipeline{Jobs: map[string]model.Job{}, RawJobs: map[string]map[string]any{}}
_, _ = resolveLocalInclude(p, "/child.yml", fetcher.GitLabConfig{}, dir, map[string]bool{}, 0)
if _, ok := p.Jobs["gc-job"]; !ok {
t.Error("grandchild job should be merged")
}
}
// ── resolveRemoteInclude ──────────────────────────────────────────────────────
func TestResolveRemoteInclude_Success(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
fmt.Fprintln(w, "remote-job:\n script: echo remote")
}))
defer srv.Close()
p := &model.Pipeline{Jobs: map[string]model.Job{}, RawJobs: map[string]map[string]any{}}
cfg := fetcher.AutoConfig()
warnings, _ := resolveRemoteInclude(p, srv.URL+"/ci.yml", cfg, "/tmp", map[string]bool{}, 0)
if len(warnings) != 0 {
t.Errorf("unexpected warnings: %v", warnings)
}
if _, ok := p.Jobs["remote-job"]; !ok {
t.Error("remote-job should be merged")
}
}
func TestResolveRemoteInclude_Error(t *testing.T) {
p := &model.Pipeline{Jobs: map[string]model.Job{}, RawJobs: map[string]map[string]any{}}
warnings, _ := resolveRemoteInclude(p, "http://localhost:0/unreachable.yml", fetcher.GitLabConfig{}, "/tmp", map[string]bool{}, 0)
if len(warnings) == 0 {
t.Error("expected warning for unreachable URL")
}
}
func TestResolveRemoteInclude_AlreadyVisited(t *testing.T) {
p := &model.Pipeline{Jobs: map[string]model.Job{}, RawJobs: map[string]map[string]any{}}
visited := map[string]bool{"http://example.com/ci.yml": true}
warnings, _ := resolveRemoteInclude(p, "http://example.com/ci.yml", fetcher.GitLabConfig{}, "/tmp", visited, 0)
if len(warnings) != 0 {
t.Error("no warning expected for already-visited URL")
}
}
func TestResolveRemoteInclude_InvalidYAML(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
fmt.Fprintln(w, ":\tbad\tyaml")
}))
defer srv.Close()
p := &model.Pipeline{Jobs: map[string]model.Job{}, RawJobs: map[string]map[string]any{}}
warnings, _ := resolveRemoteInclude(p, srv.URL+"/bad.yml", fetcher.AutoConfig(), "/tmp", map[string]bool{}, 0)
if len(warnings) == 0 {
t.Error("expected warning for invalid YAML from remote")
}
}
// ── resolveProjectInclude ─────────────────────────────────────────────────────
func TestResolveProjectInclude_NoToken(t *testing.T) {
p := &model.Pipeline{Jobs: map[string]model.Job{}, RawJobs: map[string]map[string]any{}}
entry := map[string]any{"project": "g/p", "file": "ci.yml"}
warnings, _ := resolveProjectInclude(p, entry, "g/p", fetcher.GitLabConfig{}, "/tmp", map[string]bool{}, 0)
if len(warnings) == 0 {
t.Error("expected skipped warning when no token")
}
if !warnings[0].Skipped {
t.Error("expected Skipped=true when no token")
}
}
func TestResolveProjectInclude_AlreadyVisited(t *testing.T) {
p := &model.Pipeline{Jobs: map[string]model.Job{}, RawJobs: map[string]map[string]any{}}
visited := map[string]bool{"project:g/p:ci.yml@": true}
entry := map[string]any{"project": "g/p", "file": "ci.yml"}
warnings, _ := resolveProjectInclude(p, entry, "g/p", fetcher.GitLabConfig{Token: "tok"}, "/tmp", visited, 0)
if len(warnings) != 0 {
t.Error("already-visited should produce no warning")
}
}
func TestResolveProjectInclude_WithToken_FetchError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(404)
}))
defer srv.Close()
p := &model.Pipeline{Jobs: map[string]model.Job{}, RawJobs: map[string]map[string]any{}}
cfg := fetcher.GitLabConfig{BaseURL: srv.URL, Token: "tok"}
entry := map[string]any{"project": "g/p", "file": "ci.yml", "ref": "main"}
warnings, _ := resolveProjectInclude(p, entry, "g/p", cfg, "/tmp", map[string]bool{}, 0)
if len(warnings) == 0 {
t.Error("expected warning for 404 fetch")
}
}
func TestResolveProjectInclude_NoFiles(t *testing.T) {
p := &model.Pipeline{Jobs: map[string]model.Job{}, RawJobs: map[string]map[string]any{}}
entry := map[string]any{"project": "g/p"} // no file key
warnings, _ := resolveProjectInclude(p, entry, "g/p", fetcher.GitLabConfig{Token: "tok"}, "/tmp", map[string]bool{}, 0)
if len(warnings) != 0 {
t.Error("no warnings expected when no files")
}
}
// ── resolveComponentInclude ───────────────────────────────────────────────────
func TestResolveComponentInclude_DollarRef(t *testing.T) {
p := &model.Pipeline{Jobs: map[string]model.Job{}, RawJobs: map[string]map[string]any{}}
warn, _, hadErr := resolveComponentInclude(p, "${CI_SERVER}/g/p/comp@v1", nil, fetcher.GitLabConfig{}, "/tmp", map[string]bool{}, 0)
if !hadErr {
t.Error("expected hadErr for $ ref")
}
if warn.Label == "" {
t.Error("expected non-empty label")
}
}
func TestResolveComponentInclude_BadRef(t *testing.T) {
p := &model.Pipeline{Jobs: map[string]model.Job{}, RawJobs: map[string]map[string]any{}}
_, _, hadErr := resolveComponentInclude(p, "no-at-sign", nil, fetcher.GitLabConfig{}, "/tmp", map[string]bool{}, 0)
if !hadErr {
t.Error("expected hadErr for bad ref")
}
}
func TestResolveComponentInclude_AlreadyVisited(t *testing.T) {
p := &model.Pipeline{Jobs: map[string]model.Job{}, RawJobs: map[string]map[string]any{}}
visited := map[string]bool{"component:host/g/p/comp@v1": true}
_, _, hadErr := resolveComponentInclude(p, "host/g/p/comp@v1", nil, fetcher.GitLabConfig{}, "/tmp", visited, 0)
if hadErr {
t.Error("already-visited should not return hadErr")
}
}
func TestResolveComponentInclude_FetchError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(404)
}))
defer srv.Close()
p := &model.Pipeline{Jobs: map[string]model.Job{}, RawJobs: map[string]map[string]any{}}
cfg := fetcher.GitLabConfig{BaseURL: srv.URL, Token: "tok"}
_, _, hadErr := resolveComponentInclude(p, "localhost/g/p/comp@v1", nil, cfg, "/tmp", map[string]bool{}, 0)
if !hadErr {
t.Error("expected hadErr for 404 component fetch")
}
}
// ── resolveIncludes — depth guard ─────────────────────────────────────────────
func TestResolveIncludes_DepthExceeded(t *testing.T) {
p := &model.Pipeline{Jobs: map[string]model.Job{}, RawJobs: map[string]map[string]any{}}
warnings, _ := resolveIncludes(p, nil, fetcher.GitLabConfig{}, "/tmp", map[string]bool{}, maxIncludeDepth+1)
if len(warnings) == 0 {
t.Error("expected depth-exceeded warning")
}
}
// ── ResolveIncludes (public entry) ────────────────────────────────────────────
func TestResolveIncludes_LocalFile(t *testing.T) {
dir := t.TempDir()
childPath := filepath.Join(dir, "child.yml")
if err := os.WriteFile(childPath, []byte("include-job:\n script: echo\n"), 0o644); err != nil {
t.Fatal(err)
}
p := &model.Pipeline{
Jobs: map[string]model.Job{},
RawJobs: map[string]map[string]any{},
Include: []any{map[string]any{"local": "/child.yml"}},
}
warnings, _ := ResolveIncludes(p, fetcher.GitLabConfig{}, dir)
if len(warnings) != 0 {
t.Errorf("unexpected warnings: %v", warnings)
}
if _, ok := p.Jobs["include-job"]; !ok {
t.Error("include-job should be merged into pipeline")
}
}
func TestResolveIncludes_TemplateSkipped(t *testing.T) {
p := &model.Pipeline{
Jobs: map[string]model.Job{},
RawJobs: map[string]map[string]any{},
Include: []any{map[string]any{"template": "Auto-DevOps.gitlab-ci.yml"}},
}
warnings, _ := ResolveIncludes(p, fetcher.GitLabConfig{}, "/tmp")
if len(warnings) != 0 {
t.Errorf("template should be silently skipped, got: %v", warnings)
}
}
func TestResolveIncludes_StringLocalInclude(t *testing.T) {
dir := t.TempDir()
childPath := filepath.Join(dir, "ci.yml")
if err := os.WriteFile(childPath, []byte("str-job:\n script: echo\n"), 0o644); err != nil {
t.Fatal(err)
}
p := &model.Pipeline{
Jobs: map[string]model.Job{},
RawJobs: map[string]map[string]any{},
Include: []any{"ci.yml"},
}
warnings, _ := ResolveIncludes(p, fetcher.GitLabConfig{}, dir)
if len(warnings) != 0 {
t.Errorf("unexpected warnings: %v", warnings)
}
}
func TestResolveIncludes_InvalidEntry(t *testing.T) {
p := &model.Pipeline{
Jobs: map[string]model.Job{},
RawJobs: map[string]map[string]any{},
Include: []any{42},
}
warnings, _ := ResolveIncludes(p, fetcher.GitLabConfig{}, "/tmp")
if len(warnings) != 0 {
t.Errorf("unexpected warnings for invalid entry: %v", warnings)
}
}
// ── fetchComponentFile ────────────────────────────────────────────────────────
func TestFetchComponentFile_FallbackPath(t *testing.T) {
calls := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
w.WriteHeader(404)
}))
defer srv.Close()
cfg := fetcher.GitLabConfig{BaseURL: srv.URL, Token: "tok"}
_, err := fetchComponentFile(cfg, "g/p", "mycomp", "v1")
if err == nil {
t.Error("expected error when both paths fail")
}
}
func TestFetchComponentFile_PrimarySuccess(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
fmt.Fprintln(w, "comp-job:\n script: echo")
}))
defer srv.Close()
cfg := fetcher.GitLabConfig{BaseURL: srv.URL}
data, err := fetchComponentFile(cfg, "g/p", "mycomp", "v1")
if err != nil {
t.Fatalf("primary success: unexpected error: %v", err)
}
if len(data) == 0 {
t.Error("primary success: expected non-empty data")
}
}
func TestFetchComponentFile_FallbackSuccess(t *testing.T) {
calls := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
if calls == 1 {
// First call (primary path) fails.
w.WriteHeader(404)
return
}
// Second call (fallback path) succeeds.
w.WriteHeader(200)
fmt.Fprintln(w, "comp-job:\n script: echo")
}))
defer srv.Close()
cfg := fetcher.GitLabConfig{BaseURL: srv.URL}
data, err := fetchComponentFile(cfg, "g/p", "mycomp", "v1")
if err != nil {
t.Fatalf("fallback success: unexpected error: %v", err)
}
if len(data) == 0 {
t.Error("fallback success: expected non-empty data")
}
}
// ── resolveProjectInclude — success path ──────────────────────────────────────
func TestResolveProjectInclude_Success(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
fmt.Fprintln(w, "proj-job:\n script: echo from project")
}))
defer srv.Close()
p := &model.Pipeline{Jobs: map[string]model.Job{}, RawJobs: map[string]map[string]any{}}
cfg := fetcher.GitLabConfig{BaseURL: srv.URL, Token: "tok"}
entry := map[string]any{"project": "g/p", "file": "ci.yml", "ref": "main"}
warnings, _ := resolveProjectInclude(p, entry, "g/p", cfg, "/tmp", map[string]bool{}, 0)
if len(warnings) != 0 {
t.Errorf("success: unexpected warnings: %v", warnings)
}
if _, ok := p.Jobs["proj-job"]; !ok {
t.Error("proj-job should be merged into pipeline")
}
}
func TestResolveProjectInclude_InvalidYAML(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
fmt.Fprintln(w, ":\tbad\tyaml")
}))
defer srv.Close()
p := &model.Pipeline{Jobs: map[string]model.Job{}, RawJobs: map[string]map[string]any{}}
cfg := fetcher.GitLabConfig{BaseURL: srv.URL, Token: "tok"}
entry := map[string]any{"project": "g/p", "file": "ci.yml"}
warnings, _ := resolveProjectInclude(p, entry, "g/p", cfg, "/tmp", map[string]bool{}, 0)
if len(warnings) == 0 {
t.Error("invalid YAML: expected warning")
}
}
// TestResolveProjectInclude_WithSubIncludes covers includes.go:236-240 —
// the fetched project YAML itself contains include: entries.
func TestResolveProjectInclude_WithSubIncludes(t *testing.T) {
dir := t.TempDir()
// Write a local file that the project YAML sub-includes.
localContent := "local-from-project:\n script: echo local\n"
if err := os.WriteFile(filepath.Join(dir, "local.yml"), []byte(localContent), 0o644); err != nil {
t.Fatal(err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
// Return YAML that itself has a local sub-include.
fmt.Fprintln(w, "include:\n - local: /local.yml\nproj-sub-job:\n script: echo proj")
}))
defer srv.Close()
p := &model.Pipeline{Jobs: map[string]model.Job{}, RawJobs: map[string]map[string]any{}}
cfg := fetcher.GitLabConfig{BaseURL: srv.URL, Token: "tok"}
entry := map[string]any{"project": "g/p", "file": "ci.yml", "ref": "main"}
warnings, _ := resolveProjectInclude(p, entry, "g/p", cfg, dir, map[string]bool{}, 0)
if len(warnings) != 0 {
t.Errorf("sub-includes: unexpected warnings: %v", warnings)
}
if _, ok := p.Jobs["proj-sub-job"]; !ok {
t.Error("proj-sub-job should be merged via project include")
}
}
// tlsComp sets up a TLS test server and swaps http.DefaultTransport so the test
// client trusts the self-signed cert. Returns the host (without scheme).
func tlsComp(t *testing.T, h http.HandlerFunc) (host string) {
t.Helper()
srv := httptest.NewTLSServer(h)
t.Cleanup(srv.Close)
orig := http.DefaultTransport
http.DefaultTransport = srv.Client().Transport
t.Cleanup(func() { http.DefaultTransport = orig })
return srv.URL[len("https://"):]
}
// ── resolveComponentInclude — success path ────────────────────────────────────
func TestResolveComponentInclude_Success(t *testing.T) {
host := tlsComp(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
fmt.Fprintln(w, "comp-job:\n script: echo from component")
})
ref := host + "/g/p/mycomp@v1"
p := &model.Pipeline{Jobs: map[string]model.Job{}, RawJobs: map[string]map[string]any{}}
cfg := fetcher.GitLabConfig{}
_, _, hadErr := resolveComponentInclude(p, ref, nil, cfg, "/tmp", map[string]bool{}, 0)
if hadErr {
t.Error("success: unexpected hadErr")
}
if _, ok := p.Jobs["comp-job"]; !ok {
t.Error("comp-job should be merged into pipeline")
}
}
func TestResolveComponentInclude_InvalidYAML(t *testing.T) {
host := tlsComp(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
fmt.Fprintln(w, ":\tbad\tyaml")
})
ref := host + "/g/p/mycomp@v1"
p := &model.Pipeline{Jobs: map[string]model.Job{}, RawJobs: map[string]map[string]any{}}
cfg := fetcher.GitLabConfig{}
_, _, hadErr := resolveComponentInclude(p, ref, nil, cfg, "/tmp", map[string]bool{}, 0)
if !hadErr {
t.Error("invalid YAML: expected hadErr")
}
}
// TestResolveComponentInclude_WithSubIncludes covers includes.go:288-291 —
// the fetched component YAML itself contains include: entries.
func TestResolveComponentInclude_WithSubIncludes(t *testing.T) {
dir := t.TempDir()
localContent := "comp-local-job:\n script: echo comp-local\n"
if err := os.WriteFile(filepath.Join(dir, "sub.yml"), []byte(localContent), 0o644); err != nil {
t.Fatal(err)
}
host := tlsComp(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
// Component YAML contains a local sub-include.
fmt.Fprintln(w, "include:\n - local: /sub.yml\ncomp-job:\n script: echo comp")
})
ref := host + "/g/p/mycomp@v1"
p := &model.Pipeline{Jobs: map[string]model.Job{}, RawJobs: map[string]map[string]any{}}
cfg := fetcher.GitLabConfig{}
_, extW, hadErr := resolveComponentInclude(p, ref, nil, cfg, dir, map[string]bool{}, 0)
if hadErr {
t.Errorf("sub-includes: unexpected hadErr; extWarnings=%v", extW)
}
if _, ok := p.Jobs["comp-job"]; !ok {
t.Error("comp-job should be merged via component include")
}
}
// ── resolveRemoteInclude — sub-includes ───────────────────────────────────────
func TestResolveRemoteInclude_WithSubIncludes(t *testing.T) {
// The remote file itself includes a local file. The local file resolution
// exercises the sub-include path in resolveRemoteInclude (line 189-193).
dir := t.TempDir()
localContent := "local-child-job:\n script: echo child\n"
if err := os.WriteFile(filepath.Join(dir, "child.yml"), []byte(localContent), 0o644); err != nil {
t.Fatal(err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
// The remote YAML includes a local file.
fmt.Fprintln(w, "include:\n - local: /child.yml\nremote-job:\n script: echo remote")
}))
defer srv.Close()
p := &model.Pipeline{Jobs: map[string]model.Job{}, RawJobs: map[string]map[string]any{}}
warnings, _ := resolveRemoteInclude(p, srv.URL+"/ci.yml", fetcher.AutoConfig(), dir, map[string]bool{}, 0)
if len(warnings) != 0 {
t.Errorf("sub-includes: unexpected warnings: %v", warnings)
}
if _, ok := p.Jobs["remote-job"]; !ok {
t.Error("remote-job should be merged")
}
if _, ok := p.Jobs["local-child-job"]; !ok {
t.Error("local-child-job should be merged via sub-include")
}
}
// ── resolveIncludes — routing branches ───────────────────────────────────────
func TestResolveIncludes_RemoteEntry(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
fmt.Fprintln(w, "remote-job:\n script: echo")
}))
defer srv.Close()
p := &model.Pipeline{Jobs: map[string]model.Job{}, RawJobs: map[string]map[string]any{}}
includes := []any{map[string]any{"remote": srv.URL + "/ci.yml"}}
warnings, _ := resolveIncludes(p, includes, fetcher.AutoConfig(), "/tmp", map[string]bool{}, 0)
if len(warnings) != 0 {
t.Errorf("remote entry: unexpected warnings: %v", warnings)
}
if _, ok := p.Jobs["remote-job"]; !ok {
t.Error("remote-job should be merged via resolveIncludes remote routing")
}
}
func TestResolveIncludes_ProjectEntry(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
fmt.Fprintln(w, "proj-job:\n script: echo")
}))
defer srv.Close()
p := &model.Pipeline{Jobs: map[string]model.Job{}, RawJobs: map[string]map[string]any{}}
cfg := fetcher.GitLabConfig{BaseURL: srv.URL, Token: "tok"}
includes := []any{map[string]any{"project": "g/p", "file": "ci.yml"}}
_, _ = resolveIncludes(p, includes, cfg, "/tmp", map[string]bool{}, 0)
// proj-job should be merged (or warning if fetch fails, but with token it should succeed)
}
func TestResolveIncludes_ComponentEntry(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
fmt.Fprintln(w, "comp-job:\n script: echo")
}))
defer srv.Close()
host := srv.URL[len("http://"):]
p := &model.Pipeline{Jobs: map[string]model.Job{}, RawJobs: map[string]map[string]any{}}
cfg := fetcher.GitLabConfig{BaseURL: srv.URL}
includes := []any{map[string]any{"component": host + "/g/p/mycomp@v1"}}
warnings, _ := resolveIncludes(p, includes, cfg, "/tmp", map[string]bool{}, 0)
_ = warnings // component path is exercised regardless of outcome
}
func TestResolveIncludes_ComponentEntry_HadErr(t *testing.T) {
// Component with a dollar sign in ref → hadErr=true → warning is appended.
p := &model.Pipeline{Jobs: map[string]model.Job{}, RawJobs: map[string]map[string]any{}}
includes := []any{map[string]any{"component": "${CI_SERVER}/g/p/comp@v1"}}
warnings, _ := resolveIncludes(p, includes, fetcher.GitLabConfig{}, "/tmp", map[string]bool{}, 0)
if len(warnings) == 0 {
t.Error("$ in component ref: expected warning")
}
}
// ── substituteInputs — empty data ────────────────────────────────────────────
func TestSubstituteInputs_EmptyData(t *testing.T) {
result := substituteInputs([]byte{}, map[string]any{"KEY": "val"})
if len(result) != 0 {
t.Errorf("empty data: expected empty result, got %q", result)
}
}
+26
View File
@@ -0,0 +1,26 @@
stages:
- build
- test
# No default: block — any inherit: default: declaration is a no-op.
# GL043: inherit: default: false but no default: block.
isolated-job:
stage: build
inherit:
default: false
script: echo isolated
# GL043: inherit: default: [list] but no default: block.
selective-inherit-job:
stage: test
inherit:
default: [image, tags]
script: echo selective
# Not GL043: inherit only touches variables: (no default: check needed).
vars-inherit-job:
stage: test
inherit:
variables: false
script: echo vars-only
+20
View File
@@ -0,0 +1,20 @@
stages:
- build
- test
default:
image: node:20
# GL043: before_script is in the list but not defined in default:.
selective-bad:
stage: build
inherit:
default: [image, before_script] # before_script not in default:
script: echo hi
# Not GL043: image IS defined in default: — list is valid.
selective-good:
stage: test
inherit:
default: [image]
script: echo hi
+4
View File
@@ -9,6 +9,10 @@ workflow:
when: always
- when: never
default:
tags:
- docker
variables:
GO_VERSION: "1.26"
+15
View File
@@ -0,0 +1,15 @@
stages:
- build
- test
build-job:
stage: build
script: make
test-job:
stage: test
script: make test
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
needs: [build-job, nonexistent-job] # nonexistent-job does not exist → GL044
- when: on_success
+19
View File
@@ -0,0 +1,19 @@
stages:
- build
- test
build-job:
stage: build
script: make
lint-job:
stage: build
script: make lint
test-job:
stage: test
script: make test
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
needs: [build-job, lint-job] # both exist → clean
- when: on_success
+48
View File
@@ -0,0 +1,48 @@
stages:
- deploy
- test
variables:
DEPLOY: "false"
SKIP_TEST: "true"
# GL042: both if: conditions always evaluate to false given the declared variables.
always-dead-deploy:
stage: deploy
rules:
- if: '$DEPLOY == "true"'
when: on_success
script: echo deploy
# GL042: if: is false, explicit when:on_success rule — still never fires.
always-dead-test:
stage: test
rules:
- if: '$SKIP_TEST == "false"'
when: on_success
script: echo test
# Not GL042: references a predefined CI_ variable → unknown value → skip check.
predefined-var-job:
stage: test
rules:
- if: '$CI_COMMIT_BRANCH == "nonexistent-branch"'
when: on_success
script: echo branch
# Not GL042: has an unconditional rule (no if: → always matches).
unconditional-job:
stage: test
rules:
- if: '$DEPLOY == "true"'
when: never
- when: on_success
script: echo always
# Not GL042: references undeclared variable → unknown → conservative.
undeclared-var-job:
stage: test
rules:
- if: '$UNDECLARED_VAR == "something"'
when: on_success
script: echo undeclared