Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b21ef5c0bb | |||
| 04f17f8616 | |||
| 7f7e2bf77b | |||
| e13455a629 | |||
| b8e640de03 | |||
| 4ce7f86d4d |
@@ -5,6 +5,40 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||||
This project uses [Semantic Versioning](https://semver.org).
|
This project uses [Semantic Versioning](https://semver.org).
|
||||||
|
|
||||||
|
## [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
|
## [0.2.19] - 2026-06-14
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+267
@@ -0,0 +1,267 @@
|
|||||||
|
# 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 |
|
||||||
|
|
||||||
|
### 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 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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 |
|
||||||
|
|
||||||
|
In DAG pipelines (any job has `needs:`) the pipeline graph uses job-to-job
|
||||||
|
Bézier connectors instead of stage-to-stage lines.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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,23 @@
|
|||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache%202.0-blue.svg" alt="License"></a>
|
<a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache%202.0-blue.svg" alt="License"></a>
|
||||||
<a href="CHANGELOG.md"><img src="https://img.shields.io/badge/release-v0.2.19-blue.svg" alt="Release"></a>
|
<a href="CHANGELOG.md"><img src="https://img.shields.io/badge/release-v0.2.21-blue.svg" alt="Release"></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
> **Disclaimer:** This tool was built through iterative AI-assisted development with [Claude](https://claude.ai). It is experimental, incomplete, and not intended for production use. Coverage of GitLab CI keywords is best-effort and may lag behind GitLab's evolving spec. Use it at your own discretion — no correctness guarantees are made. Contributions and bug reports are welcome.
|
> **Disclaimer:** This tool was built through iterative AI-assisted development with [Claude](https://claude.ai). It is experimental, incomplete, and not intended for production use. Coverage of GitLab CI keywords is best-effort and may lag behind GitLab's evolving spec. Use it at your own discretion — no correctness guarantees are made. Contributions and bug reports are welcome.
|
||||||
|
|
||||||
A local tool to validate and lint `.gitlab-ci.yml` pipelines without needing a GitLab server.
|
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
|
- **Lints** — 43 rules covering pipeline structure, keyword constraints, `needs:`/`dependencies:` graphs, expression reachability, and deprecations (GL001–GL043); run `glint explain <ID>` for any rule
|
||||||
- **Stage validation** — every job's `stage` must be declared in `stages`
|
- **Resolves includes** — local files, HTTPS URLs, GitLab project templates, and CI/CD Catalog components, with offline cache support
|
||||||
- **`extends:` resolution** — resolves single and multi-level template inheritance before linting, so derived jobs are evaluated against their fully merged definition
|
- **Simulates context** — `--branch`, `--tag`, `--source` flags evaluate `rules:if:` and `only`/`except` to show which jobs would be active, manual, or skipped
|
||||||
- **`needs:` DAG validation** — checks that `needs:` references exist, respect stage ordering, and contain no circular dependencies
|
- **Multiple output formats** — `--format text` (default, ruff-style), `json`, `sarif` (GitHub Code Scanning / GitLab SAST), `junit`, `github` (PR annotations)
|
||||||
- **`dependencies:` validation** — checks that artifact dependency references exist and are in earlier stages
|
- **Project config** — `.glint.yml` for rule suppression, severity overrides, token/URL defaults; `# glint: ignore RULE` for per-job inline suppression
|
||||||
- **Keyword validation** — validates constraints on `when`, `parallel`, `retry`, `allow_failure`, `trigger`, `artifacts`, `cache`, `release`, `environment`, `coverage`, `rules`, and more
|
- **Graph visualization** — `glint graph` prints a terminal job tree; `glint graph pipeline` renders a GitLab CI-style SVG/PNG
|
||||||
- **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
|
|
||||||
|
|
||||||
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
|
## Requirements
|
||||||
|
|
||||||
@@ -78,394 +51,12 @@ glint [OPTIONS] <COMMAND>
|
|||||||
Commands:
|
Commands:
|
||||||
check Lint a pipeline file — exits 0 (clean) or 1 (errors found)
|
check Lint a pipeline file — exits 0 (clean) or 1 (errors found)
|
||||||
graph Visualise the pipeline as a job tree or Mermaid graph
|
graph Visualise the pipeline as a job tree or Mermaid graph
|
||||||
|
explain Print description and fix for a lint rule
|
||||||
```
|
```
|
||||||
|
|
||||||
Run `glint <command> --help` for command-specific options and examples.
|
Run `glint <command> --help` for all flags. See [USAGE.md](USAGE.md) for full
|
||||||
|
examples covering output formats, context simulation, remote includes, cache,
|
||||||
### `glint check`
|
graph modes, and project configuration.
|
||||||
|
|
||||||
```bash
|
|
||||||
glint check .gitlab-ci.yml
|
|
||||||
```
|
|
||||||
|
|
||||||
Exits `0` when no errors are found, `1` when at least one error is reported.
|
|
||||||
|
|
||||||
### Output formats
|
|
||||||
|
|
||||||
Pass `--format` to control the output. Plain text is the default.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Default: ruff-style text (human-readable)
|
|
||||||
glint check .gitlab-ci.yml
|
|
||||||
|
|
||||||
# JSON — stable schema, machine-readable
|
|
||||||
glint check --format json .gitlab-ci.yml
|
|
||||||
|
|
||||||
# SARIF 2.1.0 — GitHub Code Scanning / GitLab SAST
|
|
||||||
glint check --format sarif .gitlab-ci.yml > glint.sarif
|
|
||||||
|
|
||||||
# JUnit XML — CI test-report artifact (GitLab: artifacts:reports:junit)
|
|
||||||
glint check --format junit .gitlab-ci.yml > glint-junit.xml
|
|
||||||
|
|
||||||
# GitHub Actions annotations — inline PR diff comments
|
|
||||||
glint check --format github .gitlab-ci.yml
|
|
||||||
```
|
|
||||||
|
|
||||||
In structured formats (`json`, `sarif`, `junit`, `github`) the summary line
|
|
||||||
(`OK: … no issues found` or `N finding(s): M error(s)`) is written to stderr
|
|
||||||
so stdout contains only the machine-readable payload.
|
|
||||||
|
|
||||||
**JSON schema (`schema_version: 1`):**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"schema_version": 1,
|
|
||||||
"glint_version": "v0.2.18",
|
|
||||||
"pipeline": ".gitlab-ci.yml",
|
|
||||||
"findings": [
|
|
||||||
{"rule":"GL004","severity":"error","file":".gitlab-ci.yml","line":14,
|
|
||||||
"job":"deploy","message":"stage \"production\" is not defined in 'stages'"}
|
|
||||||
],
|
|
||||||
"summary": {"total": 1, "errors": 1, "warnings": 0}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**GitHub annotation lines:**
|
|
||||||
```
|
|
||||||
::error file=.gitlab-ci.yml,line=14,title=GL004::job "deploy": stage "production" is not defined in 'stages'
|
|
||||||
```
|
|
||||||
|
|
||||||
### Project configuration (`.glint.yml`)
|
|
||||||
|
|
||||||
Place a `.glint.yml` file next to your pipeline (or anywhere in the directory tree up to the repository root) to configure glint for that project. glint searches upward from the pipeline file's directory, stopping at the first `.git` boundary.
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# .glint.yml
|
|
||||||
|
|
||||||
# Suppress specific rules entirely.
|
|
||||||
ignore:
|
|
||||||
- GL007 # we still use only:/except:, migration in progress
|
|
||||||
- GL032 # lots of dynamic variables injected by CI
|
|
||||||
|
|
||||||
# Override the severity of specific rules.
|
|
||||||
severity:
|
|
||||||
GL004: warning # demote stage errors to warnings during a migration
|
|
||||||
GL035: error # promote absolute-path warning to error for this project
|
|
||||||
|
|
||||||
# Extra stages that are valid but not declared in the pipeline YAML itself
|
|
||||||
# (e.g. injected by an include template we can't edit).
|
|
||||||
stages:
|
|
||||||
- quality
|
|
||||||
- security
|
|
||||||
|
|
||||||
# Default token — overridden by --token flag and GITLAB_TOKEN env.
|
|
||||||
token: glpat-xxxx
|
|
||||||
|
|
||||||
# Default GitLab instance URL.
|
|
||||||
url: https://gitlab.example.com
|
|
||||||
|
|
||||||
# Default cache directory for fetched remote includes.
|
|
||||||
cache_dir: ~/.cache/glint
|
|
||||||
```
|
|
||||||
|
|
||||||
**Priority chain for token and URL:** `--token`/`--gitlab-url` flags > `.glint.yml` values > `GITLAB_TOKEN`/`CI_SERVER_URL` environment variables.
|
|
||||||
|
|
||||||
### Inline suppression (`# glint: ignore`)
|
|
||||||
|
|
||||||
Suppress a finding for a specific job by placing a `# glint: ignore RULE` comment immediately before the job definition:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# glint: ignore GL007
|
|
||||||
legacy-job:
|
|
||||||
stage: build
|
|
||||||
only:
|
|
||||||
- main
|
|
||||||
script: echo ok
|
|
||||||
|
|
||||||
# Multiple rules — comma- or space-separated:
|
|
||||||
# glint: ignore GL007, GL032
|
|
||||||
another-job:
|
|
||||||
stage: build
|
|
||||||
script: echo ok
|
|
||||||
|
|
||||||
# Suppress all rules for this job:
|
|
||||||
# glint: ignore all
|
|
||||||
noisy-job:
|
|
||||||
stage: build
|
|
||||||
script: echo ok
|
|
||||||
```
|
|
||||||
|
|
||||||
Inline suppressions are scoped to the single job they precede. They do not affect other jobs or pipeline-level findings. For project-wide suppression use `.glint.yml` `ignore:`.
|
|
||||||
|
|
||||||
### Remote project includes
|
|
||||||
|
|
||||||
Pipelines that include templates from other GitLab projects are supported.
|
|
||||||
Provide a token so `glint` can fetch them:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# personal access token (read_api scope)
|
|
||||||
GITLAB_TOKEN=glpat-xxxx glint check .gitlab-ci.yml
|
|
||||||
|
|
||||||
# CI/CD job token (when running inside a pipeline)
|
|
||||||
CI_JOB_TOKEN=$CI_JOB_TOKEN glint check .gitlab-ci.yml
|
|
||||||
|
|
||||||
# self-hosted GitLab
|
|
||||||
GITLAB_TOKEN=glpat-xxxx GITLAB_URL=https://gitlab.example.com glint check .gitlab-ci.yml
|
|
||||||
|
|
||||||
# or via flags
|
|
||||||
glint check --token glpat-xxxx --gitlab-url https://gitlab.example.com .gitlab-ci.yml
|
|
||||||
```
|
|
||||||
|
|
||||||
**Project includes** require a token; without one they are skipped with a
|
|
||||||
warning and the rest of the pipeline is linted as-is.
|
|
||||||
|
|
||||||
### Include cache and offline mode
|
|
||||||
|
|
||||||
Pass `--cache-dir` to cache fetched remote templates so repeated runs skip the network:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# First run: fetches and caches
|
|
||||||
glint check --cache-dir ~/.cache/glint .gitlab-ci.yml
|
|
||||||
|
|
||||||
# Subsequent runs: served from cache
|
|
||||||
glint check --cache-dir ~/.cache/glint .gitlab-ci.yml
|
|
||||||
|
|
||||||
# Fully offline (uses ~/.cache/glint automatically when --cache-dir is absent)
|
|
||||||
glint check --offline .gitlab-ci.yml
|
|
||||||
glint check --offline --cache-dir ~/.cache/glint .gitlab-ci.yml
|
|
||||||
```
|
|
||||||
|
|
||||||
Cache entries are keyed by SHA-256 of the full request URL/coordinates and stored as plain YAML files in the cache directory. There is currently no automatic expiry — delete the directory or individual entries to force a fresh fetch.
|
|
||||||
|
|
||||||
**Component includes** (`include: component: ...`) attempt the fetch
|
|
||||||
unauthenticated, so public [CI/CD Catalog](https://gitlab.com/explore/catalog)
|
|
||||||
components work without a token. A warning is emitted if the fetch fails.
|
|
||||||
|
|
||||||
Token resolution order (first non-empty wins):
|
|
||||||
|
|
||||||
| Source | Header used |
|
|
||||||
|--------|-------------|
|
|
||||||
| `--token` flag / `GITLAB_TOKEN` | `PRIVATE-TOKEN` |
|
|
||||||
| `CI_JOB_TOKEN` | `JOB-TOKEN` |
|
|
||||||
| `GITLAB_PRIVATE_TOKEN` | `PRIVATE-TOKEN` |
|
|
||||||
|
|
||||||
Instance URL resolution order: `--gitlab-url` flag → `CI_SERVER_URL` →
|
|
||||||
`GITLAB_URL` → `https://gitlab.com`
|
|
||||||
|
|
||||||
### Component reference format
|
|
||||||
|
|
||||||
```
|
|
||||||
<host>/<project-path>/<component-name>@<version>
|
|
||||||
|
|
||||||
gitlab.com/components/secret-detection/secret-detection@v0.1.0
|
|
||||||
gitlab.com/my-org/ci-catalog/lint@main
|
|
||||||
gitlab.example.com/platform/components/build@~latest
|
|
||||||
```
|
|
||||||
|
|
||||||
The component file is looked up in order:
|
|
||||||
1. `templates/<component-name>.yml` (single-file layout)
|
|
||||||
2. `templates/<component-name>/template.yml` (directory layout)
|
|
||||||
|
|
||||||
Component input parameters (`with:`) are not validated — they are resolved by
|
|
||||||
GitLab at runtime. Jobs in fetched components may use `$[[ inputs.xxx ]]`
|
|
||||||
placeholders in fields like `stage`; `glint` skips those fields rather
|
|
||||||
than producing false positive errors.
|
|
||||||
|
|
||||||
### `glint graph`
|
|
||||||
|
|
||||||
Visualise the pipeline. Without a mode word, prints a job tree and the include
|
|
||||||
dependency graph separated by `---`.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Default: job tree + include dependency graph
|
|
||||||
glint graph .gitlab-ci.yml
|
|
||||||
|
|
||||||
# Job tree only (stages → jobs, like the tree command)
|
|
||||||
glint graph tree .gitlab-ci.yml
|
|
||||||
|
|
||||||
# Include dependency graph → Mermaid flowchart to stdout
|
|
||||||
glint graph includes .gitlab-ci.yml > includes.mmd
|
|
||||||
|
|
||||||
# GitLab-like pipeline layout → PNG (or SVG fallback) written to --out dir
|
|
||||||
glint graph pipeline .gitlab-ci.yml
|
|
||||||
# prints the output file path, e.g.: glint-out/pipeline-20260607-143022.png
|
|
||||||
|
|
||||||
# Mermaid to stdout + pipeline file path to stderr
|
|
||||||
glint graph all .gitlab-ci.yml > includes.mmd
|
|
||||||
|
|
||||||
# Custom output directory (pipeline mode)
|
|
||||||
glint graph pipeline --out /tmp/graphs .gitlab-ci.yml
|
|
||||||
```
|
|
||||||
|
|
||||||
**Job tree** (`graph tree`) — stages as branches, jobs as leaves. Jobs with
|
|
||||||
`when: manual`, `when: delayed`, or `trigger:` are annotated in brackets.
|
|
||||||
|
|
||||||
**Include graph** (`graph includes`) — [Mermaid](https://mermaid.js.org) flowchart written to stdout.
|
|
||||||
Pipe to a `.mmd` file or paste into [mermaid.live](https://mermaid.live).
|
|
||||||
One node per include entry, colour-coded by type:
|
|
||||||
- Orange (bold): the main pipeline file
|
|
||||||
- Purple: `project:` includes
|
|
||||||
- Green: `component:` includes
|
|
||||||
- Blue: `local:` includes
|
|
||||||
- Grey: `remote:` URL includes
|
|
||||||
- Light orange: GitLab-provided `template:` includes
|
|
||||||
|
|
||||||
**Pipeline graph** (`graph pipeline`) — GitLab CI-style SVG rendered to a timestamped file
|
|
||||||
in the `--out` directory (default: `glint-out/`). Converted to PNG automatically
|
|
||||||
when `rsvg-convert`, `inkscape`, or `magick` is available; falls back to SVG otherwise.
|
|
||||||
Jobs are colour-coded by type:
|
|
||||||
- Blue (`#1f75cb`): regular jobs
|
|
||||||
- Orange (`#fc6d26`): `when: manual` jobs
|
|
||||||
- Purple (`#6b4fbb`): `trigger:` jobs
|
|
||||||
- Amber (`#fca326`): `when: delayed` jobs
|
|
||||||
|
|
||||||
DAG mode (job-to-job Bézier arrows) activates automatically when any job has a `needs:` list.
|
|
||||||
Classic mode draws L-shaped or straight connectors between stage columns otherwise.
|
|
||||||
|
|
||||||
### Context simulation
|
|
||||||
|
|
||||||
Pass `--branch`, `--tag`, or `--source` to `glint check` to see which jobs
|
|
||||||
would run for a given pipeline event. The pipeline is still fully linted;
|
|
||||||
context output is printed first.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# What runs on a push to develop?
|
|
||||||
glint check --branch develop .gitlab-ci.yml
|
|
||||||
|
|
||||||
# What runs when a v1.2.0 tag is pushed?
|
|
||||||
glint check --tag v1.2.0 .gitlab-ci.yml
|
|
||||||
|
|
||||||
# Merge request pipeline
|
|
||||||
glint check --source merge_request_event .gitlab-ci.yml
|
|
||||||
|
|
||||||
# Arbitrary variable overrides (repeatable)
|
|
||||||
glint check --branch main --var DEPLOY_ENV=production .gitlab-ci.yml
|
|
||||||
```
|
|
||||||
|
|
||||||
**Evaluated:**
|
|
||||||
- `rules:if:` — full expression language: `==`, `!=`, `=~`, `!~`, `&&`, `||`, `!`, `()`, `$VAR`, string literals, `null`
|
|
||||||
- `only:` / `except:` — ref keywords (`branches`, `tags`, `merge_requests`, `schedules`, …), branch name globs (`feat/*`), and `/regex/` patterns
|
|
||||||
- Variable expansion — `$VAR` / `${VAR}` references within variable values are expanded after all sources are merged; use `--list-vars` to inspect the resolved values
|
|
||||||
|
|
||||||
**Not evaluated** (no git tree at lint time): `rules:changes:`, `rules:exists:`.
|
|
||||||
Rules without an `if:` clause always match.
|
|
||||||
|
|
||||||
**Predefined variables** set automatically by the shortcut flags:
|
|
||||||
|
|
||||||
| Flag | Variables populated |
|
|
||||||
|------|---------------------|
|
|
||||||
| `--branch <name>` | `CI_COMMIT_BRANCH`, `CI_COMMIT_REF_NAME`, `CI_COMMIT_REF_SLUG`, `CI_PIPELINE_SOURCE=push` |
|
|
||||||
| `--tag <name>` | `CI_COMMIT_TAG`, `CI_COMMIT_REF_NAME`, `CI_COMMIT_REF_SLUG`, `CI_PIPELINE_SOURCE=push` (clears `CI_COMMIT_BRANCH`) |
|
|
||||||
| `--source <event>` | `CI_PIPELINE_SOURCE` |
|
|
||||||
| `--var KEY=VALUE` | any variable; overrides shortcuts |
|
|
||||||
|
|
||||||
### Example output
|
|
||||||
|
|
||||||
```
|
|
||||||
# Clean pipeline (implicit default: --branch main --source push)
|
|
||||||
Context: branch=main, source=push
|
|
||||||
|
|
||||||
Active (5): build, deploy-staging, test, ...
|
|
||||||
|
|
||||||
OK: .gitlab-ci.yml — no issues found (5 job(s), 3 stage(s))
|
|
||||||
|
|
||||||
# With --branch develop context
|
|
||||||
Context: branch=develop, source=push
|
|
||||||
|
|
||||||
Active (3): build, deploy-staging, test
|
|
||||||
Skipped (2): deploy-prod, release-notes
|
|
||||||
|
|
||||||
OK: .gitlab-ci.yml — no issues found (5 job(s), 3 stage(s))
|
|
||||||
|
|
||||||
# With --tag v1.0.0 context
|
|
||||||
Context: tag=v1.0.0, source=push
|
|
||||||
|
|
||||||
Active (4): build, deploy-prod, release-notes, test
|
|
||||||
Skipped (1): deploy-staging
|
|
||||||
|
|
||||||
OK: .gitlab-ci.yml — no issues found (5 job(s), 3 stage(s))
|
|
||||||
|
|
||||||
# Pipeline with issues
|
|
||||||
.gitlab-ci.yml:14: GL004 [error] job "deploy": stage "production" is not defined in 'stages'
|
|
||||||
.gitlab-ci.yml:22: GL027 [error] job "test": needs unknown job "build-app"
|
|
||||||
.gitlab-ci.yml:31: GL007 [warning] job "old-job": 'only'/'except' are deprecated; prefer 'rules'
|
|
||||||
|
|
||||||
3 finding(s): 2 error(s)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Lint rules
|
|
||||||
|
|
||||||
Every finding includes a stable rule ID (e.g. `GL003`) that can be used to filter output or reference a specific check in documentation.
|
|
||||||
|
|
||||||
### Pipeline-level
|
|
||||||
|
|
||||||
| ID | Severity | Rule |
|
|
||||||
|----|----------|------|
|
|
||||||
| GL002 | ERROR | `workflow.rules[*].when` is not `always` or `never` |
|
|
||||||
| GL001 | WARNING | No `stages` defined (GitLab falls back to default stages) |
|
|
||||||
| GL036 | ERROR | `default.timeout` is not a valid GitLab CI duration string |
|
|
||||||
| GL040 | WARNING | A stage name appears more than once in `stages:` |
|
|
||||||
|
|
||||||
### Job-level — structure
|
|
||||||
|
|
||||||
| ID | Severity | Rule |
|
|
||||||
|----|----------|------|
|
|
||||||
| GL003 | ERROR | Job is missing required `script` (or `run`) — non-trigger, non-template jobs |
|
|
||||||
| GL004 | ERROR | Job references a `stage` not declared in `stages` |
|
|
||||||
| GL005 | ERROR | `only` and `rules` used together on the same job |
|
|
||||||
| GL006 | ERROR | `except` and `rules` used together on the same job |
|
|
||||||
| GL007 | WARNING | `only`/`except` used (deprecated, prefer `rules`) |
|
|
||||||
|
|
||||||
### Job-level — keyword constraints
|
|
||||||
|
|
||||||
| ID | Severity | Rule |
|
|
||||||
|----|----------|------|
|
|
||||||
| GL008 | ERROR | `when` is not one of `on_success`, `on_failure`, `always`, `manual`, `delayed`, `never` |
|
|
||||||
| GL009 | ERROR | `when: delayed` without `start_in` |
|
|
||||||
| GL010 | ERROR | `start_in` set but `when` is not `delayed` |
|
|
||||||
| GL011 | ERROR | `parallel` integer not in range 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.
|
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
|
|||||||
+22
-50
@@ -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
|
- ~~**Single-context simulation**~~ — ✓ shipped v0.2.0; `--branch`, `--tag`, `--source`, `--var` flags on both subcommands; jobs classified as active / manual / skipped
|
||||||
# shipped: single-context simulation
|
- ~~**`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
|
||||||
glint check --branch develop .gitlab-ci.yml
|
- ~~**Expression evaluator: multi-line `if:` values**~~ — ✓ shipped post-v0.2.0; newlines in block-scalar and folded YAML `if:` values treated as whitespace
|
||||||
glint check --tag v1.2.0 .gitlab-ci.yml
|
- ~~**Expression evaluator: `${VAR}` syntax**~~ — ✓ shipped post-v0.2.0; `${CI_COMMIT_BRANCH}` equivalent to `$CI_COMMIT_BRANCH` everywhere
|
||||||
glint check --source merge_request_event --var CI_MERGE_REQUEST_TARGET_BRANCH_NAME=main .gitlab-ci.yml
|
- ~~**Expression evaluator: regex flags**~~ — ✓ shipped post-v0.2.0; `/pattern/i`, `/pattern/m`, `/pattern/s` supported
|
||||||
glint graph tree --branch main .gitlab-ci.yml # tree annotated with [skipped] / [manual]
|
- ~~**Expression evaluator: variable as regex RHS**~~ — ✓ shipped post-v0.2.0; `$BRANCH =~ $PATTERN` where `$PATTERN` holds a `/regex/` string evaluates correctly
|
||||||
```
|
- ~~**Expression evaluator: bare `true`/`false` and integer literals**~~ — ✓ shipped post-v0.2.0; `$FLAG == true`, `$COUNT == 4` compare as decimal strings matching GitLab CI behaviour
|
||||||
|
- ~~**Implicit default context**~~ — ✓ shipped v0.2.11; defaults to `--branch main --source push` when no context flag is given, so `rules:if:` is always evaluated
|
||||||
**Shipped post-v0.2.0 (unreleased)**
|
- ~~**`--list-vars` debug flag**~~ — ✓ shipped v0.2.11; prints sorted `KEY=VALUE` of all collected pipeline variables (root file + includes + workflow-rule union + effective context) to stderr
|
||||||
|
- ~~**Variable expansion**~~ — ✓ shipped v0.2.13; `$VAR`/`${VAR}` references within variable values expanded after all sources are merged; transitive chains resolved; visible in `--list-vars`
|
||||||
- ✓ **`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.
|
- ~~**Non-string scalar variables**~~ — ✓ shipped v0.2.13; `BUILD: true`, `RETRIES: 3` rendered and injected correctly instead of being silently dropped
|
||||||
- ✓ **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.
|
- ~~**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
|
||||||
- ✓ **Expression evaluator: `${VAR}` curly-brace syntax** — `${CI_COMMIT_BRANCH}` is equivalent to `$CI_COMMIT_BRANCH` everywhere.
|
- ~~**Workflow rule strict evaluation**~~ — ✓ shipped v0.2.14; unparseable `if:` skips the rule instead of matching everything; prevents wrong variables being injected
|
||||||
- ✓ **Expression evaluator: regex flags** — `/pattern/i`, `/pattern/m`, `/pattern/s` are now supported; `i` maps to `(?i)` in Go's regexp.
|
- ~~**Single `=` operator**~~ — ✓ shipped v0.2.14; bare `=` accepted as alias for `==` in `rules:if:` expressions
|
||||||
- ✓ **Expression evaluator: variable as regex RHS** — `$BRANCH =~ $PATTERN` where `$PATTERN` holds a `/regex/` string is evaluated correctly.
|
- ~~**`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
|
||||||
- ✓ **Expression evaluator: bare `true` / `false` keywords** — treated as the strings `"true"` / `"false"` matching GitLab CI's own behaviour; `$GATEWAY_ENABLED == true` now evaluates correctly.
|
- **Multi-context simulation** — run multiple contexts in one invocation and print a comparison table (`--context branch=main --context branch=develop --context tag=v1.0.0`)
|
||||||
- ✓ **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
|
- **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
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -70,7 +42,7 @@ The current rule set covers the most common sources of broken pipelines. These a
|
|||||||
- ~~**Duplicate stage names (GL040)**~~ — ✓ shipped v0.2.16; warns when a stage appears more than once in `stages:`
|
- ~~**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
|
- ~~**`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
|
- ~~**Unreachable jobs**~~ — covered by GL033 (shipped v0.2.15); every-`when:never` rules block is statically dead
|
||||||
- **`inherit:` completeness** — flag when a job overrides a default field that would require `inherit: default: false` to suppress
|
- ~~**`inherit:` completeness (GL043)**~~ — ✓ shipped v0.2.20; warns when `inherit: default:` is declared but there's no `default:` block, or list form names fields not set in `default:`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -118,7 +90,7 @@ The SVG renderer and terminal tree cover the basic layout. These would bring it
|
|||||||
|
|
||||||
- ~~**`needs: optional: true` false-positive errors**~~ — ✓ shipped post-v0.2.0; optional missing needs are downgraded to `[WARNING]`
|
- ~~**`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)
|
- ~~**`extends:` jobs with missing script false errors**~~ — ✓ shipped post-v0.2.0; jobs using `extends:` that have no `script` after resolution emit `[WARNING]` (the script may come from an unfetchable remote base)
|
||||||
- **`rules:if:` static reachability** — report when a job's entire `rules:` block can never evaluate to `when: on_success` given the declared pipeline variables (pure static, no context required)
|
- ~~**`rules:if:` static reachability (GL042)**~~ — ✓ shipped v0.2.20; warns when all `rules:if:` conditions evaluate to false given declared variable values (only fires when all referenced vars are declared in YAML)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -141,8 +113,8 @@ The SVG renderer and terminal tree cover the basic layout. These would bring it
|
|||||||
|
|
||||||
## Reliability and developer experience
|
## Reliability and developer experience
|
||||||
|
|
||||||
- ~~**Structured rule IDs**~~ — ✓ 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
|
- ~~**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; GL042–GL043 added v0.2.20
|
||||||
- **`--explain <rule-id>`** — print the rule description, rationale, and an example fix
|
- ~~**`glint explain <rule-id>`**~~ — ✓ shipped v0.2.20; prints rule description, rationale, bad-YAML example, and fix; `glint explain` (no arg) lists all rules
|
||||||
- ~~**Semantic versioning and first release**~~ — shipped as `v0.1.0` (2026-06-07)
|
- ~~**Semantic versioning and first release**~~ — shipped as `v0.1.0` (2026-06-07)
|
||||||
- ~~**Subcommand CLI**~~ — shipped as `v0.2.0` (2026-06-11); `glint check` / `glint graph [mode]` with ruff-style `--help`
|
- ~~**Subcommand CLI**~~ — shipped as `v0.2.0` (2026-06-11); `glint check` / `glint graph [mode]` with ruff-style `--help`
|
||||||
- **Changelog automation** — generate release notes from Conventional Commits via `git-cliff` or similar
|
- **Changelog automation** — generate release notes from Conventional Commits via `git-cliff` or similar
|
||||||
|
|||||||
@@ -107,6 +107,18 @@ tasks:
|
|||||||
ignore_error: false
|
ignore_error: false
|
||||||
- cmd: ./{{.BINARY}} check testdata/config_suppress/.gitlab-ci.yml
|
- cmd: ./{{.BINARY}} check testdata/config_suppress/.gitlab-ci.yml
|
||||||
ignore_error: false
|
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}} explain GL007
|
||||||
|
ignore_error: false
|
||||||
|
- cmd: ./{{.BINARY}} explain gl042
|
||||||
|
ignore_error: false
|
||||||
|
- cmd: ./{{.BINARY}} explain
|
||||||
|
ignore_error: false
|
||||||
|
|
||||||
lint-go:
|
lint-go:
|
||||||
desc: Run go vet on all packages
|
desc: Run go vet on all packages
|
||||||
|
|||||||
@@ -0,0 +1,314 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
# Mermaid to stdout + pipeline 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.
|
||||||
|
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.
|
||||||
@@ -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.")
|
||||||
|
}
|
||||||
+121
-12
@@ -4,6 +4,7 @@ import (
|
|||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -20,18 +21,46 @@ import (
|
|||||||
// version is set at build time via -ldflags "-X main.version=vX.Y.Z".
|
// version is set at build time via -ldflags "-X main.version=vX.Y.Z".
|
||||||
var version = "dev"
|
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:
|
// defaultCacheDir returns the platform-default glint cache directory:
|
||||||
// $XDG_CACHE_HOME/glint or ~/.cache/glint.
|
// $XDG_CACHE_HOME/glint or ~/.cache/glint.
|
||||||
func defaultCacheDir() string {
|
func defaultCacheDir() string {
|
||||||
if xdg := os.Getenv("XDG_CACHE_HOME"); xdg != "" {
|
if xdg := os.Getenv("XDG_CACHE_HOME"); xdg != "" {
|
||||||
return filepath.Join(xdg, "glint")
|
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 filepath.Join(home, ".cache", "glint")
|
||||||
}
|
}
|
||||||
return ""
|
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.
|
const globalUsage = `glint: Lint and visualise GitLab CI pipelines locally.
|
||||||
|
|
||||||
Usage: glint [OPTIONS] <COMMAND>
|
Usage: glint [OPTIONS] <COMMAND>
|
||||||
@@ -39,6 +68,7 @@ Usage: glint [OPTIONS] <COMMAND>
|
|||||||
Commands:
|
Commands:
|
||||||
check Lint a pipeline file — exits 0 (clean) or 1 (errors found)
|
check Lint a pipeline file — exits 0 (clean) or 1 (errors found)
|
||||||
graph Visualise the pipeline as a job tree or Mermaid graph
|
graph Visualise the pipeline as a job tree or Mermaid graph
|
||||||
|
explain Show description and fix for a lint rule (e.g. glint explain GL007)
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
-h, --help Print help
|
-h, --help Print help
|
||||||
@@ -50,13 +80,16 @@ For help with a specific command, see: ` + "`glint <command> --help`" + `.
|
|||||||
func main() {
|
func main() {
|
||||||
if len(os.Args) < 2 {
|
if len(os.Args) < 2 {
|
||||||
fmt.Fprint(os.Stderr, globalUsage)
|
fmt.Fprint(os.Stderr, globalUsage)
|
||||||
os.Exit(2)
|
exit(2)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
switch os.Args[1] {
|
switch os.Args[1] {
|
||||||
case "check":
|
case "check":
|
||||||
cmdCheck(os.Args[2:])
|
cmdCheck(os.Args[2:])
|
||||||
case "graph":
|
case "graph":
|
||||||
cmdGraph(os.Args[2:])
|
cmdGraph(os.Args[2:])
|
||||||
|
case "explain":
|
||||||
|
cmdExplain(os.Args[2:])
|
||||||
case "-h", "--help", "help":
|
case "-h", "--help", "help":
|
||||||
fmt.Fprintf(os.Stderr, "glint %s\n\n", version)
|
fmt.Fprintf(os.Stderr, "glint %s\n\n", version)
|
||||||
fmt.Fprint(os.Stderr, globalUsage)
|
fmt.Fprint(os.Stderr, globalUsage)
|
||||||
@@ -64,7 +97,7 @@ func main() {
|
|||||||
fmt.Printf("glint %s\n", version)
|
fmt.Printf("glint %s\n", version)
|
||||||
default:
|
default:
|
||||||
fmt.Fprintf(os.Stderr, "glint: unknown command %q\n\n%s", os.Args[1], globalUsage)
|
fmt.Fprintf(os.Stderr, "glint: unknown command %q\n\n%s", os.Args[1], globalUsage)
|
||||||
os.Exit(2)
|
exit(2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,6 +123,9 @@ func cmdCheck(args []string) {
|
|||||||
listVars := fs.Bool("list-vars", false, "print all collected pipeline variables (from root and included files) to stderr, then continue")
|
listVars := fs.Bool("list-vars", false, "print all collected pipeline variables (from root and included files) to stderr, then continue")
|
||||||
var vars multiFlag
|
var vars multiFlag
|
||||||
fs.Var(&vars, "var", "set a CI variable as KEY=VALUE; repeatable")
|
fs.Var(&vars, "var", "set a CI variable as KEY=VALUE; repeatable")
|
||||||
|
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.Usage = func() {
|
fs.Usage = func() {
|
||||||
fmt.Fprintf(os.Stderr, "glint %s\n\n", version)
|
fmt.Fprintf(os.Stderr, "glint %s\n\n", version)
|
||||||
fmt.Fprint(os.Stderr, `Lint a GitLab CI pipeline file.
|
fmt.Fprint(os.Stderr, `Lint a GitLab CI pipeline file.
|
||||||
@@ -144,6 +180,16 @@ Options:
|
|||||||
Set or override a CI variable. Takes precedence over --branch, --tag,
|
Set or override a CI variable. Takes precedence over --branch, --tag,
|
||||||
and --source. Repeatable.
|
and --source. Repeatable.
|
||||||
|
|
||||||
|
--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.
|
||||||
|
|
||||||
--list-vars
|
--list-vars
|
||||||
Print all pipeline-level variables collected from the root file and
|
Print all pipeline-level variables collected from the root file and
|
||||||
every included file (sorted KEY=VALUE) to stderr, then continue
|
every included file (sorted KEY=VALUE) to stderr, then continue
|
||||||
@@ -171,6 +217,8 @@ Examples:
|
|||||||
glint check --branch main --var DEPLOY_ENV=production .gitlab-ci.yml
|
glint check --branch main --var DEPLOY_ENV=production .gitlab-ci.yml
|
||||||
glint check --cache-dir ~/.cache/glint .gitlab-ci.yml
|
glint check --cache-dir ~/.cache/glint .gitlab-ci.yml
|
||||||
glint check --offline --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
|
||||||
`)
|
`)
|
||||||
}
|
}
|
||||||
_ = fs.Parse(args)
|
_ = fs.Parse(args)
|
||||||
@@ -186,12 +234,14 @@ Examples:
|
|||||||
}
|
}
|
||||||
if !validFormats[*format] {
|
if !validFormats[*format] {
|
||||||
fmt.Fprintf(os.Stderr, "glint: unknown format %q; valid: text, json, sarif, junit, github\n", *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 {
|
if fs.NArg() != 1 {
|
||||||
fs.Usage()
|
fs.Usage()
|
||||||
os.Exit(2)
|
exit(2)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
path := fs.Arg(0)
|
path := fs.Arg(0)
|
||||||
rootDir := filepath.Dir(filepath.Clean(path))
|
rootDir := filepath.Dir(filepath.Clean(path))
|
||||||
@@ -226,7 +276,8 @@ Examples:
|
|||||||
p, err := model.Parse(path)
|
p, err := model.Parse(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
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
|
// Merge config-defined stages into the pipeline before linting so that
|
||||||
@@ -250,13 +301,35 @@ Examples:
|
|||||||
extWarnings, err := resolver.Resolve(p)
|
extWarnings, err := resolver.Resolve(p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "error: resolving extends: %v\n", err)
|
fmt.Fprintf(os.Stderr, "error: resolving extends: %v\n", err)
|
||||||
os.Exit(2)
|
exit(2)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
for _, w := range extWarnings {
|
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)
|
fmt.Fprintf(os.Stderr, "%s: [warning] job %q extends unknown job %q; extends chain skipped\n", path, w.Job, w.Base)
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx := cicontext.New(*branch, *tag, *source, vars)
|
ctx := cicontext.New(*branch, *tag, *source, vars)
|
||||||
|
// 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() {
|
if !ctx.IsEmpty() {
|
||||||
if !enrichContext(ctx, p) {
|
if !enrichContext(ctx, p) {
|
||||||
fmt.Fprintf(os.Stderr, "%s: [warning] workflow:rules: pipeline would not start for this context\n", path)
|
fmt.Fprintf(os.Stderr, "%s: [warning] workflow:rules: pipeline would not start for this context\n", path)
|
||||||
@@ -303,7 +376,7 @@ Examples:
|
|||||||
}
|
}
|
||||||
|
|
||||||
if errCount > 0 {
|
if errCount > 0 {
|
||||||
os.Exit(1)
|
exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -368,6 +441,13 @@ Options:
|
|||||||
--var <KEY=VALUE>
|
--var <KEY=VALUE>
|
||||||
Set or override a CI variable. Repeatable.
|
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
|
--list-vars
|
||||||
Print all pipeline-level variables collected from the root file and
|
Print all pipeline-level variables collected from the root file and
|
||||||
every included file (sorted KEY=VALUE) to stderr, then continue
|
every included file (sorted KEY=VALUE) to stderr, then continue
|
||||||
@@ -386,6 +466,7 @@ Examples:
|
|||||||
glint graph tree --branch develop .gitlab-ci.yml
|
glint graph tree --branch develop .gitlab-ci.yml
|
||||||
glint graph tree --tag v1.0.0 .gitlab-ci.yml
|
glint graph tree --tag v1.0.0 .gitlab-ci.yml
|
||||||
glint graph tree --list-vars .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 includes .gitlab-ci.yml > includes.mmd
|
||||||
glint graph pipeline .gitlab-ci.yml
|
glint graph pipeline .gitlab-ci.yml
|
||||||
glint graph pipeline --out /tmp/graphs .gitlab-ci.yml
|
glint graph pipeline --out /tmp/graphs .gitlab-ci.yml
|
||||||
@@ -398,6 +479,9 @@ Examples:
|
|||||||
listVars := fs.Bool("list-vars", false, "print all collected pipeline variables to stderr, then continue")
|
listVars := fs.Bool("list-vars", false, "print all collected pipeline variables to stderr, then continue")
|
||||||
var vars multiFlag
|
var vars multiFlag
|
||||||
fs.Var(&vars, "var", "set a CI variable as KEY=VALUE; repeatable")
|
fs.Var(&vars, "var", "set a CI variable as KEY=VALUE; repeatable")
|
||||||
|
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)
|
_ = fs.Parse(args)
|
||||||
|
|
||||||
// Apply implicit defaults when no context flag is given at all.
|
// Apply implicit defaults when no context flag is given at all.
|
||||||
@@ -408,7 +492,8 @@ Examples:
|
|||||||
|
|
||||||
if fs.NArg() != 1 {
|
if fs.NArg() != 1 {
|
||||||
fs.Usage()
|
fs.Usage()
|
||||||
os.Exit(2)
|
exit(2)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
path := fs.Arg(0)
|
path := fs.Arg(0)
|
||||||
|
|
||||||
@@ -422,7 +507,8 @@ Examples:
|
|||||||
p, err := model.Parse(path)
|
p, err := model.Parse(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||||
os.Exit(2)
|
exit(2)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
rootDir := filepath.Dir(filepath.Clean(path))
|
rootDir := filepath.Dir(filepath.Clean(path))
|
||||||
@@ -430,6 +516,27 @@ Examples:
|
|||||||
resolver.Resolve(p) //nolint:errcheck
|
resolver.Resolve(p) //nolint:errcheck
|
||||||
|
|
||||||
ctx := cicontext.New(*branch, *tag, *source, vars)
|
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() {
|
if !ctx.IsEmpty() {
|
||||||
enrichContext(ctx, p)
|
enrichContext(ctx, p)
|
||||||
}
|
}
|
||||||
@@ -450,7 +557,8 @@ Examples:
|
|||||||
outPath, err := graph.RenderPipeline(p, *out)
|
outPath, err := graph.RenderPipeline(p, *out)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "error: rendering pipeline graph: %v\n", err)
|
fmt.Fprintf(os.Stderr, "error: rendering pipeline graph: %v\n", err)
|
||||||
os.Exit(2)
|
exit(2)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
fmt.Println(outPath)
|
fmt.Println(outPath)
|
||||||
case "all":
|
case "all":
|
||||||
@@ -458,7 +566,8 @@ Examples:
|
|||||||
outPath, err := graph.RenderPipeline(p, *out)
|
outPath, err := graph.RenderPipeline(p, *out)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "error: rendering pipeline graph: %v\n", err)
|
fmt.Fprintf(os.Stderr, "error: rendering pipeline graph: %v\n", err)
|
||||||
os.Exit(2)
|
exit(2)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
fmt.Fprintln(os.Stderr, outPath)
|
fmt.Fprintln(os.Stderr, outPath)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,838 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.k3nny.fr/glint/internal/cicontext"
|
||||||
|
"git.k3nny.fr/glint/internal/linter"
|
||||||
|
"git.k3nny.fr/glint/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// captureExit replaces the exit variable with a function that records the code,
|
||||||
|
// and restores it after the test. Call the returned cleanup func in defer.
|
||||||
|
func captureExit(t *testing.T) *int {
|
||||||
|
t.Helper()
|
||||||
|
orig := exit
|
||||||
|
code := -1
|
||||||
|
exit = func(c int) { code = c }
|
||||||
|
t.Cleanup(func() { exit = orig })
|
||||||
|
return &code
|
||||||
|
}
|
||||||
|
|
||||||
|
// writePipeline writes a minimal valid pipeline file to a temp dir and returns its path.
|
||||||
|
func writePipeline(t *testing.T, content string) string {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, ".gitlab-ci.yml")
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
const minimalPipeline = `
|
||||||
|
stages: [build]
|
||||||
|
build-job:
|
||||||
|
stage: build
|
||||||
|
script: make
|
||||||
|
`
|
||||||
|
|
||||||
|
// ── main() ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestMain_NoArgs(t *testing.T) {
|
||||||
|
code := captureExit(t)
|
||||||
|
os.Args = []string{"glint"}
|
||||||
|
main()
|
||||||
|
if *code != 2 { t.Errorf("no args: want exit(2), got %d", *code) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMain_HelpFlag(t *testing.T) {
|
||||||
|
captureExit(t) // should not exit
|
||||||
|
os.Args = []string{"glint", "--help"}
|
||||||
|
main()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMain_VersionFlag(t *testing.T) {
|
||||||
|
captureExit(t)
|
||||||
|
os.Args = []string{"glint", "--version"}
|
||||||
|
main()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMain_HelpAlias(t *testing.T) {
|
||||||
|
captureExit(t)
|
||||||
|
os.Args = []string{"glint", "help"}
|
||||||
|
main()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMain_VersionAlias(t *testing.T) {
|
||||||
|
captureExit(t)
|
||||||
|
os.Args = []string{"glint", "version"}
|
||||||
|
main()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMain_UnknownCommand(t *testing.T) {
|
||||||
|
code := captureExit(t)
|
||||||
|
os.Args = []string{"glint", "badcmd"}
|
||||||
|
main()
|
||||||
|
if *code != 2 { t.Errorf("unknown cmd: want exit(2), got %d", *code) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── cmdCheck ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestCmdCheck_ValidPipeline(t *testing.T) {
|
||||||
|
code := captureExit(t)
|
||||||
|
path := writePipeline(t, minimalPipeline)
|
||||||
|
os.Args = []string{"glint", "check", path}
|
||||||
|
cmdCheck([]string{path})
|
||||||
|
if *code != -1 { t.Errorf("valid pipeline: want no exit, got %d", *code) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCmdCheck_NoArgs(t *testing.T) {
|
||||||
|
code := captureExit(t)
|
||||||
|
cmdCheck([]string{})
|
||||||
|
if *code != 2 { t.Errorf("no args: want exit(2), got %d", *code) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCmdCheck_InvalidFormat(t *testing.T) {
|
||||||
|
code := captureExit(t)
|
||||||
|
path := writePipeline(t, minimalPipeline)
|
||||||
|
cmdCheck([]string{"--format", "badformat", path})
|
||||||
|
if *code != 2 { t.Errorf("bad format: want exit(2), got %d", *code) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCmdCheck_MissingFile(t *testing.T) {
|
||||||
|
code := captureExit(t)
|
||||||
|
cmdCheck([]string{"/nonexistent/pipeline.yml"})
|
||||||
|
if *code != 2 { t.Errorf("missing file: want exit(2), got %d", *code) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCmdCheck_WithErrors_ExitsOne(t *testing.T) {
|
||||||
|
code := captureExit(t)
|
||||||
|
// Pipeline with an error finding (invalid stage reference)
|
||||||
|
content := `
|
||||||
|
stages: [build]
|
||||||
|
test-job:
|
||||||
|
stage: nonexistent
|
||||||
|
script: echo
|
||||||
|
`
|
||||||
|
path := writePipeline(t, content)
|
||||||
|
cmdCheck([]string{path})
|
||||||
|
if *code != 1 { t.Errorf("pipeline with errors: want exit(1), got %d", *code) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCmdCheck_FormatJSON(t *testing.T) {
|
||||||
|
captureExit(t)
|
||||||
|
path := writePipeline(t, minimalPipeline)
|
||||||
|
cmdCheck([]string{"--format", "json", path})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCmdCheck_FormatSARIF(t *testing.T) {
|
||||||
|
captureExit(t)
|
||||||
|
path := writePipeline(t, minimalPipeline)
|
||||||
|
cmdCheck([]string{"--format", "sarif", path})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCmdCheck_FormatJUnit(t *testing.T) {
|
||||||
|
captureExit(t)
|
||||||
|
path := writePipeline(t, minimalPipeline)
|
||||||
|
cmdCheck([]string{"--format", "junit", path})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCmdCheck_FormatGitHub(t *testing.T) {
|
||||||
|
captureExit(t)
|
||||||
|
path := writePipeline(t, minimalPipeline)
|
||||||
|
cmdCheck([]string{"--format", "github", path})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCmdCheck_WithBranch(t *testing.T) {
|
||||||
|
captureExit(t)
|
||||||
|
path := writePipeline(t, minimalPipeline)
|
||||||
|
cmdCheck([]string{"--branch", "develop", path})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCmdCheck_WithTag(t *testing.T) {
|
||||||
|
captureExit(t)
|
||||||
|
path := writePipeline(t, minimalPipeline)
|
||||||
|
cmdCheck([]string{"--tag", "v1.0.0", path})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCmdCheck_WithSource(t *testing.T) {
|
||||||
|
captureExit(t)
|
||||||
|
path := writePipeline(t, minimalPipeline)
|
||||||
|
cmdCheck([]string{"--source", "schedule", path})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCmdCheck_WithVar(t *testing.T) {
|
||||||
|
captureExit(t)
|
||||||
|
path := writePipeline(t, minimalPipeline)
|
||||||
|
cmdCheck([]string{"--var", "MY_VAR=hello", path})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCmdCheck_ListVars(t *testing.T) {
|
||||||
|
captureExit(t)
|
||||||
|
content := `
|
||||||
|
stages: [build]
|
||||||
|
variables:
|
||||||
|
MY_VAR: hello
|
||||||
|
build-job:
|
||||||
|
stage: build
|
||||||
|
script: echo
|
||||||
|
`
|
||||||
|
path := writePipeline(t, content)
|
||||||
|
cmdCheck([]string{"--list-vars", "--branch", "main", path})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCmdCheck_WithOffline(t *testing.T) {
|
||||||
|
captureExit(t)
|
||||||
|
path := writePipeline(t, minimalPipeline)
|
||||||
|
cmdCheck([]string{"--offline", path})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCmdCheck_WorkflowRulesExclude(t *testing.T) {
|
||||||
|
captureExit(t)
|
||||||
|
content := `
|
||||||
|
stages: [build]
|
||||||
|
workflow:
|
||||||
|
rules:
|
||||||
|
- if: '$CI_COMMIT_TAG'
|
||||||
|
when: always
|
||||||
|
- when: never
|
||||||
|
build-job:
|
||||||
|
stage: build
|
||||||
|
script: echo
|
||||||
|
`
|
||||||
|
path := writePipeline(t, content)
|
||||||
|
// branch push — workflow rule won't match a tag, so pipeline won't start
|
||||||
|
cmdCheck([]string{"--branch", "main", path})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── cmdGraph ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestCmdGraph_NoArgs(t *testing.T) {
|
||||||
|
code := captureExit(t)
|
||||||
|
cmdGraph([]string{})
|
||||||
|
if *code != 2 { t.Errorf("no args: want exit(2), got %d", *code) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCmdGraph_Tree(t *testing.T) {
|
||||||
|
captureExit(t)
|
||||||
|
path := writePipeline(t, minimalPipeline)
|
||||||
|
cmdGraph([]string{"tree", path})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCmdGraph_Includes(t *testing.T) {
|
||||||
|
captureExit(t)
|
||||||
|
path := writePipeline(t, minimalPipeline)
|
||||||
|
cmdGraph([]string{"includes", path})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCmdGraph_Default(t *testing.T) {
|
||||||
|
captureExit(t)
|
||||||
|
path := writePipeline(t, minimalPipeline)
|
||||||
|
cmdGraph([]string{path})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCmdGraph_Pipeline(t *testing.T) {
|
||||||
|
captureExit(t)
|
||||||
|
path := writePipeline(t, minimalPipeline)
|
||||||
|
outDir := t.TempDir()
|
||||||
|
cmdGraph([]string{"pipeline", "--out", outDir, path})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCmdGraph_All(t *testing.T) {
|
||||||
|
captureExit(t)
|
||||||
|
path := writePipeline(t, minimalPipeline)
|
||||||
|
outDir := t.TempDir()
|
||||||
|
cmdGraph([]string{"all", "--out", outDir, path})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCmdGraph_MissingFile(t *testing.T) {
|
||||||
|
code := captureExit(t)
|
||||||
|
cmdGraph([]string{"/nonexistent.yml"})
|
||||||
|
if *code != 2 { t.Errorf("missing file: want exit(2), got %d", *code) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCmdGraph_WithBranch(t *testing.T) {
|
||||||
|
captureExit(t)
|
||||||
|
path := writePipeline(t, minimalPipeline)
|
||||||
|
cmdGraph([]string{"tree", "--branch", "develop", path})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCmdGraph_WithTag(t *testing.T) {
|
||||||
|
captureExit(t)
|
||||||
|
path := writePipeline(t, minimalPipeline)
|
||||||
|
cmdGraph([]string{"tree", "--tag", "v1.0.0", path})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCmdGraph_WithListVars(t *testing.T) {
|
||||||
|
captureExit(t)
|
||||||
|
path := writePipeline(t, minimalPipeline)
|
||||||
|
cmdGraph([]string{"tree", "--list-vars", "--branch", "main", path})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCmdGraph_Offline(t *testing.T) {
|
||||||
|
captureExit(t)
|
||||||
|
path := writePipeline(t, minimalPipeline)
|
||||||
|
cmdGraph([]string{"tree", "--offline", path})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── cmdExplain ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestCmdExplain_NoArgs_ListsRules(t *testing.T) {
|
||||||
|
captureExit(t)
|
||||||
|
cmdExplain([]string{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCmdExplain_ValidRule(t *testing.T) {
|
||||||
|
captureExit(t)
|
||||||
|
cmdExplain([]string{"GL001"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCmdExplain_UnknownRule(t *testing.T) {
|
||||||
|
code := captureExit(t)
|
||||||
|
cmdExplain([]string{"GL999"})
|
||||||
|
if *code != 2 { t.Errorf("unknown rule: want exit(2), got %d", *code) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCmdExplain_LowercaseRule(t *testing.T) {
|
||||||
|
captureExit(t)
|
||||||
|
// lowercase should work (converted to upper inside)
|
||||||
|
cmdExplain([]string{"gl001"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── defaultCacheDir ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestDefaultCacheDir(t *testing.T) {
|
||||||
|
// Should return a non-empty string (either XDG or ~/.cache/glint)
|
||||||
|
got := defaultCacheDir()
|
||||||
|
if got == "" { t.Error("expected non-empty cache dir") }
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultCacheDir_XDG(t *testing.T) {
|
||||||
|
orig := os.Getenv("XDG_CACHE_HOME")
|
||||||
|
os.Setenv("XDG_CACHE_HOME", "/tmp/xdg-cache")
|
||||||
|
defer os.Setenv("XDG_CACHE_HOME", orig)
|
||||||
|
|
||||||
|
got := defaultCacheDir()
|
||||||
|
if got != "/tmp/xdg-cache/glint" {
|
||||||
|
t.Errorf("XDG_CACHE_HOME: got %q want /tmp/xdg-cache/glint", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── multiFlag ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestMultiFlag(t *testing.T) {
|
||||||
|
var f multiFlag
|
||||||
|
if f.String() != "" { t.Error("empty: expected empty string") }
|
||||||
|
if err := f.Set("a"); err != nil { t.Fatal(err) }
|
||||||
|
if err := f.Set("b"); err != nil { t.Fatal(err) }
|
||||||
|
if f.String() != "a, b" { t.Errorf("got %q", f.String()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── printVars ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestPrintVars(t *testing.T) {
|
||||||
|
p := &model.Pipeline{
|
||||||
|
Variables: map[string]any{"KEY": "val", "OTHER": map[string]any{"value": "v2"}},
|
||||||
|
Workflow: &model.Workflow{
|
||||||
|
Rules: []model.Rule{{Variables: map[string]any{"RULE_VAR": "x"}}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ctx := cicontext.New("main", "", "", nil)
|
||||||
|
printVars(p, ctx) // should not panic
|
||||||
|
printVars(p, nil) // nil ctx: no context vars printed
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrintVarMap(t *testing.T) {
|
||||||
|
printVarMap(nil) // nil map: prints (none)
|
||||||
|
printVarMap(map[string]any{}) // empty: prints (none)
|
||||||
|
printVarMap(map[string]any{"A": "1", "B": map[string]any{"value": "2"}})
|
||||||
|
printVarMap(map[string]any{"C": 42}) // non-scalar: (complex)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVarValueString(t *testing.T) {
|
||||||
|
if varValueString("hello") != "hello" { t.Error("string") }
|
||||||
|
if varValueString(map[string]any{"value": "v"}) != "v" { t.Error("map with value") }
|
||||||
|
if varValueString(map[string]any{"other": "x"}) != "(complex)" { t.Error("complex") }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── enrichContext ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestEnrichContext(t *testing.T) {
|
||||||
|
p := &model.Pipeline{
|
||||||
|
Variables: map[string]any{"ENV": "prod"},
|
||||||
|
}
|
||||||
|
ctx := cicontext.New("main", "", "", nil)
|
||||||
|
runs := enrichContext(ctx, p)
|
||||||
|
if !runs { t.Error("expected pipeline to run on main branch with no workflow rules") }
|
||||||
|
if ctx.Get("ENV") != "prod" { t.Error("pipeline var should be injected") }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── printContext ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestPrintContext(t *testing.T) {
|
||||||
|
p := &model.Pipeline{
|
||||||
|
Jobs: map[string]model.Job{
|
||||||
|
"active-job": {Name: "active-job", Script: []any{"echo"}, Stage: "build"},
|
||||||
|
"manual-job": {Name: "manual-job", Script: []any{"echo"}, When: "manual"},
|
||||||
|
"skipped-job": {Name: "skipped-job", Rules: []model.Rule{{If: `$CI_COMMIT_TAG != ""`, When: "on_success"}}},
|
||||||
|
".hidden": {Name: ".hidden"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ctx := cicontext.New("main", "", "", nil)
|
||||||
|
printContext(p, ctx) // should not panic
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── printJobGroup ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestPrintJobGroup(t *testing.T) {
|
||||||
|
printJobGroup("Active ", []string{"a", "b"}) // should not panic
|
||||||
|
printJobGroup("Empty ", []string{}) // empty: no output
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── main() switch cases ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestMain_Check(t *testing.T) {
|
||||||
|
captureExit(t)
|
||||||
|
path := writePipeline(t, minimalPipeline)
|
||||||
|
os.Args = []string{"glint", "check", path}
|
||||||
|
main()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMain_Graph(t *testing.T) {
|
||||||
|
captureExit(t)
|
||||||
|
path := writePipeline(t, minimalPipeline)
|
||||||
|
os.Args = []string{"glint", "graph", path}
|
||||||
|
main()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMain_Explain(t *testing.T) {
|
||||||
|
captureExit(t)
|
||||||
|
os.Args = []string{"glint", "explain", "GL001"}
|
||||||
|
main()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── defaultCacheDir — UserHomeDir failure ─────────────────────────────────────
|
||||||
|
|
||||||
|
func TestDefaultCacheDir_HomeDirFails(t *testing.T) {
|
||||||
|
orig := userHomeDirFn
|
||||||
|
userHomeDirFn = func() (string, error) { return "", errors.New("no home") }
|
||||||
|
t.Cleanup(func() { userHomeDirFn = orig })
|
||||||
|
|
||||||
|
origXDG := os.Getenv("XDG_CACHE_HOME")
|
||||||
|
os.Unsetenv("XDG_CACHE_HOME")
|
||||||
|
t.Cleanup(func() { os.Setenv("XDG_CACHE_HOME", origXDG) })
|
||||||
|
|
||||||
|
got := defaultCacheDir()
|
||||||
|
if got != "" {
|
||||||
|
t.Errorf("expected empty string when UserHomeDir fails, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── cmdCheck — config load error ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestCmdCheck_ConfigError(t *testing.T) {
|
||||||
|
captureExit(t)
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, ".gitlab-ci.yml")
|
||||||
|
if err := os.WriteFile(path, []byte(minimalPipeline), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Create an unreadable .glint.yml — config.Load returns a non-NotExist error.
|
||||||
|
cfgPath := filepath.Join(dir, ".glint.yml")
|
||||||
|
if err := os.WriteFile(cfgPath, []byte("ignore: []"), 0o000); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { os.Chmod(cfgPath, 0o644) }) // allow cleanup
|
||||||
|
cmdCheck([]string{path})
|
||||||
|
// Warning is emitted to stderr; linting still proceeds, so no exit(2).
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── cmdCheck — glintCfg.Stages ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestCmdCheck_ConfigStages(t *testing.T) {
|
||||||
|
captureExit(t)
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, ".gitlab-ci.yml")
|
||||||
|
if err := os.WriteFile(path, []byte(minimalPipeline), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Write a .glint.yml that declares an extra stage.
|
||||||
|
glintCfg := "stages:\n - extra-stage\n"
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, ".glint.yml"), []byte(glintCfg), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cmdCheck([]string{path})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── cmdCheck — ResolveIncludes warnings ──────────────────────────────────────
|
||||||
|
|
||||||
|
func TestCmdCheck_IncludeWarning(t *testing.T) {
|
||||||
|
captureExit(t)
|
||||||
|
// Clear all token env vars so the project include is skipped (no token).
|
||||||
|
for _, k := range []string{"GITLAB_TOKEN", "CI_JOB_TOKEN", "GITLAB_PRIVATE_TOKEN"} {
|
||||||
|
t.Setenv(k, "")
|
||||||
|
}
|
||||||
|
content := `
|
||||||
|
stages: [build]
|
||||||
|
include:
|
||||||
|
- project: some/group/project
|
||||||
|
ref: main
|
||||||
|
file: template.yml
|
||||||
|
build-job:
|
||||||
|
stage: build
|
||||||
|
script: echo
|
||||||
|
`
|
||||||
|
path := writePipeline(t, content)
|
||||||
|
cmdCheck([]string{path})
|
||||||
|
// warning printed to stderr; pipeline still lints cleanly → no exit
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── cmdCheck — resolver.Resolve error (cycle) ─────────────────────────────────
|
||||||
|
|
||||||
|
func TestCmdCheck_ResolveCycle(t *testing.T) {
|
||||||
|
code := captureExit(t)
|
||||||
|
content := `
|
||||||
|
stages: [build]
|
||||||
|
.base:
|
||||||
|
script: echo base
|
||||||
|
extends: .child
|
||||||
|
.child:
|
||||||
|
script: echo child
|
||||||
|
extends: .base
|
||||||
|
real-job:
|
||||||
|
stage: build
|
||||||
|
script: echo
|
||||||
|
`
|
||||||
|
path := writePipeline(t, content)
|
||||||
|
cmdCheck([]string{path})
|
||||||
|
if *code != 2 {
|
||||||
|
t.Errorf("cycle in extends: want exit(2), got %d", *code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── cmdCheck — extends unknown base (extWarnings) ────────────────────────────
|
||||||
|
|
||||||
|
func TestCmdCheck_ExtendsWarning(t *testing.T) {
|
||||||
|
captureExit(t)
|
||||||
|
content := `
|
||||||
|
stages: [build]
|
||||||
|
build-job:
|
||||||
|
stage: build
|
||||||
|
extends: .nonexistent-base
|
||||||
|
script: echo
|
||||||
|
`
|
||||||
|
path := writePipeline(t, content)
|
||||||
|
cmdCheck([]string{path})
|
||||||
|
// Warning is printed; exit code is not 2 (extends warning is non-fatal).
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── cmdGraph — RenderPipeline errors ─────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestCmdGraph_PipelineError(t *testing.T) {
|
||||||
|
code := captureExit(t)
|
||||||
|
path := writePipeline(t, minimalPipeline)
|
||||||
|
// Create a regular file at the outDir path so os.MkdirAll fails.
|
||||||
|
outFile := filepath.Join(t.TempDir(), "not-a-dir")
|
||||||
|
if err := os.WriteFile(outFile, []byte{}, 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cmdGraph([]string{"pipeline", "--out", outFile, path})
|
||||||
|
if *code != 2 {
|
||||||
|
t.Errorf("pipeline outDir-is-file: want exit(2), got %d", *code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCmdGraph_AllError(t *testing.T) {
|
||||||
|
code := captureExit(t)
|
||||||
|
path := writePipeline(t, minimalPipeline)
|
||||||
|
outFile := filepath.Join(t.TempDir(), "not-a-dir")
|
||||||
|
if err := os.WriteFile(outFile, []byte{}, 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cmdGraph([]string{"all", "--out", outFile, path})
|
||||||
|
if *code != 2 {
|
||||||
|
t.Errorf("all outDir-is-file: want exit(2), got %d", *code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── enrichContext — workflow rule variables ───────────────────────────────────
|
||||||
|
|
||||||
|
func TestEnrichContext_WorkflowVars(t *testing.T) {
|
||||||
|
p := &model.Pipeline{
|
||||||
|
Workflow: &model.Workflow{
|
||||||
|
Rules: []model.Rule{{
|
||||||
|
// No If: → always matches; Variables are injected into ctx.
|
||||||
|
When: "always",
|
||||||
|
Variables: map[string]any{"DEPLOY_ENV": "production"},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ctx := cicontext.New("main", "", "", nil)
|
||||||
|
runs := enrichContext(ctx, p)
|
||||||
|
if !runs {
|
||||||
|
t.Error("expected pipeline to run with unconditional workflow rule")
|
||||||
|
}
|
||||||
|
if ctx.Get("DEPLOY_ENV") != "production" {
|
||||||
|
t.Errorf("expected DEPLOY_ENV=production, got %q", ctx.Get("DEPLOY_ENV"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── writeJUnit — empty Rule and File ─────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestWriteJUnit_EmptyRuleAndFile(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
// Rule == "" → name falls back to "lint" (format.go:231-233).
|
||||||
|
// File == "" → classname falls back to pipeline arg (format.go:235-237).
|
||||||
|
writeJUnit(&buf, []linter.Finding{
|
||||||
|
{Severity: linter.Error, Rule: "", File: "", Message: "something went wrong"},
|
||||||
|
}, "my-pipeline.yml")
|
||||||
|
out := buf.String()
|
||||||
|
if !strings.Contains(out, "lint") {
|
||||||
|
t.Errorf("expected 'lint' as testcase name when Rule is empty; got:\n%s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "my-pipeline.yml") {
|
||||||
|
t.Errorf("expected pipeline path as classname when File is empty; got:\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── execCommandOutput (default implementation) ────────────────────────────────
|
||||||
|
|
||||||
|
func TestExecCommandOutput_Default(t *testing.T) {
|
||||||
|
// Call the default implementation directly (without mocking) to cover the
|
||||||
|
// closure body in main.go.
|
||||||
|
orig := execCommandOutput
|
||||||
|
execCommandOutput = func(name string, args ...string) ([]byte, error) {
|
||||||
|
return exec.Command(name, args...).Output()
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { execCommandOutput = orig })
|
||||||
|
out, err := execCommandOutput("echo", "hello")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(out), "hello") {
|
||||||
|
t.Errorf("unexpected output: %q", string(out))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── gitDiffFiles ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestGitDiffFiles_Success(t *testing.T) {
|
||||||
|
orig := execCommandOutput
|
||||||
|
execCommandOutput = func(name string, args ...string) ([]byte, error) {
|
||||||
|
return []byte("src/main.go\nDockerfile\n"), nil
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { execCommandOutput = orig })
|
||||||
|
|
||||||
|
files, err := gitDiffFiles("origin/main")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(files) != 2 || files[0] != "src/main.go" || files[1] != "Dockerfile" {
|
||||||
|
t.Errorf("unexpected files: %v", files)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGitDiffFiles_Error(t *testing.T) {
|
||||||
|
orig := execCommandOutput
|
||||||
|
execCommandOutput = func(name string, args ...string) ([]byte, error) {
|
||||||
|
return nil, errors.New("not a git repository")
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { execCommandOutput = orig })
|
||||||
|
|
||||||
|
files, err := gitDiffFiles("HEAD~1")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
if files != nil {
|
||||||
|
t.Errorf("expected nil files on error, got %v", files)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGitDiffFiles_EmptyOutput(t *testing.T) {
|
||||||
|
orig := execCommandOutput
|
||||||
|
execCommandOutput = func(name string, args ...string) ([]byte, error) {
|
||||||
|
return []byte(""), nil
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { execCommandOutput = orig })
|
||||||
|
|
||||||
|
files, err := gitDiffFiles("HEAD~1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
// Empty output → nil slice (no lines passed the filter)
|
||||||
|
if files != nil {
|
||||||
|
t.Errorf("expected nil (no files), got %v", files)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── cmdCheck --changes / --changes-from ──────────────────────────────────────
|
||||||
|
|
||||||
|
func TestCmdCheck_ChangesFlag(t *testing.T) {
|
||||||
|
code := captureExit(t)
|
||||||
|
content := `stages: [build]
|
||||||
|
build-job:
|
||||||
|
stage: build
|
||||||
|
script: echo
|
||||||
|
rules:
|
||||||
|
- changes: [src/**/*.go]
|
||||||
|
when: on_success
|
||||||
|
`
|
||||||
|
path := writePipeline(t, content)
|
||||||
|
// With --changes src/main.go: the rule fires → active job, no error
|
||||||
|
cmdCheck([]string{"--changes", "src/main.go", path})
|
||||||
|
if *code != -1 {
|
||||||
|
t.Errorf("expected clean exit with matching --changes, got %d", *code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCmdCheck_ChangesFrom_Fails(t *testing.T) {
|
||||||
|
// When --changes-from fails (not a git repo / bad ref), a warning is emitted
|
||||||
|
// and the check continues normally (permissive behaviour).
|
||||||
|
orig := execCommandOutput
|
||||||
|
execCommandOutput = func(name string, args ...string) ([]byte, error) {
|
||||||
|
return nil, errors.New("fatal: not a git repository")
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { execCommandOutput = orig })
|
||||||
|
|
||||||
|
code := captureExit(t)
|
||||||
|
path := writePipeline(t, minimalPipeline)
|
||||||
|
// Should warn but not crash; pipeline is clean → no exit(1).
|
||||||
|
cmdCheck([]string{"--changes-from", "origin/main", path})
|
||||||
|
if *code == 1 {
|
||||||
|
t.Errorf("expected no exit(1) when --changes-from fails gracefully, got %d", *code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCmdCheck_ChangesFrom_Success(t *testing.T) {
|
||||||
|
orig := execCommandOutput
|
||||||
|
execCommandOutput = func(name string, args ...string) ([]byte, error) {
|
||||||
|
return []byte("src/main.go\n"), nil
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { execCommandOutput = orig })
|
||||||
|
|
||||||
|
code := captureExit(t)
|
||||||
|
content := `stages: [build]
|
||||||
|
build-job:
|
||||||
|
stage: build
|
||||||
|
script: echo
|
||||||
|
rules:
|
||||||
|
- changes: [src/**]
|
||||||
|
when: on_success
|
||||||
|
`
|
||||||
|
path := writePipeline(t, content)
|
||||||
|
cmdCheck([]string{"--changes-from", "origin/main", path})
|
||||||
|
if *code != -1 {
|
||||||
|
t.Errorf("expected clean exit, got %d", *code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCmdCheck_ChangesFrom_EmptyDiff(t *testing.T) {
|
||||||
|
// --changes-from succeeds but returns no files (nothing changed).
|
||||||
|
// reliable=true, allChanged stays nil → allChanged = []string{} branch is hit.
|
||||||
|
orig := execCommandOutput
|
||||||
|
execCommandOutput = func(name string, args ...string) ([]byte, error) {
|
||||||
|
return []byte(""), nil // empty output → no changed files
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { execCommandOutput = orig })
|
||||||
|
|
||||||
|
code := captureExit(t)
|
||||||
|
content := `stages: [build]
|
||||||
|
build-job:
|
||||||
|
stage: build
|
||||||
|
script: echo
|
||||||
|
rules:
|
||||||
|
- changes: [src/**]
|
||||||
|
when: on_success
|
||||||
|
`
|
||||||
|
path := writePipeline(t, content)
|
||||||
|
// build-job's rule fires only if src/** matches; with 0 changed files it is skipped.
|
||||||
|
// Pipeline is clean (no lint errors) → no exit(1).
|
||||||
|
cmdCheck([]string{"--changes-from", "origin/main", path})
|
||||||
|
if *code == 1 {
|
||||||
|
t.Errorf("unexpected exit(1): %d", *code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCmdGraph_ChangesFrom_Fails(t *testing.T) {
|
||||||
|
orig := execCommandOutput
|
||||||
|
execCommandOutput = func(name string, args ...string) ([]byte, error) {
|
||||||
|
return nil, errors.New("not a git repository")
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { execCommandOutput = orig })
|
||||||
|
|
||||||
|
code := captureExit(t)
|
||||||
|
path := writePipeline(t, minimalPipeline)
|
||||||
|
cmdGraph([]string{"tree", "--changes-from", "origin/main", path})
|
||||||
|
if *code == 1 {
|
||||||
|
t.Errorf("unexpected exit(1) when --changes-from fails in graph mode")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCmdGraph_ChangesFrom_Success(t *testing.T) {
|
||||||
|
orig := execCommandOutput
|
||||||
|
execCommandOutput = func(name string, args ...string) ([]byte, error) {
|
||||||
|
return []byte("src/app.go\n"), nil
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { execCommandOutput = orig })
|
||||||
|
|
||||||
|
code := captureExit(t)
|
||||||
|
path := writePipeline(t, minimalPipeline)
|
||||||
|
cmdGraph([]string{"tree", "--changes-from", "origin/main", path})
|
||||||
|
if *code == 1 {
|
||||||
|
t.Errorf("unexpected exit(1) in graph --changes-from success path")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCmdGraph_ChangesFrom_EmptyDiff(t *testing.T) {
|
||||||
|
orig := execCommandOutput
|
||||||
|
execCommandOutput = func(name string, args ...string) ([]byte, error) {
|
||||||
|
return []byte(""), nil
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { execCommandOutput = orig })
|
||||||
|
|
||||||
|
code := captureExit(t)
|
||||||
|
path := writePipeline(t, minimalPipeline)
|
||||||
|
// reliable=true, allChanged nil → allChanged = []string{} branch hit
|
||||||
|
cmdGraph([]string{"tree", "--changes-from", "origin/main", path})
|
||||||
|
if *code == 1 {
|
||||||
|
t.Errorf("unexpected exit(1) in graph --changes-from empty diff")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCmdGraph_ChangesFlag(t *testing.T) {
|
||||||
|
code := captureExit(t)
|
||||||
|
content := `stages: [build]
|
||||||
|
build-job:
|
||||||
|
stage: build
|
||||||
|
script: echo
|
||||||
|
rules:
|
||||||
|
- changes: [src/**]
|
||||||
|
when: on_success
|
||||||
|
`
|
||||||
|
path := writePipeline(t, content)
|
||||||
|
cmdGraph([]string{"tree", "--changes", "src/app.go", path})
|
||||||
|
if *code == 1 {
|
||||||
|
t.Errorf("unexpected exit(1) with valid pipeline and --changes flag")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── isSuppressed ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestIsSuppressed(t *testing.T) {
|
||||||
|
suppressions := map[string][]string{
|
||||||
|
"build-job": {"GL001", "GL002"},
|
||||||
|
"all-job": {"*"},
|
||||||
|
}
|
||||||
|
if isSuppressed("", "GL001", suppressions) { t.Error("empty job: not suppressed") }
|
||||||
|
if isSuppressed("build-job", "GL003", suppressions) { t.Error("GL003 not in list") }
|
||||||
|
if !isSuppressed("build-job", "GL001", suppressions) { t.Error("GL001 should be suppressed") }
|
||||||
|
if !isSuppressed("all-job", "GL042", suppressions) { t.Error("wildcard should suppress") }
|
||||||
|
if isSuppressed("unknown-job", "GL001", suppressions) { t.Error("unknown job: not suppressed") }
|
||||||
|
}
|
||||||
@@ -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 {
|
type Context struct {
|
||||||
Vars map[string]string
|
Vars map[string]string
|
||||||
pinned map[string]bool // vars set via --var or shortcuts; never overwritten by Inject
|
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
|
// 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
|
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.
|
// Summary returns a short human-readable description of the context for CLI output.
|
||||||
func (c *Context) Summary() string {
|
func (c *Context) Summary() string {
|
||||||
if c.IsEmpty() {
|
if c.IsEmpty() {
|
||||||
@@ -115,6 +128,9 @@ func (c *Context) Summary() string {
|
|||||||
if v := c.Get("CI_PIPELINE_SOURCE"); v != "" {
|
if v := c.Get("CI_PIPELINE_SOURCE"); v != "" {
|
||||||
parts = append(parts, "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, ", ")
|
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) {
|
func (p *exprParser) parseRegexLiteral() (string, bool) {
|
||||||
if p.peek() != '/' {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
p.pos++ // consume opening '/'
|
p.pos++ // consume opening '/'
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
for p.pos < len(p.s) {
|
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) {
|
func TestEvalIfStrict(t *testing.T) {
|
||||||
vars := func(key string) string {
|
vars := func(key string) string {
|
||||||
m := map[string]string{
|
m := map[string]string{
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package cicontext
|
package cicontext
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"path"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -49,6 +50,9 @@ func EvalWorkflow(p *model.Pipeline, ctx *Context) (bool, map[string]string) {
|
|||||||
if !ruleIfMatchesStrict(rule.If, vars) {
|
if !ruleIfMatchesStrict(rule.If, vars) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if !changesMatch(rule.Changes, ctx) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
when := rule.When
|
when := rule.When
|
||||||
if when == "" {
|
if when == "" {
|
||||||
when = "always"
|
when = "always"
|
||||||
@@ -74,6 +78,9 @@ func EvalJob(job model.Job, ctx *Context) JobState {
|
|||||||
if !ruleIfMatches(rule.If, vars) {
|
if !ruleIfMatches(rule.If, vars) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if !changesMatch(rule.Changes, ctx) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
return whenToState(rule.When)
|
return whenToState(rule.When)
|
||||||
}
|
}
|
||||||
return JobSkipped // no rule matched → job is excluded
|
return JobSkipped // no rule matched → job is excluded
|
||||||
@@ -262,3 +269,91 @@ func matchGlob(pattern, s string) bool {
|
|||||||
}
|
}
|
||||||
return len(s) == 0
|
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) {
|
func TestLoad_InvalidYAML(t *testing.T) {
|
||||||
tmp := t.TempDir()
|
tmp := t.TempDir()
|
||||||
if err := os.WriteFile(filepath.Join(tmp, Filename), []byte("ignore: [unclosed\n"), 0o644); err != nil {
|
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{}
|
||||||
@@ -82,9 +82,6 @@ func Pipeline(p *model.Pipeline) string {
|
|||||||
// Emit one subgraph per stage.
|
// Emit one subgraph per stage.
|
||||||
for _, stage := range stages {
|
for _, stage := range stages {
|
||||||
jobs := byStage[stage]
|
jobs := byStage[stage]
|
||||||
if len(jobs) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
sort.Strings(jobs)
|
sort.Strings(jobs)
|
||||||
wf(" subgraph %s[\"%s\"]", stageID(stage), stage)
|
wf(" subgraph %s[\"%s\"]", stageID(stage), stage)
|
||||||
for _, name := range jobs {
|
for _, name := range jobs {
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
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{Trigger: "x"}) != "trigger" { t.Error("trigger") }
|
||||||
|
if jobClass(model.Job{}) != "regular" { t.Error("regular") }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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") }
|
||||||
|
}
|
||||||
+15
-20
@@ -37,10 +37,7 @@ func RenderPipeline(p *model.Pipeline, outDir string) (string, error) {
|
|||||||
return "", fmt.Errorf("creating output directory %s: %w", outDir, err)
|
return "", fmt.Errorf("creating output directory %s: %w", outDir, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
svg, err := pipelineSVG(p)
|
svg := pipelineSVG(p)
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
ts := time.Now().Format("20060102-150405")
|
ts := time.Now().Format("20060102-150405")
|
||||||
svgPath := filepath.Join(outDir, "pipeline-"+ts+".svg")
|
svgPath := filepath.Join(outDir, "pipeline-"+ts+".svg")
|
||||||
@@ -58,21 +55,19 @@ func RenderPipeline(p *model.Pipeline, outDir string) (string, error) {
|
|||||||
|
|
||||||
// convertToPNG tries rsvg-convert, Inkscape, magick, and convert (not on Windows).
|
// convertToPNG tries rsvg-convert, Inkscape, magick, and convert (not on Windows).
|
||||||
func convertToPNG(svgPath, pngPath string) bool {
|
func convertToPNG(svgPath, pngPath string) bool {
|
||||||
type cand struct {
|
candidates := [][]string{
|
||||||
args []string
|
{"rsvg-convert", "--output", pngPath, svgPath},
|
||||||
skipWin bool
|
{"inkscape", "--export-filename=" + pngPath, svgPath},
|
||||||
|
{"magick", svgPath, pngPath},
|
||||||
}
|
}
|
||||||
for _, c := range []cand{
|
// `convert` is the legacy ImageMagick name; skip on Windows where the name
|
||||||
{[]string{"rsvg-convert", "--output", pngPath, svgPath}, false},
|
// collides with the built-in FAT→NTFS converter.
|
||||||
{[]string{"inkscape", "--export-filename=" + pngPath, svgPath}, false},
|
if runtime.GOOS != "windows" {
|
||||||
{[]string{"magick", svgPath, pngPath}, false},
|
candidates = append(candidates, []string{"convert", svgPath, pngPath})
|
||||||
{[]string{"convert", svgPath, pngPath}, true},
|
|
||||||
} {
|
|
||||||
if c.skipWin && runtime.GOOS == "windows" {
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
if bin, err := exec.LookPath(c.args[0]); err == nil {
|
for _, args := range candidates {
|
||||||
if exec.Command(bin, c.args[1:]...).Run() == nil {
|
if bin, err := exec.LookPath(args[0]); err == nil {
|
||||||
|
if exec.Command(bin, args[1:]...).Run() == nil {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -80,7 +75,7 @@ func convertToPNG(svgPath, pngPath string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func pipelineSVG(p *model.Pipeline) (string, error) {
|
func pipelineSVG(p *model.Pipeline) string {
|
||||||
// Collect visible (non-template) job names in sorted order.
|
// Collect visible (non-template) job names in sorted order.
|
||||||
var visible []string
|
var visible []string
|
||||||
for name := range p.Jobs {
|
for name := range p.Jobs {
|
||||||
@@ -91,7 +86,7 @@ func pipelineSVG(p *model.Pipeline) (string, error) {
|
|||||||
sort.Strings(visible)
|
sort.Strings(visible)
|
||||||
|
|
||||||
if len(visible) == 0 {
|
if len(visible) == 0 {
|
||||||
return svgEmpty(), nil
|
return svgEmpty()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Group by stage; fall back to "test" (GitLab default) when stage is unset.
|
// Group by stage; fall back to "test" (GitLab default) when stage is unset.
|
||||||
@@ -290,7 +285,7 @@ func pipelineSVG(p *model.Pipeline) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
w(`</svg>`)
|
w(`</svg>`)
|
||||||
return sb.String(), nil
|
return sb.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// drawChipIcon writes an SVG symbol inside the status circle to help identify
|
// drawChipIcon writes an SVG symbol inside the status circle to help identify
|
||||||
|
|||||||
@@ -0,0 +1,347 @@
|
|||||||
|
package graph
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"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{}) != "#1f75cb" { t.Error("regular color") }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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{}, "<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{})
|
||||||
|
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)
|
||||||
|
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 connector
|
||||||
|
if !strings.Contains(svg, "<polyline") && !strings.Contains(svg, "<line") {
|
||||||
|
t.Error("expected connector in 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)
|
||||||
|
// 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 <line> 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)
|
||||||
|
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"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
svg := pipelineSVG(p)
|
||||||
|
// Each type has its own color in the SVG
|
||||||
|
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") }
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPipelineSVG_JobNoStage(t *testing.T) {
|
||||||
|
p := &model.Pipeline{
|
||||||
|
Jobs: map[string]model.Job{
|
||||||
|
"myjob": {Name: "myjob"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
svg := pipelineSVG(p)
|
||||||
|
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)
|
||||||
|
if !strings.Contains(svg, "<svg") { t.Error("expected <svg") }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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)
|
||||||
|
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")
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
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 (line 72) 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)
|
||||||
|
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 → different column heights → L-shaped elbow
|
||||||
|
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)
|
||||||
|
if !strings.Contains(svg, "<polyline") {
|
||||||
|
t.Error("expected <polyline> for L-shaped elbow 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)
|
||||||
|
// Should render without panic; .hidden-template is not visible so no connector drawn.
|
||||||
|
if !strings.Contains(svg, "<svg") {
|
||||||
|
t.Error("expected valid SVG output")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,819 @@
|
|||||||
|
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`,
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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,597 @@
|
|||||||
|
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)) != 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)) != 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)
|
||||||
|
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)) != 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
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)) != 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)) != 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)) != 1 { t.Error("same stage dep: error") }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -62,6 +62,8 @@ func Lint(p *model.Pipeline) []Finding {
|
|||||||
findings = append(findings, checkNeeds(p)...)
|
findings = append(findings, checkNeeds(p)...)
|
||||||
findings = append(findings, checkDependencies(p)...)
|
findings = append(findings, checkDependencies(p)...)
|
||||||
findings = append(findings, checkVariableRefs(p)...)
|
findings = append(findings, checkVariableRefs(p)...)
|
||||||
|
findings = append(findings, checkRulesIfReachability(p)...)
|
||||||
|
findings = append(findings, checkInheritCompleteness(p)...)
|
||||||
slices.SortStableFunc(findings, func(a, b Finding) int {
|
slices.SortStableFunc(findings, func(a, b Finding) int {
|
||||||
if c := cmp.Compare(a.File, b.File); c != 0 {
|
if c := cmp.Compare(a.File, b.File); c != 0 {
|
||||||
return c
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package linter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.k3nny.fr/glint/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
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,15 @@ const (
|
|||||||
|
|
||||||
// GL041: cache.key.files contains a glob pattern; it must be a list of exact file paths.
|
// GL041: cache.key.files contains a glob pattern; it must be a list of exact file paths.
|
||||||
RuleInvalidCacheKeyFiles = "GL041"
|
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"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,168 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Parse (file) ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestParse(t *testing.T) {
|
||||||
|
t.Run("valid file", func(t *testing.T) {
|
||||||
|
content := `
|
||||||
|
stages: [build]
|
||||||
|
build-job:
|
||||||
|
stage: build
|
||||||
|
script: [make]
|
||||||
|
`
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, ".gitlab-ci.yml")
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
p, err := Parse(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Parse: %v", err)
|
||||||
|
}
|
||||||
|
if p.SourceFile != path {
|
||||||
|
t.Errorf("SourceFile: got %q want %q", p.SourceFile, path)
|
||||||
|
}
|
||||||
|
if _, ok := p.Jobs["build-job"]; !ok {
|
||||||
|
t.Error("build-job not found")
|
||||||
|
}
|
||||||
|
if p.Jobs["build-job"].File != path {
|
||||||
|
t.Errorf("job File not set: %q", p.Jobs["build-job"].File)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("missing file", func(t *testing.T) {
|
||||||
|
_, err := Parse("/nonexistent/path/ci.yml")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for missing file")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ParseBytes edge cases ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestParseBytes_EdgeCases(t *testing.T) {
|
||||||
|
// Empty YAML: doc.Kind is not DocumentNode → return empty Pipeline.
|
||||||
|
p, err := ParseBytes([]byte(""))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("empty YAML: unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if p == nil || len(p.Jobs) != 0 {
|
||||||
|
t.Error("empty YAML: expected empty pipeline")
|
||||||
|
}
|
||||||
|
|
||||||
|
// YAML null: second pass succeeds, doc root is ScalarNode → return empty Pipeline (line 51-53).
|
||||||
|
p2, err := ParseBytes([]byte("null"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("null YAML: unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if p2 == nil || len(p2.Jobs) != 0 {
|
||||||
|
t.Error("null YAML: expected empty pipeline")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalid YAML (undefined alias): first-pass Unmarshal fails (line 34-36).
|
||||||
|
_, err = ParseBytes([]byte("*undefined_anchor"))
|
||||||
|
if err == nil {
|
||||||
|
t.Error("undefined alias: expected error from ParseBytes")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sequence YAML: second-pass Unmarshal fails (cannot decode !!seq into Pipeline).
|
||||||
|
_, err = ParseBytes([]byte("- item1\n- item2\n"))
|
||||||
|
if err == nil {
|
||||||
|
t.Error("sequence YAML: expected error from ParseBytes")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Job value that cannot be decoded as map[string]any (scalar job value).
|
||||||
|
_, err = ParseBytes([]byte("my-job: \"just a string\"\n"))
|
||||||
|
if err == nil {
|
||||||
|
t.Error("scalar job value: expected error from ParseBytes")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Job with wrong field type: rawMap decode succeeds, Job decode fails (line 79-81).
|
||||||
|
_, err = ParseBytes([]byte("my-job:\n stage: [build, test]\n"))
|
||||||
|
if err == nil {
|
||||||
|
t.Error("wrong field type: expected error from ParseBytes (stage must be string)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParse_ParseBytesError exercises the Parse → ParseBytes error path (line 18).
|
||||||
|
func TestParse_ParseBytesError(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "bad.yml")
|
||||||
|
if err := os.WriteFile(path, []byte("my-job: \"scalar\"\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, err := Parse(path)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("scalar job value: expected error from Parse")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseSuppressComment_NonIgnore ensures that a "glint:" directive that is
|
||||||
|
// not "ignore" is silently skipped.
|
||||||
|
func TestParseSuppressComment_NonIgnore(t *testing.T) {
|
||||||
|
result := parseSuppressComment("# glint: refresh GL001", "")
|
||||||
|
if result != nil {
|
||||||
|
t.Errorf("non-ignore glint directive should return nil, got %v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── sanitizeYAMLEscapes ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestSanitizeYAMLEscapes(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "no double quotes — unchanged",
|
||||||
|
input: "stage: build",
|
||||||
|
want: "stage: build",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "double-quoted string without backslash",
|
||||||
|
input: `if: "$CI_BRANCH == \"main\""`,
|
||||||
|
want: `if: "$CI_BRANCH == \"main\""`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// \/ inside double-quoted YAML string becomes \\/ (yaml.v3 does not recognise \/).
|
||||||
|
name: "slash escape rewritten in larger context",
|
||||||
|
input: `if: "$CI_BRANCH =~ /^us\//"`,
|
||||||
|
want: `if: "$CI_BRANCH =~ /^us\\//"`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "backslash-slash inside double quotes rewritten",
|
||||||
|
input: `"pattern: /^us\//"` ,
|
||||||
|
want: `"pattern: /^us\\//"`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "single-quoted string unchanged",
|
||||||
|
input: `'hello \/ world'`,
|
||||||
|
want: `'hello \/ world'`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "escaped single quote inside single-quoted string",
|
||||||
|
input: `'it''s fine'`,
|
||||||
|
want: `'it''s fine'`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "other backslash escapes unchanged",
|
||||||
|
input: `"\n\t\r"`,
|
||||||
|
want: `"\n\t\r"`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got := string(sanitizeYAMLEscapes([]byte(tc.input)))
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("got %q\nwant %q", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,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 {
|
return inputPlaceholderRe.ReplaceAllFunc(data, func(match []byte) []byte {
|
||||||
groups := inputPlaceholderRe.FindSubmatch(match)
|
groups := inputPlaceholderRe.FindSubmatch(match)
|
||||||
if len(groups) < 2 {
|
|
||||||
return match
|
|
||||||
}
|
|
||||||
if val, ok := inputs[string(groups[1])]; ok {
|
if val, ok := inputs[string(groups[1])]; ok {
|
||||||
return []byte(fmt.Sprintf("%v", val))
|
return []byte(fmt.Sprintf("%v", val))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
package resolver
|
package resolver
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"git.k3nny.fr/glint/internal/fetcher"
|
||||||
|
"git.k3nny.fr/glint/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestSubstituteInputs(t *testing.T) {
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
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: always
|
||||||
- when: never
|
- when: never
|
||||||
|
|
||||||
|
default:
|
||||||
|
tags:
|
||||||
|
- docker
|
||||||
|
|
||||||
variables:
|
variables:
|
||||||
GO_VERSION: "1.26"
|
GO_VERSION: "1.26"
|
||||||
|
|
||||||
|
|||||||
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