Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2c45b343c2 | |||
| 8e76caddb2 | |||
| 6f8d47a8de | |||
| 192ab3198b | |||
| f197c368d3 | |||
| d6afb148ca | |||
| 522c637b75 | |||
| 9342ce0eff | |||
| 7a4d55ec9a | |||
| 0e51844c3a | |||
| 8dc30d9207 | |||
| 416659ffd8 | |||
| 3b0bcb72d3 | |||
| b21ef5c0bb | |||
| 04f17f8616 | |||
| 7f7e2bf77b | |||
| e13455a629 | |||
| b8e640de03 | |||
| 4ce7f86d4d |
@@ -0,0 +1,66 @@
|
||||
# .glint.yml — glint project configuration
|
||||
#
|
||||
# Place this file anywhere between your .gitlab-ci.yml and the repository root.
|
||||
# glint searches upward from the pipeline file and stops at the first .git
|
||||
# boundary, so the repo root is the typical location.
|
||||
#
|
||||
# All keys are optional. Omit or comment out anything you don't need.
|
||||
|
||||
# ── Rule suppression ──────────────────────────────────────────────────────────
|
||||
#
|
||||
# Suppress rules globally for this project. Suppressed rules produce no output
|
||||
# and do not affect the exit code.
|
||||
#
|
||||
ignore:
|
||||
- GL007 # only:/except: used (migrating from legacy syntax)
|
||||
- GL032 # rules:if: references undeclared variable (injected at runtime)
|
||||
|
||||
# ── Severity overrides ────────────────────────────────────────────────────────
|
||||
#
|
||||
# Override the default severity of any rule.
|
||||
# Valid values: error | warning | ignore
|
||||
# "ignore" is equivalent to listing the rule in `ignore:` above.
|
||||
#
|
||||
severity:
|
||||
GL004: warning # demote "unknown stage" to warning during a stage migration
|
||||
GL035: error # promote absolute-path warning to a hard error
|
||||
GL007: ignore # equivalent to adding GL007 to ignore:
|
||||
|
||||
# ── Extra stage names ─────────────────────────────────────────────────────────
|
||||
#
|
||||
# Declare stage names that are valid for this project beyond what is listed in
|
||||
# the pipeline's own `stages:` block. Jobs referencing these stages will not
|
||||
# be flagged by GL004. Useful when stages are defined in a shared parent
|
||||
# template that glint cannot reach.
|
||||
#
|
||||
stages:
|
||||
- quality
|
||||
- security
|
||||
- compliance
|
||||
|
||||
# ── GitLab token ──────────────────────────────────────────────────────────────
|
||||
#
|
||||
# Default personal access token (read_api scope) used to fetch `include:
|
||||
# project:` templates. This is the lowest-priority token source; it is
|
||||
# overridden by the --token flag and the GITLAB_TOKEN / CI_JOB_TOKEN /
|
||||
# GITLAB_PRIVATE_TOKEN environment variables.
|
||||
#
|
||||
# Avoid committing real tokens — use environment variables instead.
|
||||
#
|
||||
# token: glpat-xxxxxxxxxxxxxxxxxxxx
|
||||
|
||||
# ── GitLab instance URL ───────────────────────────────────────────────────────
|
||||
#
|
||||
# Default GitLab instance URL, used when fetching project: and component:
|
||||
# includes. Overridden by --gitlab-url, CI_SERVER_URL, and GITLAB_URL.
|
||||
#
|
||||
# url: https://gitlab.example.com
|
||||
|
||||
# ── Include cache directory ───────────────────────────────────────────────────
|
||||
#
|
||||
# Default directory for caching fetched remote includes (project: and
|
||||
# component:). The directory is created on first use. Overridden by
|
||||
# --cache-dir. When --offline is given without --cache-dir, glint defaults
|
||||
# to ~/.cache/glint regardless of this setting.
|
||||
#
|
||||
# cache_dir: ~/.cache/glint
|
||||
@@ -0,0 +1,11 @@
|
||||
- id: glint
|
||||
name: glint — validate GitLab CI pipeline
|
||||
description: >-
|
||||
Lint .gitlab-ci.yml with glint before committing. Catches misconfigured
|
||||
stages, invalid keywords, broken needs: graphs, deprecated patterns, and
|
||||
more — without a GitLab server.
|
||||
entry: glint check
|
||||
language: golang
|
||||
files: '(^|/)\.gitlab-ci\.yml$'
|
||||
pass_filenames: true
|
||||
minimum_pre_commit_version: '3.0.0'
|
||||
+104
@@ -5,6 +5,110 @@ 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.29] - 2026-06-26
|
||||
|
||||
### Added
|
||||
|
||||
- **LSP server** (`glint lsp`) — new `internal/lsp` package and `glint lsp` subcommand that starts a Language Server Protocol server over stdin/stdout using Content-Length–framed JSON-RPC 2.0. Editors (VS Code, Neovim, Emacs, JetBrains, etc.) can connect with any generic LSP client configuration. Supported methods: `initialize`, `initialized`, `shutdown`, `exit`, `textDocument/didOpen`, `textDocument/didChange`, `textDocument/didSave`, `textDocument/didClose`. On every document open or change the server runs the full glint lint pipeline and publishes diagnostics via `textDocument/publishDiagnostics`; each diagnostic carries the rule ID as its `code` field and `"glint"` as `source`. Parse errors are surfaced as an Error diagnostic at the top of the document. Include resolution is best-effort (uses `GITLAB_TOKEN` / `GITLAB_URL` env vars; default cache dir `~/.cache/glint`). CLI flags: `--token`, `--gitlab-url`, `--cache-dir`, `--offline`.
|
||||
|
||||
## [0.2.28] - 2026-06-26
|
||||
|
||||
### Added
|
||||
|
||||
- **Pre-commit hook** — `.pre-commit-hooks.yaml` defines a `glint` hook with `language: golang`; pre-commit builds glint from source automatically on first run and re-runs `glint check` on any staged `.gitlab-ci.yml` changes. Reference: `repo: https://git.k3nny.fr/k3nny/glint, rev: v0.2.28`.
|
||||
|
||||
- **GitLab CI component** (`templates/check.yml`) — a GitLab CI/CD Catalog–compatible component that downloads the glint Linux binary and runs `glint check` as a pipeline job. Accepts inputs: `stage` (default `validate`), `pipeline_file` (default `.gitlab-ci.yml`), `version` (default `latest`), `allow_failure` (default `false`), and `extra_args`. Can also be used as a plain local or remote include without the Catalog.
|
||||
|
||||
- **GitHub Actions composite action** (`action.yml`) — downloads the glint Linux binary into `$RUNNER_TEMP`, adds it to `$GITHUB_PATH`, and runs `glint check`. Inputs: `version`, `file`, `args`. Mirror this repository to GitHub as `k3nny/glint` to reference it as `uses: k3nny/glint@v0.2.28`.
|
||||
|
||||
## [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-Changelog–compatible 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 (GL027–GL031). 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 (GL001–GL043). `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
@@ -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 2–200, or map form missing `matrix:` |
|
||||
| GL012 | ERR | `retry:` integer not in range 0–2, 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 (GL027–GL031). 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. |
|
||||
@@ -6,50 +6,24 @@
|
||||
|
||||
<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.29-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 (GL001–GL043); 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
|
||||
- **LSP server** — `glint lsp` starts a Language Server Protocol server over stdin/stdout; connect with any LSP client to get inline diagnostics (rule ID as code, error/warning severity) in VS Code, Neovim, Emacs, JetBrains, etc.
|
||||
|
||||
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
|
||||
|
||||
@@ -59,7 +33,7 @@ See [ROADMAP.md](ROADMAP.md) for planned improvements.
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
git clone https://git.k3nny.fr/glint
|
||||
git clone https://git.k3nny.fr/k3nny/glint
|
||||
cd glint
|
||||
go build -o glint ./cmd/glint/...
|
||||
```
|
||||
@@ -78,394 +52,61 @@ 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
|
||||
lsp Start a Language Server Protocol server (stdin/stdout)
|
||||
```
|
||||
|
||||
Run `glint <command> --help` for command-specific options and examples.
|
||||
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.
|
||||
|
||||
### `glint check`
|
||||
## Integrations
|
||||
|
||||
```bash
|
||||
glint check .gitlab-ci.yml
|
||||
```
|
||||
### Pre-commit hook
|
||||
|
||||
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.
|
||||
Add to `.pre-commit-config.yaml` in your repository to run glint automatically whenever `.gitlab-ci.yml` changes:
|
||||
|
||||
```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
|
||||
repos:
|
||||
- repo: https://git.k3nny.fr/k3nny/glint
|
||||
rev: v0.2.28
|
||||
hooks:
|
||||
- id: glint
|
||||
```
|
||||
|
||||
**Priority chain for token and URL:** `--token`/`--gitlab-url` flags > `.glint.yml` values > `GITLAB_TOKEN`/`CI_SERVER_URL` environment variables.
|
||||
Requires [pre-commit](https://pre-commit.com) and Go 1.21+. On first run, pre-commit builds glint from source automatically.
|
||||
|
||||
### Inline suppression (`# glint: ignore`)
|
||||
### GitLab CI component
|
||||
|
||||
Suppress a finding for a specific job by placing a `# glint: ignore RULE` comment immediately before the job definition:
|
||||
Copy [`templates/check.yml`](templates/check.yml) into your repository and include it as a local file, or publish this repository to a GitLab CI/CD Catalog and reference it as a component:
|
||||
|
||||
```yaml
|
||||
# glint: ignore GL007
|
||||
legacy-job:
|
||||
stage: build
|
||||
only:
|
||||
- main
|
||||
script: echo ok
|
||||
# As a local include (copy templates/check.yml to your repo first):
|
||||
include:
|
||||
- local: .gitlab/glint-check.yml
|
||||
|
||||
# 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
|
||||
# As a Catalog component (after publishing to a GitLab instance):
|
||||
include:
|
||||
- component: $CI_SERVER_FQDN/k3nny/glint/check@v0.2.28
|
||||
inputs:
|
||||
stage: validate # optional, default: validate
|
||||
allow_failure: true # optional, default: false
|
||||
```
|
||||
|
||||
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:`.
|
||||
The component downloads the glint Linux binary, runs `glint check`, and respects all inputs defined in the `spec:` block.
|
||||
|
||||
### Remote project includes
|
||||
### GitHub Actions
|
||||
|
||||
Pipelines that include templates from other GitLab projects are supported.
|
||||
Provide a token so `glint` can fetch them:
|
||||
Copy [`action.yml`](action.yml) from this repository, or mirror this repo to GitHub as `k3nny/glint` and reference it directly:
|
||||
|
||||
```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
|
||||
```yaml
|
||||
- uses: k3nny/glint@v0.2.28
|
||||
with:
|
||||
file: .gitlab-ci.yml # optional, default: .gitlab-ci.yml
|
||||
args: '--format sarif' # optional
|
||||
```
|
||||
|
||||
**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 2–200, or map form missing `matrix` key |
|
||||
| GL012 | ERROR | `retry` integer not in range 0–2, 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.
|
||||
The action downloads the glint Linux binary into `$RUNNER_TEMP` and runs `glint check`. Only Linux runners are supported (matches the available release binary).
|
||||
|
||||
## Development
|
||||
|
||||
@@ -478,11 +119,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
|
||||
|
||||
```
|
||||
|
||||
+70
-100
@@ -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
|
||||
- [x] **Single-context simulation** — shipped v0.2.0; `--branch`, `--tag`, `--source`, `--var` flags on both subcommands; jobs classified as active / manual / skipped
|
||||
- [x] **`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
|
||||
- [x] **Expression evaluator: multi-line `if:` values** — shipped post-v0.2.0; newlines in block-scalar and folded YAML `if:` values treated as whitespace
|
||||
- [x] **Expression evaluator: `${VAR}` syntax** — shipped post-v0.2.0; `${CI_COMMIT_BRANCH}` equivalent to `$CI_COMMIT_BRANCH` everywhere
|
||||
- [x] **Expression evaluator: regex flags** — shipped post-v0.2.0; `/pattern/i`, `/pattern/m`, `/pattern/s` supported
|
||||
- [x] **Expression evaluator: variable as regex RHS** — shipped post-v0.2.0; `$BRANCH =~ $PATTERN` where `$PATTERN` holds a `/regex/` string evaluates correctly
|
||||
- [x] **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
|
||||
- [x] **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
|
||||
- [x] **`--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
|
||||
- [x] **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`
|
||||
- [x] **Non-string scalar variables** — shipped v0.2.13; `BUILD: true`, `RETRIES: 3` rendered and injected correctly instead of being silently dropped
|
||||
- [x] **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
|
||||
- [x] **Workflow rule strict evaluation** — shipped v0.2.14; unparseable `if:` skips the rule instead of matching everything; prevents wrong variables being injected
|
||||
- [x] **Single `=` operator** — shipped v0.2.14; bare `=` accepted as alias for `==` in `rules:if:` expressions
|
||||
- [x] **`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
|
||||
- [x] **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
|
||||
- [x] **Context-scoped linting** — shipped v0.2.23; jobs evaluated as `JobSkipped` in the supplied context are excluded from `needs:`/`dependencies:` cross-checks (GL027–GL031) to eliminate false-positive errors for conditionally-gated jobs
|
||||
|
||||
---
|
||||
|
||||
@@ -60,36 +32,36 @@ glint graph tree --branch main .gitlab-ci.yml # tree annotated with [skipped]
|
||||
|
||||
The current rule set covers the most common sources of broken pipelines. These are the gaps most likely to matter in practice.
|
||||
|
||||
- ~~**Variable reference validation (GL032)**~~ — ✓ shipped v0.2.11; warns when a `rules:if:` expression references `$VAR` / `${VAR}` not declared anywhere in pipeline YAML; predefined GitLab namespaces (`CI_*`, `GITLAB_*`, …) exempt; variables from included files are also considered
|
||||
- ~~**`rules:if:` static reachability (GL033)**~~ — ✓ shipped v0.2.15; warns when every rule in a job's `rules:` block has `when: never`, making the job permanently excluded from any pipeline run; no `if:` evaluation required
|
||||
- ~~**`services:` validation (GL034)**~~ — ✓ shipped v0.2.16; map form requires `name`; `alias` must be a valid DNS label
|
||||
- ~~**`rules:changes` / `rules:exists` absolute path detection (GL035)**~~ — ✓ shipped v0.2.16; warns when a path starts with `/`; GitLab CI paths are always relative to the repo root
|
||||
- ~~**`timeout` format validation (GL036)**~~ — ✓ shipped v0.2.16; validates job-level and `default.timeout` against recognised GitLab CI duration strings
|
||||
- ~~**`id_tokens:` / `secrets:` required-key checks (GL037, GL038)**~~ — ✓ shipped v0.2.16; `id_tokens` entries must have `aud`; `secrets` entries must declare a provider
|
||||
- ~~**`pages:publish` + `artifacts.paths` consistency (GL039)**~~ — ✓ shipped v0.2.16; warns when the publish directory is missing from `artifacts.paths`
|
||||
- ~~**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
|
||||
- [x] **Variable reference validation (GL032)** — shipped v0.2.11; warns when a `rules:if:` expression references `$VAR` / `${VAR}` not declared anywhere in pipeline YAML; predefined GitLab namespaces (`CI_*`, `GITLAB_*`, …) exempt; variables from included files are also considered
|
||||
- [x] **`rules:if:` static reachability (GL033)** — shipped v0.2.15; warns when every rule in a job's `rules:` block has `when: never`, making the job permanently excluded from any pipeline run; no `if:` evaluation required
|
||||
- [x] **`services:` validation (GL034)** — shipped v0.2.16; map form requires `name`; `alias` must be a valid DNS label
|
||||
- [x] **`rules:changes` / `rules:exists` absolute path detection (GL035)** — shipped v0.2.16; warns when a path starts with `/`; GitLab CI paths are always relative to the repo root
|
||||
- [x] **`timeout` format validation (GL036)** — shipped v0.2.16; validates job-level and `default.timeout` against recognised GitLab CI duration strings
|
||||
- [x] **`id_tokens:` / `secrets:` required-key checks (GL037, GL038)** — shipped v0.2.16; `id_tokens` entries must have `aud`; `secrets` entries must declare a provider
|
||||
- [x] **`pages:publish` + `artifacts.paths` consistency (GL039)** — shipped v0.2.16; warns when the publish directory is missing from `artifacts.paths`
|
||||
- [x] **Duplicate stage names (GL040)** — shipped v0.2.16; warns when a stage appears more than once in `stages:`
|
||||
- [x] **`cache:key:files` must be exact paths (GL041)** — shipped v0.2.16; warns when entries look like glob patterns
|
||||
- [x] **Unreachable jobs** — covered by GL033 (shipped v0.2.15); every-`when:never` rules block is statically dead
|
||||
- [x] **`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:`
|
||||
|
||||
---
|
||||
|
||||
## Include resolution
|
||||
|
||||
- ~~**`include: local:`** full resolution~~ — ✓ shipped in v0.2.0; local files are read from disk, recursively resolved, and merged before linting
|
||||
- ~~**`include: remote:`** (URL)~~ — ✓ shipped post-v0.2.0; plain HTTPS URLs are fetched (unauthenticated), parsed, and merged; sub-includes are resolved recursively; unreachable URLs emit `[WARNING]` and linting continues
|
||||
- ~~**Recursive include depth limit**~~ — ✓ shipped v0.2.17; depth capped at 100 (matching GitLab); project/component includes now tracked in visited set to prevent cross-file cycles
|
||||
- ~~**Offline mode / cache**~~ — ✓ shipped v0.2.17; `--cache-dir DIR` persists fetched templates; `--offline` serves from cache only; default cache dir (`~/.cache/glint`) used automatically with `--offline`
|
||||
- ~~**`include: inputs:`**~~ — ✓ shipped v0.2.17; `$[[ inputs.KEY ]]` and `$[[ inputs.KEY | default(…) ]]` placeholders in fetched component YAML are substituted from the include's `with:` block before parsing
|
||||
- [x] **`include: local:` full resolution** — shipped v0.2.0; local files are read from disk, recursively resolved, and merged before linting
|
||||
- [x] **`include: remote:` (URL)** — shipped post-v0.2.0; plain HTTPS URLs are fetched (unauthenticated), parsed, and merged; sub-includes are resolved recursively; unreachable URLs emit `[WARNING]` and linting continues
|
||||
- [x] **Recursive include depth limit** — shipped v0.2.17; depth capped at 100 (matching GitLab); project/component includes now tracked in visited set to prevent cross-file cycles
|
||||
- [x] **Offline mode / cache** — shipped v0.2.17; `--cache-dir DIR` persists fetched templates; `--offline` serves from cache only; default cache dir (`~/.cache/glint`) used automatically with `--offline`
|
||||
- [x] **`include: inputs:`** — shipped v0.2.17; `$[[ inputs.KEY ]]` and `$[[ inputs.KEY | default(…) ]]` placeholders in fetched component YAML are substituted from the include's `with:` block before parsing
|
||||
|
||||
---
|
||||
|
||||
## Output formats — ✓ shipped v0.2.18
|
||||
## Output formats
|
||||
|
||||
- ~~**JSON** (`--format json`)~~ — ✓ shipped v0.2.18; machine-readable findings with stable schema (version 1)
|
||||
- ~~**SARIF** (`--format sarif`)~~ — ✓ shipped v0.2.18; SARIF 2.1.0; consumed natively by GitHub Code Scanning and GitLab SAST
|
||||
- ~~**JUnit XML** (`--format junit`)~~ — ✓ shipped v0.2.18; lets CI pipelines publish lint results as a test report artifact
|
||||
- ~~**GitHub annotation format** (`--format github`)~~ — ✓ shipped v0.2.18; emits `::error file=…,line=…,title=RULE::message` lines so findings appear as inline comments in PR diffs
|
||||
- [x] **JSON** (`--format json`) — shipped v0.2.18; machine-readable findings with stable schema (version 1)
|
||||
- [x] **SARIF** (`--format sarif`) — shipped v0.2.18; SARIF 2.1.0; consumed natively by GitHub Code Scanning and GitLab SAST
|
||||
- [x] **JUnit XML** (`--format junit`) — shipped v0.2.18; lets CI pipelines publish lint results as a test report artifact
|
||||
- [x] **GitHub annotation format** (`--format github`) — shipped v0.2.18; emits `::error file=…,line=…,title=RULE::message` lines so findings appear as inline comments in PR diffs
|
||||
|
||||
---
|
||||
|
||||
@@ -97,53 +69,51 @@ The current rule set covers the most common sources of broken pipelines. These a
|
||||
|
||||
The SVG renderer and terminal tree cover the basic layout. These would bring it closer to GitLab's full interactive view.
|
||||
|
||||
- ~~**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
|
||||
- [x] **Terminal job tree** — shipped v0.2.0 as `glint graph tree`; stages as branches, jobs as leaves, context-aware annotations
|
||||
- [x] **`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
|
||||
- [x] **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
|
||||
- [x] **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
|
||||
- [x] **`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
|
||||
- [x] **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
|
||||
- [x] **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
|
||||
- [x] **Mermaid pipeline output** — shipped v0.2.25; `glint graph pipeline --format mermaid` prints a Mermaid flowchart to stdout (paste into mermaid.live)
|
||||
- [x] **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
|
||||
- [x] **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
|
||||
|
||||
---
|
||||
|
||||
## Findings quality — ✓ file and line numbers shipped post-v0.2.0; ruff-style format shipped v0.2.11
|
||||
## Findings quality
|
||||
|
||||
~~**File and line numbers on findings**~~ — ✓ shipped post-v0.2.0; every finding includes the source file and exact line of the job key. Works across local includes, remote project templates, and fetched component templates.
|
||||
|
||||
~~**Ruff-style output format**~~ — ✓ shipped v0.2.11; findings follow `file:line: RULEID [severity] message` matching the convention used by ruff and other modern linters.
|
||||
|
||||
**Remaining improvements**
|
||||
|
||||
- ~~**`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)
|
||||
- [x] **File and line numbers on findings** — shipped post-v0.2.0; every finding includes the source file and exact line of the job key; works across local includes, remote project templates, and fetched component templates
|
||||
- [x] **Ruff-style output format** — shipped v0.2.11; findings follow `file:line: RULEID [severity] message` matching the convention used by ruff and other modern linters
|
||||
- [x] **`needs: optional: true` false-positive errors** — shipped post-v0.2.0; optional missing needs are downgraded to `[WARNING]`
|
||||
- [x] **`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)
|
||||
- [x] **`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)
|
||||
|
||||
---
|
||||
|
||||
## CI / editor integration
|
||||
|
||||
- **GitLab CI template** — a `.gitlab-ci.yml` snippet that runs `glint` as a pipeline-validation job before the real pipeline executes; publishable to the GitLab CI/CD Catalog
|
||||
- **GitHub Actions action** — `uses: k3nny/glint@v1` wrapper for repositories that mirror or manage GitLab pipelines from GitHub
|
||||
- **Pre-commit hook** — entry for [pre-commit](https://pre-commit.com) so `glint` runs automatically on `git commit` when `.gitlab-ci.yml` changes
|
||||
- **LSP server** — `glint lsp` mode exposing diagnostics over the Language Server Protocol; enables inline squiggles in VS Code, JetBrains, Neovim, etc. without a dedicated extension
|
||||
- **VS Code extension** — thin wrapper around the LSP server with syntax highlighting for `.gitlab-ci.yml`
|
||||
- [x] **GitLab CI template** — shipped v0.2.28; `templates/check.yml` is a GitLab CI/CD Catalog component with `spec:` inputs for stage, file, version, allow_failure, and extra args; also usable as a plain local/remote include
|
||||
- [x] **GitHub Actions action** — shipped v0.2.28; `action.yml` composite action downloads the glint Linux binary and runs `glint check`; mirror to GitHub as `k3nny/glint` to reference as `uses: k3nny/glint@v0.2.28`
|
||||
- [x] **Pre-commit hook** — shipped v0.2.28; `.pre-commit-hooks.yaml` defines `language: golang` hook; pre-commit builds glint from source on first run and re-runs on staged `.gitlab-ci.yml` changes
|
||||
- [x] **LSP server** — shipped v0.2.29; `glint lsp` runs a JSON-RPC 2.0 LSP server over stdin/stdout; `textDocument/didOpen`, `didChange`, `didSave`, `didClose` all publish diagnostics; rule IDs appear as the diagnostic `code`; include resolution is best-effort using env-var token and default cache dir
|
||||
- [ ] **VS Code extension** — thin wrapper around the LSP server with syntax highlighting for `.gitlab-ci.yml`
|
||||
|
||||
---
|
||||
|
||||
## Configuration — ✓ shipped v0.2.19
|
||||
## Configuration
|
||||
|
||||
- ~~**`.glint.yml` config file**~~ — ✓ shipped v0.2.19; `ignore:`, `severity:`, `stages:`, `token:`, `url:`, `cache_dir:`; searched from the pipeline directory up to the git root
|
||||
- ~~**Inline suppression comments**~~ — ✓ shipped v0.2.19; `# glint: ignore GL007` before a job definition; comma/space-separated rules; `# glint: ignore all` wildcard
|
||||
- [x] **`.glint.yml` config file** — shipped v0.2.19; `ignore:`, `severity:`, `stages:`, `token:`, `url:`, `cache_dir:`; searched from the pipeline directory up to the git root
|
||||
- [x] **Inline suppression comments** — shipped v0.2.19; `# glint: ignore GL007` before a job definition; comma/space-separated rules; `# glint: ignore all` wildcard
|
||||
|
||||
---
|
||||
|
||||
## Reliability and developer experience
|
||||
|
||||
- ~~**Structured rule IDs**~~ — ✓ shipped post-v0.2.0; GL001–GL031 assigned; GL032 added v0.2.11; GL033 added v0.2.15; GL034–GL041 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
|
||||
- ~~**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
|
||||
- [x] **Structured rule IDs** — shipped post-v0.2.0; GL001–GL031 assigned; GL032 added v0.2.11; GL033 added v0.2.15; GL034–GL041 added v0.2.16; output formats added v0.2.18; GL042–GL043 added v0.2.20; GL044 added v0.2.24; graph improvements shipped v0.2.25–v0.2.26
|
||||
- [x] **`glint explain <rule-id>`** — shipped v0.2.20; prints rule description, rationale, bad-YAML example, and fix; `glint explain` (no arg) lists all rules
|
||||
- [x] **Semantic versioning and first release** — shipped v0.1.0 (2026-06-07)
|
||||
- [x] **Subcommand CLI** — shipped v0.2.0 (2026-06-11); `glint check` / `glint graph [mode]` with ruff-style `--help`
|
||||
- [x] **Changelog automation** — shipped v0.2.27; `cliff.toml` configures git-cliff to produce Keep-a-Changelog–compatible release notes from Conventional Commits; `task changelog` regenerates `CHANGELOG.md`, `task changelog-next` previews unreleased entries
|
||||
- [x] **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)
|
||||
|
||||
@@ -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,23 @@ tasks:
|
||||
generates:
|
||||
- "{{.BINARY}}-{{.TAG}}-linux-amd64"
|
||||
|
||||
fuzz:
|
||||
desc: "Run all fuzz targets (set FUZZ_TIME=60s to control per-target duration, default 30s)"
|
||||
cmds:
|
||||
- "{{.GO}} test -fuzz=FuzzParseBytes -fuzztime=${FUZZ_TIME:-30s} ./internal/model/"
|
||||
- "{{.GO}} test -fuzz=FuzzSanitizeYAMLEscapes -fuzztime=${FUZZ_TIME:-30s} ./internal/model/"
|
||||
- "{{.GO}} test -fuzz=FuzzEvalIf -fuzztime=${FUZZ_TIME:-30s} ./internal/cicontext/"
|
||||
- "{{.GO}} test -fuzz=FuzzExpandVarRefs -fuzztime=${FUZZ_TIME:-30s} ./internal/cicontext/"
|
||||
- "{{.GO}} test -fuzz=FuzzLint -fuzztime=${FUZZ_TIME:-30s} ./internal/linter/"
|
||||
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
# GitHub Actions composite action — glint pipeline validator
|
||||
#
|
||||
# To use this action, mirror this repository to GitHub as k3nny/glint, then:
|
||||
#
|
||||
# - uses: k3nny/glint@v0.2.28
|
||||
# with:
|
||||
# file: .gitlab-ci.yml # optional
|
||||
#
|
||||
# Alternatively, copy this file into your own repository and reference it
|
||||
# as a local action:
|
||||
#
|
||||
# - uses: ./.github/actions/glint
|
||||
|
||||
name: 'glint'
|
||||
description: 'Validate a GitLab CI pipeline file with glint'
|
||||
author: 'k3nny'
|
||||
|
||||
branding:
|
||||
icon: 'check-circle'
|
||||
color: 'orange'
|
||||
|
||||
inputs:
|
||||
version:
|
||||
description: >-
|
||||
glint release tag to install (e.g. 'v0.2.28'). Defaults to 'latest'
|
||||
which resolves to the newest published release.
|
||||
required: false
|
||||
default: 'latest'
|
||||
file:
|
||||
description: 'Path to the pipeline file to validate.'
|
||||
required: false
|
||||
default: '.gitlab-ci.yml'
|
||||
args:
|
||||
description: 'Additional arguments passed to glint check (e.g. --format sarif).'
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Install glint
|
||||
shell: bash
|
||||
env:
|
||||
GLINT_VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "$GLINT_VERSION" = "latest" ]; then
|
||||
GLINT_VERSION=$(curl -sf \
|
||||
"https://git.k3nny.fr/api/v1/repos/k3nny/glint/releases?limit=1" \
|
||||
| grep '"tag_name"' | head -1 | cut -d'"' -f4)
|
||||
fi
|
||||
DEST="$RUNNER_TEMP/glint-bin"
|
||||
mkdir -p "$DEST"
|
||||
URL="https://git.k3nny.fr/k3nny/glint/releases/download/${GLINT_VERSION}/glint-${GLINT_VERSION}-linux-amd64"
|
||||
curl -sfL "$URL" -o "$DEST/glint"
|
||||
chmod +x "$DEST/glint"
|
||||
echo "$DEST" >> "$GITHUB_PATH"
|
||||
"$DEST/glint" --version
|
||||
|
||||
- name: Run glint check
|
||||
shell: bash
|
||||
run: glint check ${{ inputs.args }} "${{ inputs.file }}"
|
||||
+65
@@ -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"
|
||||
@@ -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.")
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"git.k3nny.fr/glint/internal/fetcher"
|
||||
"git.k3nny.fr/glint/internal/lsp"
|
||||
)
|
||||
|
||||
func cmdLSP(args []string) {
|
||||
fs := flag.NewFlagSet("glint lsp", flag.ExitOnError)
|
||||
token := fs.String("token", "", "GitLab personal access token (overrides GITLAB_TOKEN)")
|
||||
gitlabURL := fs.String("gitlab-url", "", "GitLab instance URL (overrides CI_SERVER_URL / GITLAB_URL)")
|
||||
cacheDir := fs.String("cache-dir", "", "directory for caching fetched remote includes")
|
||||
offline := fs.Bool("offline", false, "skip all network calls; serve only from --cache-dir")
|
||||
fs.Usage = func() {
|
||||
fmt.Fprintf(os.Stderr, "glint %s\n\n", version)
|
||||
fmt.Fprint(os.Stderr, `Start a Language Server Protocol server for .gitlab-ci.yml files.
|
||||
|
||||
Reads JSON-RPC 2.0 messages from stdin and writes responses to stdout using
|
||||
the standard Content-Length framing. Connect with any LSP client (VS Code,
|
||||
Neovim, Emacs, etc.).
|
||||
|
||||
Usage: glint lsp [OPTIONS]
|
||||
|
||||
Options:
|
||||
--token <TOKEN>
|
||||
GitLab personal access token used for resolving project: and
|
||||
component: includes. Defaults to GITLAB_TOKEN env var.
|
||||
|
||||
--gitlab-url <URL>
|
||||
GitLab instance URL for resolving remote includes.
|
||||
[env: CI_SERVER_URL | GITLAB_URL] [default: https://gitlab.com]
|
||||
|
||||
--cache-dir <DIR>
|
||||
Cache directory for fetched remote includes. Defaults to
|
||||
~/.cache/glint so subsequent opens are served from cache.
|
||||
|
||||
--offline
|
||||
Do not make any network calls; resolve only local includes.
|
||||
Implies --cache-dir default (~/.cache/glint) when not set.
|
||||
|
||||
-h, --help
|
||||
Print help
|
||||
|
||||
Examples:
|
||||
glint lsp
|
||||
glint lsp --token glpat-xxxx --cache-dir ~/.cache/glint
|
||||
glint lsp --offline
|
||||
`)
|
||||
}
|
||||
_ = fs.Parse(args)
|
||||
|
||||
resolvedCacheDir := *cacheDir
|
||||
if resolvedCacheDir == "" {
|
||||
resolvedCacheDir = defaultCacheDir()
|
||||
}
|
||||
if *offline && resolvedCacheDir == "" {
|
||||
resolvedCacheDir = defaultCacheDir()
|
||||
}
|
||||
|
||||
cfg := fetcher.AutoConfig().WithOverrides(*gitlabURL, *token, resolvedCacheDir, *offline)
|
||||
|
||||
srv := lsp.New(os.Stdin, os.Stdout, cfg, version)
|
||||
if err := srv.Run(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "glint lsp: %v\n", err)
|
||||
exit(2)
|
||||
}
|
||||
}
|
||||
+338
-20
@@ -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,8 @@ 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)
|
||||
lsp Start a Language Server Protocol server (stdin/stdout)
|
||||
|
||||
Options:
|
||||
-h, --help Print help
|
||||
@@ -50,13 +81,18 @@ 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 "lsp":
|
||||
cmdLSP(os.Args[2:])
|
||||
case "-h", "--help", "help":
|
||||
fmt.Fprintf(os.Stderr, "glint %s\n\n", version)
|
||||
fmt.Fprint(os.Stderr, globalUsage)
|
||||
@@ -64,7 +100,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 +126,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 +185,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 +231,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 +250,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 +292,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,17 +317,73 @@ 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)
|
||||
}
|
||||
|
||||
// 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{}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -270,8 +393,9 @@ Examples:
|
||||
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 +427,7 @@ Examples:
|
||||
}
|
||||
|
||||
if errCount > 0 {
|
||||
os.Exit(1)
|
||||
exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -324,7 +448,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 +466,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 +502,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 +527,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 +542,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 +555,8 @@ Examples:
|
||||
|
||||
if fs.NArg() != 1 {
|
||||
fs.Usage()
|
||||
os.Exit(2)
|
||||
exit(2)
|
||||
return
|
||||
}
|
||||
path := fs.Arg(0)
|
||||
|
||||
@@ -422,7 +570,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 +579,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 +617,33 @@ Examples:
|
||||
case "includes":
|
||||
fmt.Print(graph.Includes(path, p.Include, cfg))
|
||||
case "pipeline":
|
||||
outPath, err := graph.RenderPipeline(p, *out)
|
||||
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)
|
||||
os.Exit(2)
|
||||
exit(2)
|
||||
return
|
||||
}
|
||||
fmt.Println(outPath)
|
||||
case "all":
|
||||
fmt.Print(graph.Includes(path, p.Include, cfg))
|
||||
outPath, err := graph.RenderPipeline(p, *out)
|
||||
default: // "svg"
|
||||
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.Println(outPath)
|
||||
}
|
||||
case "all":
|
||||
fmt.Print(graph.Includes(path, p.Include, cfg))
|
||||
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.Fprintln(os.Stderr, outPath)
|
||||
}
|
||||
@@ -570,3 +755,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
@@ -2,14 +2,15 @@ module git.k3nny.fr/glint
|
||||
|
||||
go 1.26.4
|
||||
|
||||
require gopkg.in/yaml.v3 v3.0.1
|
||||
|
||||
require (
|
||||
github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c // indirect
|
||||
golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678 // indirect
|
||||
golang.org/x/mod v0.31.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/tools v0.40.1-0.20260108161641-ca281cf95054 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
honnef.co/go/tools v0.7.0 // indirect
|
||||
golang.org/x/mod v0.35.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/tools v0.44.1-0.20260420230617-19499e7caabc // indirect
|
||||
honnef.co/go/tools v0.8.0-rc.1 // indirect
|
||||
)
|
||||
|
||||
tool honnef.co/go/tools/cmd/staticcheck
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c h1:pxW6RcqyfI9/kWtOwnv/G+AzdKuy2ZrqINhenH4HyNs=
|
||||
github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa h1:FRnLl4eNAQl8hwxVVC17teOw8kdjVDVAiFMtgUdTSRQ=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678 h1:1P7xPZEwZMoBoz0Yze5Nx2/4pxj6nw9ZqHWXqP0iRgQ=
|
||||
golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
|
||||
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
|
||||
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/tools v0.40.1-0.20260108161641-ca281cf95054 h1:CHVDrNHx9ZoOrNN9kKWYIbT5Rj+WF2rlwPkhbQQ5V4U=
|
||||
golang.org/x/tools v0.40.1-0.20260108161641-ca281cf95054/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
|
||||
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
|
||||
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/tools v0.44.1-0.20260420230617-19499e7caabc h1:vSv/HN1q9eoPD7lMyJYVJ/GPYnqtqu6adMxUmrxOB78=
|
||||
golang.org/x/tools v0.44.1-0.20260420230617-19499e7caabc/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
|
||||
golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM=
|
||||
golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
honnef.co/go/tools v0.7.0 h1:w6WUp1VbkqPEgLz4rkBzH/CSU6HkoqNLp6GstyTx3lU=
|
||||
honnef.co/go/tools v0.7.0/go.mod h1:pm29oPxeP3P82ISxZDgIYeOaf9ta6Pi0EWvCFoLG2vc=
|
||||
honnef.co/go/tools v0.8.0-rc.1 h1:wqMm2kjcEXMOr+6yau+pdKqJKe6l2N1aKPkpini+Kzk=
|
||||
honnef.co/go/tools v0.8.0-rc.1/go.mod h1:XA+OnlRA9EDh/ukGvXMNSZNKGwFQJ+5dER0ioUkOxks=
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
type Context struct {
|
||||
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, ", ")
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package cicontext
|
||||
|
||||
import "testing"
|
||||
|
||||
// FuzzEvalIf ensures the rules:if: expression evaluator never panics on
|
||||
// arbitrary input. It accepts two strings: the expression and a variable value
|
||||
// substituted for every variable reference encountered.
|
||||
// Run with: go test -fuzz=FuzzEvalIf ./internal/cicontext/
|
||||
func FuzzEvalIf(f *testing.F) {
|
||||
// Seed corpus: representative expressions exercising every code path in
|
||||
// the hand-rolled recursive-descent parser.
|
||||
seeds := []struct{ expr, varVal string }{
|
||||
// Simple comparisons
|
||||
{`$VAR == "main"`, "main"},
|
||||
{`$VAR != "main"`, "main"},
|
||||
{`$VAR == null`, ""},
|
||||
{`$VAR != null`, "x"},
|
||||
// Regex operators
|
||||
{`$VAR =~ /^v\d+\.\d+/`, "v1.2.3"},
|
||||
{`$VAR !~ /^v\d+\.\d+/`, "not-a-version"},
|
||||
{`$VAR =~ /^us\//`, "us/west"},
|
||||
// Boolean operators
|
||||
{`$A == "x" && $B == "y"`, "x"},
|
||||
{`$A == "x" || $B == "y"`, "z"},
|
||||
{`!($VAR == "main")`, "main"},
|
||||
// Nested parens
|
||||
{`($VAR == "a" || $VAR == "b") && $VAR != "c"`, "a"},
|
||||
// Bare true / false
|
||||
{`$VAR == true`, "true"},
|
||||
{`$VAR == false`, "false"},
|
||||
// Integer comparison (GitLab CI compares as strings)
|
||||
{`$VAR == 42`, "42"},
|
||||
// Regex with flags
|
||||
{`$VAR =~ /main/i`, "MAIN"},
|
||||
// Syntax errors / incomplete expressions
|
||||
{``, ""},
|
||||
{`&&`, ""},
|
||||
{`$VAR =~`, "x"},
|
||||
{`($VAR`, "x"},
|
||||
{`$VAR == `, "x"},
|
||||
// Variable syntax variants
|
||||
{`${VAR} == "main"`, "main"},
|
||||
// Deeply nested
|
||||
{`((($VAR == "a")))`, "a"},
|
||||
// String with escapes
|
||||
{`$VAR == "hello\nworld"`, "hello\nworld"},
|
||||
// Null literal
|
||||
{`null == null`, ""},
|
||||
{`$VAR == null`, ""},
|
||||
}
|
||||
for _, s := range seeds {
|
||||
f.Add(s.expr, s.varVal)
|
||||
}
|
||||
|
||||
f.Fuzz(func(t *testing.T, expr, varVal string) {
|
||||
// EvalIf must never panic; it may return any bool.
|
||||
_ = EvalIf(expr, func(string) string { return varVal })
|
||||
_ = EvalIfStrict(expr, func(string) string { return varVal })
|
||||
})
|
||||
}
|
||||
|
||||
// FuzzExpandVarRefs ensures variable expansion in expression strings never
|
||||
// panics and never produces a longer output than the worst-case expansion bound.
|
||||
func FuzzExpandVarRefs(f *testing.F) {
|
||||
seeds := []struct{ s, val string }{
|
||||
{"$VAR", "hello"},
|
||||
{"${VAR}", "hello"},
|
||||
{"$A $B $C", "x"},
|
||||
{"no vars here", ""},
|
||||
{"$$double", "x"},
|
||||
{"$", "x"},
|
||||
{"${", "x"},
|
||||
{"${}", "x"},
|
||||
{"prefix_$VAR_suffix", "mid"},
|
||||
{"$1INVALID", "x"},
|
||||
}
|
||||
for _, s := range seeds {
|
||||
f.Add(s.s, s.val)
|
||||
}
|
||||
|
||||
f.Fuzz(func(t *testing.T, s, val string) {
|
||||
vars := map[string]string{"VAR": val, "A": val, "B": val, "C": val}
|
||||
_ = expandVarRefs(s, vars)
|
||||
})
|
||||
}
|
||||
@@ -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'")
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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") }
|
||||
}
|
||||
@@ -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#hash"},
|
||||
{`"quoted"#both`, "'quoted'#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{}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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") }
|
||||
}
|
||||
+506
-89
@@ -10,6 +10,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.k3nny.fr/glint/internal/cicontext"
|
||||
"git.k3nny.fr/glint/internal/model"
|
||||
)
|
||||
|
||||
@@ -18,29 +19,129 @@ 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
|
||||
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
|
||||
// `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})
|
||||
}
|
||||
if bin, err := exec.LookPath(c.args[0]); err == nil {
|
||||
if exec.Command(bin, c.args[1:]...).Run() == nil {
|
||||
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.
|
||||
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, y2-4, x2, y2, x2-7, y2+4, connStroke)
|
||||
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
|
||||
@@ -309,14 +529,20 @@ func drawChipIcon(sb *strings.Builder, job model.Job, cx, cy int) {
|
||||
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.
|
||||
// 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",
|
||||
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.
|
||||
// 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, "&", "&")
|
||||
s = strings.ReplaceAll(s, "<", "<")
|
||||
@@ -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">✕</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()
|
||||
}
|
||||
|
||||
@@ -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&b"},
|
||||
{"<tag>", "<tag>"},
|
||||
{"a&b<c>d", "a&b<c>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") }
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 2–200
|
||||
script: echo hi`,
|
||||
Fix: `my-job:
|
||||
parallel: 5
|
||||
script: echo hi`,
|
||||
},
|
||||
|
||||
RuleInvalidRetry: {
|
||||
Title: "'retry:' value invalid",
|
||||
Severity: Error,
|
||||
Description: "'retry:' accepts an integer 0–2 or a map with optional 'max:' " +
|
||||
"(0–2) 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]`,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package linter
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.k3nny.fr/glint/internal/model"
|
||||
)
|
||||
|
||||
// FuzzLint ensures that the full Parse → Lint pipeline never panics on
|
||||
// arbitrary YAML input. Lint rules type-assert model fields extensively;
|
||||
// this fuzzer drives those assertions against malformed-but-parseable YAML.
|
||||
// Run with: go test -fuzz=FuzzLint ./internal/linter/
|
||||
func FuzzLint(f *testing.F) {
|
||||
seeds := []string{
|
||||
// Minimal valid pipeline
|
||||
"stages: [build]\njob:\n stage: build\n script: echo ok\n",
|
||||
// Manual / delayed / trigger / on_failure job types
|
||||
"job:\n script: ok\n when: manual\n",
|
||||
"job:\n script: ok\n when: delayed\n start_in: 5 minutes\n",
|
||||
"job:\n trigger:\n project: group/repo\n",
|
||||
"job:\n script: ok\n when: on_failure\n",
|
||||
// needs: and dependencies:
|
||||
"stages: [a,b]\na:\n stage: a\n script: ok\nb:\n stage: b\n needs: [a]\n script: ok\n",
|
||||
"stages: [a,b]\na:\n stage: a\n script: ok\nb:\n stage: b\n dependencies: [a]\n script: ok\n",
|
||||
// rules:
|
||||
"job:\n script: ok\n rules:\n - if: '$CI_COMMIT_BRANCH == \"main\"'\n when: on_success\n",
|
||||
// parallel matrix
|
||||
"job:\n script: ok\n parallel:\n matrix:\n - ARCH: [amd64, arm64]\n",
|
||||
// image as string / map
|
||||
"job:\n script: ok\n image: golang:1.21\n",
|
||||
"job:\n script: ok\n image:\n name: golang:1.21\n entrypoint: ['']\n",
|
||||
// artifacts / cache with both string and map when:
|
||||
"job:\n script: ok\n artifacts:\n when: on_success\n paths: [dist/]\n",
|
||||
"job:\n script: ok\n cache:\n key: $CI_COMMIT_REF_SLUG\n paths: [vendor/]\n",
|
||||
// environment / release / coverage
|
||||
"job:\n script: ok\n environment:\n name: production\n url: https://example.com\n",
|
||||
"job:\n script: ok\n release:\n tag_name: $CI_COMMIT_TAG\n description: Release\n",
|
||||
"job:\n script: ok\n coverage: '/^TOTAL.*?(\\d+%)$/'\n",
|
||||
// retry / timeout
|
||||
"job:\n script: ok\n retry: 2\n",
|
||||
"job:\n script: ok\n timeout: 2h30m\n",
|
||||
// workflow
|
||||
"workflow:\n rules:\n - if: '$CI_COMMIT_BRANCH'\n when: always\njob:\n script: ok\n",
|
||||
// extends
|
||||
".base:\n script: ok\nchild:\n extends: .base\n stage: test\n",
|
||||
// id_tokens / secrets
|
||||
"job:\n script: ok\n id_tokens:\n TOKEN:\n aud: https://example.com\n",
|
||||
// services
|
||||
"job:\n script: ok\n services:\n - name: postgres:14\n alias: db\n",
|
||||
// pages job
|
||||
"pages:\n script: make docs\n artifacts:\n paths: [public/]\n",
|
||||
// inherit
|
||||
"default:\n retry: 1\njob:\n script: ok\n inherit:\n default: false\n",
|
||||
// allow_failure
|
||||
"job:\n script: ok\n allow_failure:\n exit_codes: [1, 2]\n",
|
||||
}
|
||||
for _, s := range seeds {
|
||||
f.Add([]byte(s))
|
||||
}
|
||||
|
||||
f.Fuzz(func(t *testing.T, data []byte) {
|
||||
p, err := model.ParseBytes(data)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
// Lint must never panic regardless of pipeline content.
|
||||
_ = Lint(p, nil)
|
||||
})
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
package lsp
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.k3nny.fr/glint/internal/fetcher"
|
||||
"git.k3nny.fr/glint/internal/linter"
|
||||
"git.k3nny.fr/glint/internal/model"
|
||||
"git.k3nny.fr/glint/internal/resolver"
|
||||
)
|
||||
|
||||
// Server is a minimal Language Server Protocol server that publishes glint
|
||||
// diagnostics for .gitlab-ci.yml files opened in an editor.
|
||||
//
|
||||
// Transport: JSON-RPC 2.0 over stdin/stdout with Content-Length framing.
|
||||
// Sync mode: Full — the client sends the complete document text on every change.
|
||||
type Server struct {
|
||||
in *bufio.Reader
|
||||
out io.Writer
|
||||
cfg fetcher.GitLabConfig
|
||||
version string
|
||||
docs map[string]string // uri → current document text
|
||||
|
||||
// Exit is called with the process exit code when the LSP client sends the
|
||||
// "exit" notification. Defaults to os.Exit; replace in tests.
|
||||
Exit func(int)
|
||||
|
||||
shutdownReceived bool
|
||||
}
|
||||
|
||||
// New creates a Server reading from r and writing to w.
|
||||
func New(r io.Reader, w io.Writer, cfg fetcher.GitLabConfig, version string) *Server {
|
||||
return &Server{
|
||||
in: bufio.NewReader(r),
|
||||
out: w,
|
||||
cfg: cfg,
|
||||
version: version,
|
||||
docs: make(map[string]string),
|
||||
Exit: os.Exit,
|
||||
}
|
||||
}
|
||||
|
||||
// Run processes LSP messages until the connection closes or a fatal error occurs.
|
||||
// It returns nil on a clean EOF (client disconnected) and a non-nil error for
|
||||
// unrecoverable protocol failures.
|
||||
func (s *Server) Run() error {
|
||||
for {
|
||||
msg, err := s.readMessage()
|
||||
if err == io.EOF {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading LSP message: %w", err)
|
||||
}
|
||||
if err := s.dispatch(msg); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// readMessage reads one Content-Length–framed JSON-RPC message from the stream.
|
||||
func (s *Server) readMessage() (*Message, error) {
|
||||
var contentLength int
|
||||
for {
|
||||
line, err := s.in.ReadString('\n')
|
||||
if err != nil {
|
||||
if err == io.EOF && line == "" {
|
||||
return nil, io.EOF
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
line = strings.TrimRight(line, "\r\n")
|
||||
if line == "" {
|
||||
break // blank line separates headers from body
|
||||
}
|
||||
if strings.HasPrefix(line, "Content-Length: ") {
|
||||
n, parseErr := strconv.Atoi(strings.TrimPrefix(line, "Content-Length: "))
|
||||
if parseErr != nil {
|
||||
return nil, fmt.Errorf("invalid Content-Length: %w", parseErr)
|
||||
}
|
||||
contentLength = n
|
||||
}
|
||||
}
|
||||
if contentLength == 0 {
|
||||
return nil, fmt.Errorf("missing or zero Content-Length header")
|
||||
}
|
||||
|
||||
body := make([]byte, contentLength)
|
||||
if _, err := io.ReadFull(s.in, body); err != nil {
|
||||
return nil, fmt.Errorf("reading message body: %w", err)
|
||||
}
|
||||
|
||||
var msg Message
|
||||
if err := json.Unmarshal(body, &msg); err != nil {
|
||||
return nil, fmt.Errorf("unmarshalling message: %w", err)
|
||||
}
|
||||
return &msg, nil
|
||||
}
|
||||
|
||||
// writeMessage encodes v as JSON and sends it with a Content-Length header.
|
||||
func (s *Server) writeMessage(v any) error {
|
||||
body, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
header := fmt.Sprintf("Content-Length: %d\r\n\r\n", len(body))
|
||||
if _, err := io.WriteString(s.out, header); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = s.out.Write(body)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Server) respond(id json.RawMessage, result any) error {
|
||||
raw, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.writeMessage(struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID json.RawMessage `json:"id"`
|
||||
Result json.RawMessage `json:"result"`
|
||||
}{"2.0", id, raw})
|
||||
}
|
||||
|
||||
func (s *Server) respondError(id json.RawMessage, code int, message string) error {
|
||||
return s.writeMessage(struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID json.RawMessage `json:"id"`
|
||||
Error RPCError `json:"error"`
|
||||
}{"2.0", id, RPCError{Code: code, Message: message}})
|
||||
}
|
||||
|
||||
func (s *Server) notify(method string, params any) error {
|
||||
raw, err := json.Marshal(params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.writeMessage(struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
Method string `json:"method"`
|
||||
Params json.RawMessage `json:"params"`
|
||||
}{"2.0", method, raw})
|
||||
}
|
||||
|
||||
// isRequest reports whether msg is a JSON-RPC request (has a non-null id).
|
||||
func isRequest(msg *Message) bool {
|
||||
return len(msg.ID) > 0 && string(msg.ID) != "null"
|
||||
}
|
||||
|
||||
func (s *Server) dispatch(msg *Message) error {
|
||||
switch msg.Method {
|
||||
case "initialize":
|
||||
return s.handleInitialize(msg)
|
||||
case "initialized":
|
||||
return nil // notification; no response required
|
||||
case "shutdown":
|
||||
s.shutdownReceived = true
|
||||
if isRequest(msg) {
|
||||
return s.respond(msg.ID, nil)
|
||||
}
|
||||
return nil
|
||||
case "exit":
|
||||
code := 1
|
||||
if s.shutdownReceived {
|
||||
code = 0
|
||||
}
|
||||
s.Exit(code)
|
||||
return nil
|
||||
case "textDocument/didOpen":
|
||||
return s.handleDidOpen(msg)
|
||||
case "textDocument/didChange":
|
||||
return s.handleDidChange(msg)
|
||||
case "textDocument/didSave":
|
||||
return s.handleDidSave(msg)
|
||||
case "textDocument/didClose":
|
||||
return s.handleDidClose(msg)
|
||||
default:
|
||||
if isRequest(msg) {
|
||||
return s.respondError(msg.ID, -32601, "method not found: "+msg.Method)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleInitialize(msg *Message) error {
|
||||
return s.respond(msg.ID, InitializeResult{
|
||||
Capabilities: ServerCapabilities{TextDocumentSync: 1},
|
||||
ServerInfo: &ServerInfo{Name: "glint", Version: s.version},
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleDidOpen(msg *Message) error {
|
||||
var p DidOpenTextDocumentParams
|
||||
if err := json.Unmarshal(msg.Params, &p); err != nil {
|
||||
return nil // ignore malformed notifications
|
||||
}
|
||||
s.docs[p.TextDocument.URI] = p.TextDocument.Text
|
||||
return s.lintAndPublish(p.TextDocument.URI, p.TextDocument.Text)
|
||||
}
|
||||
|
||||
func (s *Server) handleDidChange(msg *Message) error {
|
||||
var p DidChangeTextDocumentParams
|
||||
if err := json.Unmarshal(msg.Params, &p); err != nil {
|
||||
return nil
|
||||
}
|
||||
if len(p.ContentChanges) == 0 {
|
||||
return nil
|
||||
}
|
||||
// Full sync: the last change event holds the complete new text.
|
||||
text := p.ContentChanges[len(p.ContentChanges)-1].Text
|
||||
s.docs[p.TextDocument.URI] = text
|
||||
return s.lintAndPublish(p.TextDocument.URI, text)
|
||||
}
|
||||
|
||||
func (s *Server) handleDidSave(msg *Message) error {
|
||||
var p DidSaveTextDocumentParams
|
||||
if err := json.Unmarshal(msg.Params, &p); err != nil {
|
||||
return nil
|
||||
}
|
||||
text := s.docs[p.TextDocument.URI]
|
||||
if p.Text != nil {
|
||||
text = *p.Text
|
||||
s.docs[p.TextDocument.URI] = text
|
||||
}
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
return s.lintAndPublish(p.TextDocument.URI, text)
|
||||
}
|
||||
|
||||
func (s *Server) handleDidClose(msg *Message) error {
|
||||
var p DidCloseTextDocumentParams
|
||||
if err := json.Unmarshal(msg.Params, &p); err != nil {
|
||||
return nil
|
||||
}
|
||||
delete(s.docs, p.TextDocument.URI)
|
||||
// Clear diagnostics so the editor doesn't show stale squiggles.
|
||||
return s.notify("textDocument/publishDiagnostics", PublishDiagnosticsParams{
|
||||
URI: p.TextDocument.URI,
|
||||
Diagnostics: []Diagnostic{},
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) lintAndPublish(uri, text string) error {
|
||||
diags := s.lintDocument(uri, text)
|
||||
return s.notify("textDocument/publishDiagnostics", PublishDiagnosticsParams{
|
||||
URI: uri,
|
||||
Diagnostics: diags,
|
||||
})
|
||||
}
|
||||
|
||||
// lintDocument parses text and runs all lint rules, returning LSP Diagnostics.
|
||||
// Findings that originate from included files (not the root document) are
|
||||
// excluded; their URIs are not tracked so line numbers would be incorrect.
|
||||
func (s *Server) lintDocument(uri, text string) []Diagnostic {
|
||||
path := uriToPath(uri)
|
||||
if path == "" {
|
||||
return []Diagnostic{}
|
||||
}
|
||||
rootDir := filepath.Dir(filepath.Clean(path))
|
||||
|
||||
pipeline, err := model.ParseBytes([]byte(text))
|
||||
if err != nil {
|
||||
return []Diagnostic{{
|
||||
Range: Range{Start: Position{}, End: Position{}},
|
||||
Severity: 1,
|
||||
Source: "glint",
|
||||
Message: "YAML parse error: " + err.Error(),
|
||||
}}
|
||||
}
|
||||
pipeline.SourceFile = path
|
||||
pipeline.SetJobOrigin(path)
|
||||
|
||||
// Include resolution is best-effort: network failures produce warnings that
|
||||
// are intentionally discarded here. The linter operates on whatever was
|
||||
// successfully resolved.
|
||||
_, _ = resolver.ResolveIncludes(pipeline, s.cfg, rootDir)
|
||||
_, _ = resolver.Resolve(pipeline)
|
||||
|
||||
findings := linter.Lint(pipeline, nil)
|
||||
|
||||
diags := make([]Diagnostic, 0, len(findings))
|
||||
for _, f := range findings {
|
||||
// Skip findings from included files — their line numbers reference
|
||||
// a different document URI that the server has not opened.
|
||||
if f.File != path && f.File != "" {
|
||||
continue
|
||||
}
|
||||
line := 0
|
||||
if f.Line > 0 {
|
||||
line = f.Line - 1 // glint uses 1-based lines; LSP uses 0-based
|
||||
}
|
||||
sev := 1 // DiagnosticSeverity: Error
|
||||
if f.Severity == linter.Warning {
|
||||
sev = 2 // DiagnosticSeverity: Warning
|
||||
}
|
||||
msg := f.Message
|
||||
if f.Job != "" {
|
||||
msg = fmt.Sprintf("job %q: %s", f.Job, f.Message)
|
||||
}
|
||||
diags = append(diags, Diagnostic{
|
||||
Range: Range{
|
||||
Start: Position{Line: line},
|
||||
End: Position{Line: line},
|
||||
},
|
||||
Severity: sev,
|
||||
Code: f.Rule,
|
||||
Source: "glint",
|
||||
Message: msg,
|
||||
})
|
||||
}
|
||||
return diags
|
||||
}
|
||||
|
||||
// uriToPath converts a file:// URI to a local filesystem path.
|
||||
// Returns an empty string for non-file URIs or on parse error.
|
||||
func uriToPath(uri string) string {
|
||||
u, err := url.Parse(uri)
|
||||
if err != nil || u.Scheme != "file" {
|
||||
return ""
|
||||
}
|
||||
return filepath.FromSlash(u.Path)
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
package lsp
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.k3nny.fr/glint/internal/fetcher"
|
||||
)
|
||||
|
||||
// frame encodes v as a Content-Length–framed LSP message.
|
||||
func frame(t *testing.T, v any) []byte {
|
||||
t.Helper()
|
||||
body, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hdr := fmt.Sprintf("Content-Length: %d\r\n\r\n", len(body))
|
||||
return append([]byte(hdr), body...)
|
||||
}
|
||||
|
||||
// readMsg reads one Content-Length–framed JSON object from r.
|
||||
func readMsg(t *testing.T, r *bufio.Reader) map[string]json.RawMessage {
|
||||
t.Helper()
|
||||
var contentLength int
|
||||
for {
|
||||
line, err := r.ReadString('\n')
|
||||
if err != nil {
|
||||
t.Fatalf("reading header: %v", err)
|
||||
}
|
||||
line = strings.TrimRight(line, "\r\n")
|
||||
if line == "" {
|
||||
break
|
||||
}
|
||||
if strings.HasPrefix(line, "Content-Length: ") {
|
||||
n, err := strconv.Atoi(strings.TrimPrefix(line, "Content-Length: "))
|
||||
if err != nil {
|
||||
t.Fatalf("invalid Content-Length: %v", err)
|
||||
}
|
||||
contentLength = n
|
||||
}
|
||||
}
|
||||
body := make([]byte, contentLength)
|
||||
if _, err := io.ReadFull(r, body); err != nil {
|
||||
t.Fatalf("reading body: %v", err)
|
||||
}
|
||||
var m map[string]json.RawMessage
|
||||
if err := json.Unmarshal(body, &m); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// newTestServer returns a Server with a captured exit code and a bufio.Reader
|
||||
// wrapping the output buffer so tests can read back server messages.
|
||||
func newTestServer(input []byte) (*Server, *bytes.Buffer, *int) {
|
||||
var out bytes.Buffer
|
||||
exitCode := -1
|
||||
srv := New(bytes.NewReader(input), &out, fetcher.GitLabConfig{}, "test")
|
||||
srv.Exit = func(code int) { exitCode = code }
|
||||
return srv, &out, &exitCode
|
||||
}
|
||||
|
||||
func TestServer_Initialize(t *testing.T) {
|
||||
input := frame(t, map[string]any{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "initialize",
|
||||
"params": map[string]any{},
|
||||
})
|
||||
srv, out, _ := newTestServer(input)
|
||||
srv.Run() //nolint:errcheck
|
||||
|
||||
resp := readMsg(t, bufio.NewReader(out))
|
||||
if string(resp["id"]) != "1" {
|
||||
t.Errorf("response id = %s; want 1", resp["id"])
|
||||
}
|
||||
|
||||
var result InitializeResult
|
||||
if err := json.Unmarshal(resp["result"], &result); err != nil {
|
||||
t.Fatalf("unmarshal result: %v", err)
|
||||
}
|
||||
if result.Capabilities.TextDocumentSync != 1 {
|
||||
t.Errorf("textDocumentSync = %d; want 1", result.Capabilities.TextDocumentSync)
|
||||
}
|
||||
if result.ServerInfo == nil || result.ServerInfo.Name != "glint" {
|
||||
t.Errorf("serverInfo.name = %v; want glint", result.ServerInfo)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServer_ShutdownExit(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
buf.Write(frame(t, map[string]any{
|
||||
"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": map[string]any{},
|
||||
}))
|
||||
buf.Write(frame(t, map[string]any{
|
||||
"jsonrpc": "2.0", "method": "initialized", "params": map[string]any{},
|
||||
}))
|
||||
buf.Write(frame(t, map[string]any{
|
||||
"jsonrpc": "2.0", "id": 2, "method": "shutdown",
|
||||
}))
|
||||
buf.Write(frame(t, map[string]any{
|
||||
"jsonrpc": "2.0", "method": "exit",
|
||||
}))
|
||||
|
||||
srv, out, exitCode := newTestServer(buf.Bytes())
|
||||
srv.Run() //nolint:errcheck
|
||||
|
||||
r := bufio.NewReader(out)
|
||||
initResp := readMsg(t, r)
|
||||
if string(initResp["id"]) != "1" {
|
||||
t.Errorf("init response id = %s; want 1", initResp["id"])
|
||||
}
|
||||
shutResp := readMsg(t, r)
|
||||
if string(shutResp["id"]) != "2" {
|
||||
t.Errorf("shutdown response id = %s; want 2", shutResp["id"])
|
||||
}
|
||||
if string(shutResp["result"]) != "null" {
|
||||
t.Errorf("shutdown result = %s; want null", shutResp["result"])
|
||||
}
|
||||
if *exitCode != 0 {
|
||||
t.Errorf("exit code = %d; want 0", *exitCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServer_ExitWithoutShutdown(t *testing.T) {
|
||||
input := frame(t, map[string]any{
|
||||
"jsonrpc": "2.0", "method": "exit",
|
||||
})
|
||||
srv, _, exitCode := newTestServer(input)
|
||||
srv.Run() //nolint:errcheck
|
||||
|
||||
if *exitCode != 1 {
|
||||
t.Errorf("exit code = %d; want 1 (no prior shutdown)", *exitCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServer_MethodNotFound(t *testing.T) {
|
||||
input := frame(t, map[string]any{
|
||||
"jsonrpc": "2.0", "id": 99, "method": "workspace/unknownMethod",
|
||||
})
|
||||
srv, out, _ := newTestServer(input)
|
||||
srv.Run() //nolint:errcheck
|
||||
|
||||
resp := readMsg(t, bufio.NewReader(out))
|
||||
if resp["error"] == nil {
|
||||
t.Errorf("expected error response for unknown method, got: %v", resp)
|
||||
}
|
||||
var rpcErr RPCError
|
||||
if err := json.Unmarshal(resp["error"], &rpcErr); err != nil {
|
||||
t.Fatalf("unmarshal error: %v", err)
|
||||
}
|
||||
if rpcErr.Code != -32601 {
|
||||
t.Errorf("error code = %d; want -32601", rpcErr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServer_UnknownNotificationIgnored(t *testing.T) {
|
||||
// Notifications (no id) for unknown methods must be silently ignored.
|
||||
input := frame(t, map[string]any{
|
||||
"jsonrpc": "2.0", "method": "$/setTrace", "params": map[string]any{"value": "off"},
|
||||
})
|
||||
srv, out, _ := newTestServer(input)
|
||||
srv.Run() //nolint:errcheck
|
||||
|
||||
if out.Len() > 0 {
|
||||
t.Errorf("server wrote %d bytes for unknown notification; want 0", out.Len())
|
||||
}
|
||||
}
|
||||
|
||||
func TestServer_DidOpen_CleanPipeline(t *testing.T) {
|
||||
yaml := `stages: [build]
|
||||
|
||||
build-job:
|
||||
stage: build
|
||||
script: echo hello
|
||||
`
|
||||
// Use a pseudo file:// URI that maps to the tmp path; include resolution
|
||||
// will fail silently (no network, no local includes) which is fine.
|
||||
uri := "file:///tmp/test.gitlab-ci.yml"
|
||||
|
||||
input := frame(t, map[string]any{
|
||||
"jsonrpc": "2.0",
|
||||
"method": "textDocument/didOpen",
|
||||
"params": map[string]any{
|
||||
"textDocument": map[string]any{
|
||||
"uri": uri, "languageId": "yaml", "version": 1, "text": yaml,
|
||||
},
|
||||
},
|
||||
})
|
||||
srv, out, _ := newTestServer(input)
|
||||
srv.Run() //nolint:errcheck
|
||||
|
||||
notif := readMsg(t, bufio.NewReader(out))
|
||||
if string(notif["method"]) != `"textDocument/publishDiagnostics"` {
|
||||
t.Fatalf("method = %s; want textDocument/publishDiagnostics", notif["method"])
|
||||
}
|
||||
var params PublishDiagnosticsParams
|
||||
if err := json.Unmarshal(notif["params"], ¶ms); err != nil {
|
||||
t.Fatalf("unmarshal params: %v", err)
|
||||
}
|
||||
if params.URI != uri {
|
||||
t.Errorf("uri = %q; want %q", params.URI, uri)
|
||||
}
|
||||
// A clean pipeline should produce no diagnostics (or only warnings from
|
||||
// include resolution being skipped — but those are filtered since they
|
||||
// originate from a different file path).
|
||||
for _, d := range params.Diagnostics {
|
||||
if d.Severity == 1 {
|
||||
t.Errorf("unexpected error diagnostic: %s", d.Message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestServer_DidOpen_WithErrors(t *testing.T) {
|
||||
// A pipeline with a job in an undeclared stage triggers GL004.
|
||||
yaml := `stages: [build]
|
||||
|
||||
bad-job:
|
||||
stage: missing-stage
|
||||
script: echo hi
|
||||
`
|
||||
uri := "file:///tmp/bad.gitlab-ci.yml"
|
||||
input := frame(t, map[string]any{
|
||||
"jsonrpc": "2.0",
|
||||
"method": "textDocument/didOpen",
|
||||
"params": map[string]any{
|
||||
"textDocument": map[string]any{
|
||||
"uri": uri, "languageId": "yaml", "version": 1, "text": yaml,
|
||||
},
|
||||
},
|
||||
})
|
||||
srv, out, _ := newTestServer(input)
|
||||
srv.Run() //nolint:errcheck
|
||||
|
||||
notif := readMsg(t, bufio.NewReader(out))
|
||||
var params PublishDiagnosticsParams
|
||||
if err := json.Unmarshal(notif["params"], ¶ms); err != nil {
|
||||
t.Fatalf("unmarshal params: %v", err)
|
||||
}
|
||||
if len(params.Diagnostics) == 0 {
|
||||
t.Error("expected diagnostics for pipeline with unknown stage, got none")
|
||||
}
|
||||
found := false
|
||||
for _, d := range params.Diagnostics {
|
||||
if d.Code == "GL004" {
|
||||
found = true
|
||||
if d.Severity != 1 {
|
||||
t.Errorf("GL004 severity = %d; want 1 (Error)", d.Severity)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("expected GL004 diagnostic, got: %v", params.Diagnostics)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServer_DidOpen_ParseError(t *testing.T) {
|
||||
uri := "file:///tmp/broken.gitlab-ci.yml"
|
||||
input := frame(t, map[string]any{
|
||||
"jsonrpc": "2.0",
|
||||
"method": "textDocument/didOpen",
|
||||
"params": map[string]any{
|
||||
"textDocument": map[string]any{
|
||||
"uri": uri, "languageId": "yaml", "version": 1,
|
||||
"text": "?", // bare ? yields empty job name → parse error
|
||||
},
|
||||
},
|
||||
})
|
||||
srv, out, _ := newTestServer(input)
|
||||
srv.Run() //nolint:errcheck
|
||||
|
||||
notif := readMsg(t, bufio.NewReader(out))
|
||||
var params PublishDiagnosticsParams
|
||||
if err := json.Unmarshal(notif["params"], ¶ms); err != nil {
|
||||
t.Fatalf("unmarshal params: %v", err)
|
||||
}
|
||||
if len(params.Diagnostics) == 0 {
|
||||
t.Fatal("expected parse-error diagnostic, got none")
|
||||
}
|
||||
d := params.Diagnostics[0]
|
||||
if d.Severity != 1 {
|
||||
t.Errorf("severity = %d; want 1 (Error)", d.Severity)
|
||||
}
|
||||
if !strings.Contains(d.Message, "YAML parse error") {
|
||||
t.Errorf("message = %q; want YAML parse error", d.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServer_DidChange(t *testing.T) {
|
||||
uri := "file:///tmp/ci.gitlab-ci.yml"
|
||||
var buf bytes.Buffer
|
||||
// Open with clean content.
|
||||
buf.Write(frame(t, map[string]any{
|
||||
"jsonrpc": "2.0", "method": "textDocument/didOpen",
|
||||
"params": map[string]any{"textDocument": map[string]any{
|
||||
"uri": uri, "languageId": "yaml", "version": 1,
|
||||
"text": "stages: [build]\nbuild: {stage: build, script: echo}\n",
|
||||
}},
|
||||
}))
|
||||
// Change to content with an error.
|
||||
buf.Write(frame(t, map[string]any{
|
||||
"jsonrpc": "2.0", "method": "textDocument/didChange",
|
||||
"params": map[string]any{
|
||||
"textDocument": map[string]any{"uri": uri, "version": 2},
|
||||
"contentChanges": []map[string]any{
|
||||
{"text": "stages: [build]\nbad: {stage: gone, script: hi}\n"},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
srv, out, _ := newTestServer(buf.Bytes())
|
||||
srv.Run() //nolint:errcheck
|
||||
|
||||
r := bufio.NewReader(out)
|
||||
_ = readMsg(t, r) // first publishDiagnostics (clean)
|
||||
second := readMsg(t, r)
|
||||
|
||||
var params PublishDiagnosticsParams
|
||||
if err := json.Unmarshal(second["params"], ¶ms); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if len(params.Diagnostics) == 0 {
|
||||
t.Error("expected diagnostics after change to broken content, got none")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServer_DidClose_ClearsdiAgnostics(t *testing.T) {
|
||||
uri := "file:///tmp/toclose.gitlab-ci.yml"
|
||||
var buf bytes.Buffer
|
||||
buf.Write(frame(t, map[string]any{
|
||||
"jsonrpc": "2.0", "method": "textDocument/didOpen",
|
||||
"params": map[string]any{"textDocument": map[string]any{
|
||||
"uri": uri, "languageId": "yaml", "version": 1,
|
||||
"text": "stages: [build]\nj: {stage: build, script: echo}\n",
|
||||
}},
|
||||
}))
|
||||
buf.Write(frame(t, map[string]any{
|
||||
"jsonrpc": "2.0", "method": "textDocument/didClose",
|
||||
"params": map[string]any{"textDocument": map[string]any{"uri": uri}},
|
||||
}))
|
||||
|
||||
srv, out, _ := newTestServer(buf.Bytes())
|
||||
srv.Run() //nolint:errcheck
|
||||
|
||||
r := bufio.NewReader(out)
|
||||
_ = readMsg(t, r) // publishDiagnostics from didOpen
|
||||
|
||||
closeNotif := readMsg(t, r)
|
||||
var params PublishDiagnosticsParams
|
||||
if err := json.Unmarshal(closeNotif["params"], ¶ms); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if params.URI != uri {
|
||||
t.Errorf("uri = %q; want %q", params.URI, uri)
|
||||
}
|
||||
if len(params.Diagnostics) != 0 {
|
||||
t.Errorf("expected empty diagnostics on close, got %v", params.Diagnostics)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServer_UriToPath(t *testing.T) {
|
||||
tests := []struct {
|
||||
uri string
|
||||
want string
|
||||
}{
|
||||
{"file:///tmp/ci.yml", "/tmp/ci.yml"},
|
||||
{"file:///home/user/project/.gitlab-ci.yml", "/home/user/project/.gitlab-ci.yml"},
|
||||
{"https://example.com/file.yml", ""},
|
||||
{"not-a-uri", ""},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
got := uriToPath(tc.uri)
|
||||
if got != tc.want {
|
||||
t.Errorf("uriToPath(%q) = %q; want %q", tc.uri, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
// Package lsp implements a minimal Language Server Protocol server for glint.
|
||||
package lsp
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// Message is a JSON-RPC 2.0 message (request, response, or notification).
|
||||
type Message struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID json.RawMessage `json:"id,omitempty"`
|
||||
Method string `json:"method,omitempty"`
|
||||
Params json.RawMessage `json:"params,omitempty"`
|
||||
Result json.RawMessage `json:"result,omitempty"`
|
||||
Error *RPCError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// RPCError is a JSON-RPC 2.0 error object.
|
||||
type RPCError struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// InitializeResult is the server's response to the initialize request.
|
||||
type InitializeResult struct {
|
||||
Capabilities ServerCapabilities `json:"capabilities"`
|
||||
ServerInfo *ServerInfo `json:"serverInfo,omitempty"`
|
||||
}
|
||||
|
||||
// ServerCapabilities advertises what the server supports.
|
||||
type ServerCapabilities struct {
|
||||
// TextDocumentSync: 1 = Full (send entire document on every change).
|
||||
TextDocumentSync int `json:"textDocumentSync"`
|
||||
}
|
||||
|
||||
// ServerInfo identifies the server to the client.
|
||||
type ServerInfo struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version,omitempty"`
|
||||
}
|
||||
|
||||
// TextDocumentItem is a text document opened by the client.
|
||||
type TextDocumentItem struct {
|
||||
URI string `json:"uri"`
|
||||
LanguageID string `json:"languageId"`
|
||||
Version int `json:"version"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// TextDocumentIdentifier references a text document by URI.
|
||||
type TextDocumentIdentifier struct {
|
||||
URI string `json:"uri"`
|
||||
}
|
||||
|
||||
// VersionedTextDocumentIdentifier includes a version number.
|
||||
type VersionedTextDocumentIdentifier struct {
|
||||
URI string `json:"uri"`
|
||||
Version int `json:"version"`
|
||||
}
|
||||
|
||||
// TextDocumentContentChangeEvent is a single content change event.
|
||||
// With Full sync the Text field contains the complete new document text.
|
||||
type TextDocumentContentChangeEvent struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// DidOpenTextDocumentParams is the params for textDocument/didOpen.
|
||||
type DidOpenTextDocumentParams struct {
|
||||
TextDocument TextDocumentItem `json:"textDocument"`
|
||||
}
|
||||
|
||||
// DidChangeTextDocumentParams is the params for textDocument/didChange.
|
||||
type DidChangeTextDocumentParams struct {
|
||||
TextDocument VersionedTextDocumentIdentifier `json:"textDocument"`
|
||||
ContentChanges []TextDocumentContentChangeEvent `json:"contentChanges"`
|
||||
}
|
||||
|
||||
// DidSaveTextDocumentParams is the params for textDocument/didSave.
|
||||
type DidSaveTextDocumentParams struct {
|
||||
TextDocument TextDocumentIdentifier `json:"textDocument"`
|
||||
Text *string `json:"text,omitempty"`
|
||||
}
|
||||
|
||||
// DidCloseTextDocumentParams is the params for textDocument/didClose.
|
||||
type DidCloseTextDocumentParams struct {
|
||||
TextDocument TextDocumentIdentifier `json:"textDocument"`
|
||||
}
|
||||
|
||||
// PublishDiagnosticsParams is the params for textDocument/publishDiagnostics.
|
||||
type PublishDiagnosticsParams struct {
|
||||
URI string `json:"uri"`
|
||||
Diagnostics []Diagnostic `json:"diagnostics"`
|
||||
}
|
||||
|
||||
// Diagnostic is a lint finding expressed in LSP terms.
|
||||
type Diagnostic struct {
|
||||
Range Range `json:"range"`
|
||||
Severity int `json:"severity"` // 1=Error, 2=Warning, 3=Information, 4=Hint
|
||||
Code string `json:"code,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// Range is a zero-based line/character range within a text document.
|
||||
type Range struct {
|
||||
Start Position `json:"start"`
|
||||
End Position `json:"end"`
|
||||
}
|
||||
|
||||
// Position is a zero-based line and character offset.
|
||||
type Position struct {
|
||||
Line int `json:"line"`
|
||||
Character int `json:"character"`
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
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"),
|
||||
[]byte("?"), // null/empty YAML key — must error, not produce an empty-named job
|
||||
}
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -57,6 +57,9 @@ func ParseBytes(data []byte) (*Pipeline, error) {
|
||||
keyNode := root.Content[i]
|
||||
valNode := root.Content[i+1]
|
||||
key := keyNode.Value
|
||||
if key == "" {
|
||||
return nil, fmt.Errorf("job name cannot be empty (null or missing YAML key)")
|
||||
}
|
||||
if ReservedKeys[key] {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
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)")
|
||||
}
|
||||
|
||||
// Null/empty YAML key (e.g. bare "?"): job name cannot be empty.
|
||||
_, err = ParseBytes([]byte("?"))
|
||||
if err == nil {
|
||||
t.Error("null key: expected error from ParseBytes (job name cannot be empty)")
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
go test fuzz v1
|
||||
[]byte("?")
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
# glint — GitLab CI/CD Catalog component
|
||||
#
|
||||
# Validates a pipeline file with glint before the rest of the pipeline runs.
|
||||
#
|
||||
# Usage (after publishing to a GitLab instance as a Catalog component):
|
||||
#
|
||||
# include:
|
||||
# - component: $CI_SERVER_FQDN/k3nny/glint/check@v0.2.28
|
||||
# inputs:
|
||||
# stage: validate # optional — see inputs below
|
||||
#
|
||||
# Or as a plain remote include (no Catalog required):
|
||||
#
|
||||
# include:
|
||||
# - remote: https://raw.githubusercontent.com/.../templates/check.yml
|
||||
#
|
||||
# Or copy this file into your repository and use a local include.
|
||||
|
||||
spec:
|
||||
inputs:
|
||||
stage:
|
||||
description: "Stage in which to run the glint validation job."
|
||||
default: validate
|
||||
pipeline_file:
|
||||
description: "Path to the pipeline file to validate."
|
||||
default: .gitlab-ci.yml
|
||||
version:
|
||||
description: >-
|
||||
glint release tag to download (e.g. 'v0.2.28'). Use 'latest' to
|
||||
always pull the newest release — not recommended for production
|
||||
pipelines since it may break on a new release.
|
||||
default: latest
|
||||
allow_failure:
|
||||
description: "Set to true to let the job fail without blocking the pipeline."
|
||||
default: false
|
||||
extra_args:
|
||||
description: "Additional arguments passed to 'glint check' (e.g. '--format sarif')."
|
||||
default: ""
|
||||
|
||||
---
|
||||
glint:check:
|
||||
stage: $[[ inputs.stage ]]
|
||||
image: alpine:3.19
|
||||
variables:
|
||||
GLINT_VERSION: "$[[ inputs.version ]]"
|
||||
GLINT_FILE: "$[[ inputs.pipeline_file ]]"
|
||||
GLINT_ARGS: "$[[ inputs.extra_args ]]"
|
||||
before_script:
|
||||
- apk add --no-cache curl
|
||||
- |
|
||||
if [ "$GLINT_VERSION" = "latest" ]; then
|
||||
GLINT_VERSION=$(curl -sf \
|
||||
"https://git.k3nny.fr/api/v1/repos/k3nny/glint/releases?limit=1" \
|
||||
| grep '"tag_name"' | head -1 | cut -d'"' -f4)
|
||||
fi
|
||||
URL="https://git.k3nny.fr/k3nny/glint/releases/download/${GLINT_VERSION}/glint-${GLINT_VERSION}-linux-amd64"
|
||||
curl -sfL "$URL" -o /usr/local/bin/glint
|
||||
chmod +x /usr/local/bin/glint
|
||||
glint --version
|
||||
script:
|
||||
- glint check $GLINT_ARGS "$GLINT_FILE"
|
||||
allow_failure: $[[ inputs.allow_failure ]]
|
||||
Vendored
+26
@@ -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
|
||||
Vendored
+20
@@ -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
|
||||
Vendored
+4
@@ -9,6 +9,10 @@ workflow:
|
||||
when: always
|
||||
- when: never
|
||||
|
||||
default:
|
||||
tags:
|
||||
- docker
|
||||
|
||||
variables:
|
||||
GO_VERSION: "1.26"
|
||||
|
||||
|
||||
Vendored
+15
@@ -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
|
||||
Vendored
+19
@@ -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
|
||||
Vendored
+48
@@ -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
|
||||
Reference in New Issue
Block a user