Compare commits

...

24 Commits

Author SHA1 Message Date
k3nny 7f404f3492 docs(docs): update README, CHANGELOG, ROADMAP, Taskfile for v0.3.0
ci / vet, staticcheck, test, build (push) Failing after 2m10s
release / Build and publish release (push) Successful in 1m12s
Document glint render, --no-warn, exit codes 2/10, --no-skipped, and
colorized output. Fix Taskfile validate entries: fixtures that now exit
10 (warnings only) require ignore_error: true to pass the validate task.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 22:35:55 +02:00
k3nny 1615655c00 feat(cli): --no-warn, new exit codes, glint render, and graph --no-skipped
Exit codes (breaking change from exit 1):
- 0  clean (no findings)
- 2  one or more errors
- 10 one or more warnings, no errors
Errors take precedence over warnings.

glint check --no-warn:
  Discard warning findings before output and exit-code calculation.
  Mixed pipelines (errors + warnings) still exit 2 but only errors print.
  Warnings-only pipelines exit 0 with "OK" when --no-warn is set.

glint render <PIPELINE>:
  New subcommand. Resolves all include: and extends: chains, then writes
  a single flat .gitlab-ci.yml (default: rendered.gitlab-ci.yml, or
  stdout with --output -). Strips consumed keys (include:, extends:).
  Template jobs (.) are retained. Flags: --output, --token, --gitlab-url,
  --cache-dir, --offline, --proxy (all with .glint.yml fallback).
  To support render, Resolve() now writes the merged raw map back to
  p.RawJobs[name] and also preserves j.Column from the original job.

glint graph --no-skipped:
  Removes jobs that evaluate to JobSkipped in the given context before
  any graph function sees the pipeline. Works for tree, pipeline (SVG,
  HTML, Mermaid), includes, and all modes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 22:27:12 +02:00
k3nny 8c3605ed52 feat(cli): colorized, columnized text output with line:col locations
Replace the per-line fmt.Println loop with writeTextFindings() in
cmd/glint/output.go. The new renderer:

- Aligns all findings into four space-separated columns: location,
  rule ID, severity, message — widths computed from the full finding
  set so all lines are flush
- Colors severity words when stdout is a TTY and NO_COLOR is not set:
  red+bold for "error", orange+bold for "warning"; location dimmed;
  rule ID bold
- Formats location as file:line:col when column is known, file:line
  otherwise, falling back to just file

To populate column numbers, add Column int to model.Job (set from
keyNode.Column in the YAML parser) and Finding.Column (set during
the checkJob source-location attachment pass and in the ten cross-job
check sites that explicitly set Line: job.Line).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 22:13:52 +02:00
k3nny f79c64cd44 feat(security): security hardening, proxy support, and GL045 HTTP include warning
ci / vet, staticcheck, test, build (push) Failing after 2m32s
release / Build and publish release (push) Successful in 1m17s
Security fixes:
- Path traversal guard in include: local: — paths with ../ that escape
  the repo root are rejected instead of reading arbitrary host files
- HTTP timeout (30 s) on all fetcher requests to prevent indefinite hangs
- Response size cap (10 MiB) via io.LimitReader to prevent memory exhaustion
- Cache directory and file permissions tightened to 0700/0600
- LSP Content-Length cap (64 MiB) to guard against DoS from a malicious client

New feature:
- --proxy flag on check, graph, and lsp subcommands; also proxy: key in
  .glint.yml; overrides system HTTP_PROXY / HTTPS_PROXY env vars when set;
  cmdGraph and cmdLSP now also load .glint.yml for proxy/token/url fallbacks

New lint rule:
- GL045 (Warning): include: remote: using plain http:// instead of https://

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 21:52:57 +02:00
k3nny 4972adb213 feat(cli): add VS Code extension wrapping glint lsp
ci / vet, staticcheck, test, build (push) Failing after 2m27s
release / Build and publish release (push) Successful in 1m19s
editors/vscode/ is a TypeScript VS Code extension that starts glint lsp
as a child process and connects to it via vscode-languageclient. The
documentSelector restricts LSP processing to **/.gitlab-ci.yml so other
YAML files are unaffected. The glint.executablePath setting controls the
binary location (default: glint on PATH). Build with task ext-compile;
package as .vsix with task ext-package. Taskfile tasks added:
ext-install, ext-compile, ext-package. Root .gitignore updated to
exclude node_modules/, out/, and *.vsix from the extension directory.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 21:26:23 +02:00
k3nny 2c45b343c2 feat(lsp): add Language Server Protocol server (glint lsp)
ci / vet, staticcheck, test, build (push) Failing after 2m8s
release / Build and publish release (push) Successful in 1m7s
New internal/lsp package implements a minimal JSON-RPC 2.0 LSP server
over stdin/stdout with Content-Length framing. Supported lifecycle:
initialize → initialized → shutdown → exit. Document sync: Full (sends
complete text on every change). Handles textDocument/didOpen,
didChange, didSave, didClose; publishes textDocument/publishDiagnostics
after every change. Rule IDs surface as the diagnostic `code` field
with `"glint"` as source. Parse errors produce an Error diagnostic at
the top of the document. Include resolution is best-effort (GITLAB_TOKEN
env var; ~/.cache/glint default cache). CLI: glint lsp [--token]
[--gitlab-url] [--cache-dir] [--offline].

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 01:01:41 +02:00
k3nny 8e76caddb2 chore(build): update Go dependencies
ci / vet, staticcheck, test, build (push) Failing after 1m53s
- honnef.co/go/tools v0.7.0 → v0.8.0-rc.1 (staticcheck)
- golang.org/x/mod v0.31.0 → v0.35.0
- golang.org/x/sync v0.19.0 → v0.20.0
- golang.org/x/tools → v0.44.1-20260420
- gopkg.in/yaml.v3 promoted to direct require

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 00:22:42 +02:00
k3nny 6f8d47a8de docs(docs): convert ROADMAP from strikethrough to checkbox format
ci / vet, staticcheck, test, build (push) Failing after 1m55s
Replace ~~text~~ / ✓ shipped notation with [x] / [ ] GitHub-flavoured
markdown checkboxes throughout ROADMAP.md. Completed items are [x],
pending items are [ ]. Content and version references unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 00:16:39 +02:00
k3nny 192ab3198b feat(build): GitLab CI component, GitHub Actions action, and pre-commit hook
ci / vet, staticcheck, test, build (push) Failing after 1m52s
release / Build and publish release (push) Successful in 1m13s
templates/check.yml — GitLab CI/CD Catalog component; downloads the glint
Linux binary and runs glint check; inputs: stage, pipeline_file, version,
allow_failure, extra_args.

action.yml — GitHub Actions composite action; downloads glint into
$RUNNER_TEMP and runs glint check; inputs: version, file, args. Mirror
to github.com/k3nny/glint to use as `uses: k3nny/glint@vX.Y.Z`.

.pre-commit-hooks.yaml — language: golang hook; pre-commit builds glint
from source on first run and re-runs on staged .gitlab-ci.yml changes.
Reference as repo: https://git.k3nny.fr/k3nny/glint.

README updated with an Integrations section covering all three.
Git remote corrected to https://git.k3nny.fr/k3nny/glint.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 00:12:55 +02:00
k3nny f197c368d3 docs(linter): add .glint.yml config example
ci / vet, staticcheck, test, build (push) Failing after 2m2s
2026-06-26 00:03:39 +02:00
k3nny d6afb148ca feat(build): expand fuzz coverage to expression evaluator and linter; fix empty-key parser bug
ci / vet, staticcheck, test, build (push) Failing after 3m15s
Run fuzz tests found a real bug: a bare '?' YAML input (null mapping key)
caused ParseBytes to store p.Jobs[""] — fixed in internal/model/parser.go by
rejecting empty keys with an explicit error.

New fuzz targets added:
- FuzzEvalIf / FuzzExpandVarRefs (internal/cicontext) — exercises the
  hand-rolled recursive-descent rules:if: parser and variable expander
- FuzzLint (internal/linter) — drives the full Parse → Lint path against
  arbitrary YAML; triggers every type-assertion in the lint rules

task fuzz now runs all 5 targets sequentially (30 s each by default).
All targets clean: 90-120 s runs, 400k-3.5M executions, zero failures.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 23:58:47 +02:00
k3nny 522c637b75 fix(model): reject null/empty YAML mapping keys in ParseBytes
ci / vet, staticcheck, test, build (push) Failing after 2m43s
A bare '?' in YAML is an explicit-key indicator for a null key, which
produced a job with an empty name (p.Jobs[""]) — a parser invariant
violation caught by FuzzParseBytes.

ParseBytes now returns an error when keyNode.Value is empty.
Failing corpus entry retained as a seed so CI exercises this path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 23:43:40 +02:00
k3nny 9342ce0eff feat(build): fuzz testing and git-cliff changelog automation
ci / vet, staticcheck, test, build (push) Failing after 2m24s
release / Build and publish release (push) Successful in 1m19s
Add FuzzParseBytes and FuzzSanitizeYAMLEscapes in internal/model/fuzz_test.go.
Both targets run as regular seed-based tests in CI (go test ./...) and can be
run continuously via `task fuzz` (default 30 s per target; FUZZ_TIME=60s to
extend). Fuzz corpus failures are saved to testdata/fuzz/ for regression.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 11:02:23 +02:00
84 changed files with 15048 additions and 703 deletions
+5
View File
@@ -33,6 +33,11 @@ coverage.txt
*.swo
*~
# VS Code extension build artifacts
editors/vscode/node_modules/
editors/vscode/out/
editors/vscode/*.vsix
# OS
.DS_Store
Thumbs.db
+66
View File
@@ -0,0 +1,66 @@
# .glint.yml — glint project configuration
#
# Place this file anywhere between your .gitlab-ci.yml and the repository root.
# glint searches upward from the pipeline file and stops at the first .git
# boundary, so the repo root is the typical location.
#
# All keys are optional. Omit or comment out anything you don't need.
# ── Rule suppression ──────────────────────────────────────────────────────────
#
# Suppress rules globally for this project. Suppressed rules produce no output
# and do not affect the exit code.
#
ignore:
- GL007 # only:/except: used (migrating from legacy syntax)
- GL032 # rules:if: references undeclared variable (injected at runtime)
# ── Severity overrides ────────────────────────────────────────────────────────
#
# Override the default severity of any rule.
# Valid values: error | warning | ignore
# "ignore" is equivalent to listing the rule in `ignore:` above.
#
severity:
GL004: warning # demote "unknown stage" to warning during a stage migration
GL035: error # promote absolute-path warning to a hard error
GL007: ignore # equivalent to adding GL007 to ignore:
# ── Extra stage names ─────────────────────────────────────────────────────────
#
# Declare stage names that are valid for this project beyond what is listed in
# the pipeline's own `stages:` block. Jobs referencing these stages will not
# be flagged by GL004. Useful when stages are defined in a shared parent
# template that glint cannot reach.
#
stages:
- quality
- security
- compliance
# ── GitLab token ──────────────────────────────────────────────────────────────
#
# Default personal access token (read_api scope) used to fetch `include:
# project:` templates. This is the lowest-priority token source; it is
# overridden by the --token flag and the GITLAB_TOKEN / CI_JOB_TOKEN /
# GITLAB_PRIVATE_TOKEN environment variables.
#
# Avoid committing real tokens — use environment variables instead.
#
# token: glpat-xxxxxxxxxxxxxxxxxxxx
# ── GitLab instance URL ───────────────────────────────────────────────────────
#
# Default GitLab instance URL, used when fetching project: and component:
# includes. Overridden by --gitlab-url, CI_SERVER_URL, and GITLAB_URL.
#
# url: https://gitlab.example.com
# ── Include cache directory ───────────────────────────────────────────────────
#
# Default directory for caching fetched remote includes (project: and
# component:). The directory is created on first use. Overridden by
# --cache-dir. When --offline is given without --cache-dir, glint defaults
# to ~/.cache/glint regardless of this setting.
#
# cache_dir: ~/.cache/glint
+11
View File
@@ -0,0 +1,11 @@
- id: glint
name: glint — validate GitLab CI pipeline
description: >-
Lint .gitlab-ci.yml with glint before committing. Catches misconfigured
stages, invalid keywords, broken needs: graphs, deprecated patterns, and
more — without a GitLab server.
entry: glint check
language: golang
files: '(^|/)\.gitlab-ci\.yml$'
pass_filenames: true
minimum_pre_commit_version: '3.0.0'
+148
View File
@@ -5,6 +5,154 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
This project uses [Semantic Versioning](https://semver.org).
## [0.3.0] - 2026-06-26
### Added
- **`glint render` subcommand** — resolves all `include:` and `extends:` chains and writes the fully flattened pipeline to a single YAML file (default: `rendered.gitlab-ci.yml`; use `--output -` for stdout). Strips the consumed `include:` and `extends:` keys; retains template jobs (`.name`). Accepts the same network flags as `glint check` (`--token`, `--gitlab-url`, `--cache-dir`, `--offline`, `--proxy`). Useful for inspecting what GitLab CI actually sees or running further local tooling.
- **`glint check --no-warn`** — discard all warning findings before output and exit-code calculation. Mixed pipelines (errors + warnings) still exit 2 but only errors are printed. Warnings-only pipelines report "OK" and exit 0.
- **`glint graph --no-skipped`** — remove jobs that evaluate to `skipped` in the given context (or the implicit `branch=main` default) from all graph output: tree, SVG, HTML, and Mermaid.
- **Colorized, columnized text output** — `glint check` (text format) now renders findings in four aligned columns: location, rule ID, severity, message. `error` is printed in bold red; `warning` in bold orange. Colors are auto-detected (stdout must be a terminal) and suppressed when `NO_COLOR` is set. Location uses `file:line:col` format when column information is available.
- **`line:col` locations** — `Column int` added to `model.Job` (set from the YAML parser's `yaml.Node.Column`) and propagated to `Finding.Column` across all linter rules. Plain-text and `Finding.String()` now emit `file:line:col` when column is known.
### Changed
- **Exit codes** *(breaking)*`glint check` now exits `2` when one or more error findings are present (previously `1`) and `10` when findings contain only warnings. Exit `0` remains for a clean pipeline. Errors take precedence over warnings. Scripts that test `[ $? -eq 1 ]` need to be updated to `[ $? -eq 2 ]`.
## [0.2.31] - 2026-06-26
### Added
- **Proxy support** — `--proxy <URL>` flag on `glint check`, `glint graph`, and `glint lsp`; also configurable via `proxy:` in `.glint.yml`. When set it takes precedence over `HTTP_PROXY` / `HTTPS_PROXY` env vars; when unset, system proxy settings are honoured automatically. Covers all remote include fetches and GitLab API calls.
- **GL045: HTTP remote include warning** — new pipeline-level lint rule that warns when `include: remote:` uses a plain `http://` URL. CI templates fetched over unencrypted HTTP are at risk of in-transit tampering; `https://` is always preferred.
### Fixed
- **Path traversal in local includes** — `include: local:` paths containing `../` sequences that would escape the repository root (e.g. `../../etc/passwd`) are now rejected with a warning instead of reading arbitrary files from the host.
- **HTTP timeout on remote fetches** — all HTTP calls in `internal/fetcher` now use a 30-second timeout; previously the client had no timeout and could hang indefinitely on slow or unresponsive servers.
- **Unbounded response size** — remote include and GitLab API responses are now capped at 10 MiB using `io.LimitReader`; previously an arbitrarily large response could exhaust process memory.
- **Cache file permissions** — the cache directory is created with mode `0700` (was `0755`) and cache files with `0600` (was `0644`), preventing other local users from reading cached GitLab tokens or pipeline content.
- **LSP Content-Length DoS** — `glint lsp` now rejects incoming messages whose `Content-Length` header exceeds 64 MiB, preventing memory exhaustion from a malicious or misbehaving LSP client.
## [0.2.30] - 2026-06-26
### Added
- **VS Code extension** (`editors/vscode/`) — TypeScript extension that starts `glint lsp` as a language server and connects to it via `vscode-languageclient`. Activates on YAML files; the `documentSelector` restricts LSP processing to `**/.gitlab-ci.yml` so other YAML files are unaffected. Inline error/warning squiggles appear on every save or edit. The `glint.executablePath` setting controls the binary path (default: `glint` on `PATH`). Build: `task ext-compile`; package as `.vsix`: `task ext-package`.
## [0.2.29] - 2026-06-26
### Added
- **LSP server** (`glint lsp`) — new `internal/lsp` package and `glint lsp` subcommand that starts a Language Server Protocol server over stdin/stdout using Content-Lengthframed JSON-RPC 2.0. Editors (VS Code, Neovim, Emacs, JetBrains, etc.) can connect with any generic LSP client configuration. Supported methods: `initialize`, `initialized`, `shutdown`, `exit`, `textDocument/didOpen`, `textDocument/didChange`, `textDocument/didSave`, `textDocument/didClose`. On every document open or change the server runs the full glint lint pipeline and publishes diagnostics via `textDocument/publishDiagnostics`; each diagnostic carries the rule ID as its `code` field and `"glint"` as `source`. Parse errors are surfaced as an Error diagnostic at the top of the document. Include resolution is best-effort (uses `GITLAB_TOKEN` / `GITLAB_URL` env vars; default cache dir `~/.cache/glint`). CLI flags: `--token`, `--gitlab-url`, `--cache-dir`, `--offline`.
## [0.2.28] - 2026-06-26
### Added
- **Pre-commit hook** — `.pre-commit-hooks.yaml` defines a `glint` hook with `language: golang`; pre-commit builds glint from source automatically on first run and re-runs `glint check` on any staged `.gitlab-ci.yml` changes. Reference: `repo: https://git.k3nny.fr/k3nny/glint, rev: v0.2.28`.
- **GitLab CI component** (`templates/check.yml`) — a GitLab CI/CD Catalogcompatible component that downloads the glint Linux binary and runs `glint check` as a pipeline job. Accepts inputs: `stage` (default `validate`), `pipeline_file` (default `.gitlab-ci.yml`), `version` (default `latest`), `allow_failure` (default `false`), and `extra_args`. Can also be used as a plain local or remote include without the Catalog.
- **GitHub Actions composite action** (`action.yml`) — downloads the glint Linux binary into `$RUNNER_TEMP`, adds it to `$GITHUB_PATH`, and runs `glint check`. Inputs: `version`, `file`, `args`. Mirror this repository to GitHub as `k3nny/glint` to reference it as `uses: k3nny/glint@v0.2.28`.
## [0.2.27] - 2026-06-25
### Added
- **Fuzz testing** — `FuzzParseBytes` and `FuzzSanitizeYAMLEscapes` in `internal/model/fuzz_test.go` verify that neither the YAML parser nor the escape sanitizer panics on arbitrary input. Successful parses are also checked for structural integrity (non-nil pipeline, no empty job names). Run with `task fuzz` (default 30 s per target; set `FUZZ_TIME=60s` to extend). Found failures are saved to `testdata/fuzz/` for regression.
- **Changelog automation** — `cliff.toml` configures [git-cliff](https://git-cliff.org) to generate Keep-a-Changelogcompatible release notes from Conventional Commits. `task changelog` regenerates `CHANGELOG.md` from the full git history; `task changelog-next` previews only unreleased commits without writing. Install git-cliff with `brew install git-cliff` or `cargo install git-cliff`.
## [0.2.26] - 2026-06-25
### Changed
- **Same-stage job ordering** — when jobs within the same declared GitLab stage have `needs:` relationships between each other, the pipeline graph now splits that stage into topological sub-columns: jobs with no same-stage dependencies occupy the leftmost sub-column; jobs that depend on them are placed one sub-column to the right. Sub-columns use a narrower 20 px gap (vs. the 50 px gap between stages), and the stage header spans all sub-columns. Stages with no intra-stage `needs:` are unaffected.
- **Graph links rendered behind job chips** — SVG connector lines (Bézier curves in DAG mode, bus-bar stubs in classic mode) are now drawn before the job chip rectangles, so connector lines pass behind chips rather than on top of them.
## [0.2.25] - 2026-06-25
### Added
- **`when: on_failure` visual distinction** — SVG and HTML pipeline graphs now render failure-path jobs with a red status circle (`#d9534f`), an X-mark icon, and a dashed chip border. A new `on_failure` legend entry and Mermaid `classDef` entry are included.
- **Job tooltip / detail panel** — every job chip in SVG and HTML output is wrapped in a `<g data-job="…"><title>…</title><desc>…</desc>` group. SVG viewers that support `<title>` show the job name on hover; `<desc>` carries `stage`, `when`, `image`, and `needs` details. In HTML output the sidebar reads these directly from the DOM.
- **Multi-job connector accuracy** — classic mode (no `needs:`) now uses a bus-bar connector: horizontal stubs from every job in stage N to a vertical rail at the midpoint, then stubs from the rail to every job in stage N+1. The previous single center-to-center line left top and bottom jobs visually disconnected.
- **Skipped / blocked state colouring** — `glint graph pipeline` now accepts the same context flags as `glint check` (`--branch`, `--tag`, `--source`, `--var`, `--changes`, `--changes-from`). Jobs that evaluate to `JobSkipped` in the given context are rendered with a grey circle (`#868686`) and dimmed job name. When no context flags are given, all jobs render with their normal colours.
- **Interactive HTML output** (`--format html`) — `glint graph pipeline --format html` writes a self-contained `.html` file to `--out`. The page embeds the SVG inline with mouse-wheel zoom (centred on cursor), drag-to-pan, double-click-to-reset, and a collapsible sidebar showing job details on chip click. No external dependencies; works offline.
- **Mermaid pipeline output** (`--format mermaid`) — `glint graph pipeline --format mermaid` prints the existing Mermaid flowchart (`pipeline.go`) to stdout. Suitable for pasting into [mermaid.live](https://mermaid.live) or embedding in Markdown documentation.
- **`imageString` helper** — internal utility that extracts the image name from a `Job.Image` field that may be a plain string or a map (`image: {name: ..., entrypoint: ...}`); used by the tooltip desc builder.
## [0.2.24] - 2026-06-25
### Added
- **`rules:needs:` validation (GL044)** — validates jobs listed in `rules:needs:` overrides (GitLab CI 16.4+). Each entry must reference a job that exists in the pipeline; `optional: true` entries that reference missing jobs are downgraded to warnings (same behaviour as GL027 for top-level `needs:`). Cross-pipeline needs (maps with a `pipeline:` key) are ignored. Skipped jobs are excluded when a context is provided. `glint explain GL044` documents the rule with a bad-YAML example and fix.
- **`Rule.Needs` model field** — `rules:` entries now parse their `needs:` key into `model.Rule.Needs []any`, making the per-rule needs list available for validation and future evaluation.
## [0.2.23] - 2026-06-25
### Added
- **Context-scoped linting** — when a context is supplied via `--branch`, `--tag`, `--source`, or `--var`, `glint check` now evaluates every job against that context before running lint rules. Jobs that are statically unreachable in the given context (i.e. their `rules:` block resolves to `JobSkipped`) are excluded from `needs:` and `dependencies:` cross-job checks (GL027GL031). This eliminates false-positive errors for jobs that are intentionally gated to specific pipeline events (e.g. a deploy job with `rules: [{if: '$CI_COMMIT_TAG != ""'}]` does not generate a GL027 error on branch pipelines). Filtering applies only in single-context mode; multi-context mode (`--context`) always checks all jobs.
## [0.2.22] - 2026-06-25
### Added
- **Multi-context simulation** — `glint check` now accepts a repeatable `--context KEY=VALUE[,...]` flag. Each `--context` invocation defines one simulation context; when two or more are given, glint evaluates every pipeline job across all contexts and prints a side-by-side comparison table with `active`, `manual`, `skipped`, or `blocked` (when `workflow:rules:` would prevent the pipeline from starting) per column. Known context keys are `branch`, `tag`, and `source` (case-insensitive); any other `KEY=VALUE` pair is treated as a CI variable override. The `--changes` / `--changes-from` flags work alongside `--context` and the changed-file list is shared across all contexts. Implicit `--branch main --source push` defaults are skipped when `--context` is given.
## [0.2.21] - 2026-06-21
### Added
- **`rules:changes:` evaluation** — `glint check` and `glint graph` now evaluate `rules:changes:` conditions when file-change data is provided. Two new flags on both subcommands:
- `--changes <PATH>` — mark one or more file paths as changed (repeatable).
- `--changes-from <REF>` — run `git diff --name-only <REF>` to determine changed files automatically (e.g. `--changes-from origin/main`).
Both flags can be combined. Glob patterns in `rules:changes:` support `*` (within a path segment) and `**` (across segments). When neither flag is given the condition is treated as always matching (permissive), preserving the existing behaviour. The extended map form `{ paths: [...], compare_to: ... }` is also supported.
- **`workflow:rules:changes:` evaluation** — `workflow:rules:` entries now also evaluate `changes:` patterns when changed-file data is available, consistent with job rules.
- **Context summary includes changed files** — the `Context:` line printed by `glint check --format text` now shows the count of changed files when `--changes`/`--changes-from` is given (e.g. `branch=main, source=push, 3 changed file(s)`).
- **Unit test suite** — 100% statement coverage across all packages (`cmd/glint`, `internal/cicontext`, `internal/fetcher`, `internal/graph`, `internal/linter`, `internal/model`, `internal/resolver`).
### Changed
- **Internal:** replaced all `os.Exit` calls in `cmd/glint` with an `exit` variable to enable unit testing without process termination; no behaviour change.
- **Internal:** removed unreachable code paths found during coverage analysis — dead guard in `cicontext.parseRegexLiteral`, unreachable `len(jobs) == 0` branch in `graph.Pipeline`, and the `skipWin` struct field / dead `continue` in `graph.convertToPNG`; `pipelineSVG` return type simplified from `(string, error)` to `string` as it never returned a non-nil error. No behaviour change.
## [0.2.20] - 2026-06-14
### Added
- **`glint explain <RULE>`** — new subcommand that prints the description, rationale, a bad-YAML example, and the corrected fix for any rule ID (GL001GL043). `glint explain` with no argument lists all rules with ID, severity, and title. Rule IDs are case-insensitive (`gl007` and `GL007` are equivalent).
- **`rules:if:` evaluated reachability (GL042)** — warns when every `rules:if:` condition in a job evaluates to false using the values of variables declared in the pipeline YAML, meaning the job can never be included in a pipeline run. The check is conservative: it only fires when all variables referenced in the `if:` expressions are explicitly declared in the YAML; predefined `CI_*` / `GITLAB_*` variables and undeclared variables are treated as unknown (may have any runtime value) to avoid false positives.
- **`inherit:` completeness (GL043)** — warns when a job declares `inherit: default:` (boolean or list form) but the pipeline has no `default:` block, making the declaration a no-op. Also warns when the list form names fields (e.g. `[image, before_script]`) that are not actually set in the `default:` block — those list entries have no effect.
- **`FEATURES.md`** — full feature reference extracted from the README: all 43 lint rules in categorised tables, include resolution capabilities, context simulation details, output format schemas, configuration reference, graph visualisation modes, and developer tools. The README `## Features` section replaced with a compact 6-line `## What it does` summary with a link to `FEATURES.md`.
- **`USAGE.md`** — complete command-by-command usage reference extracted from the README: `glint check` with all output formats and examples, context simulation flags and predefined variable table, remote includes and token resolution, cache and offline mode, component reference format, `glint graph` modes, `glint explain` usage, project config (`.glint.yml`), and inline suppression. The README `## Usage` section replaced with the commands block and a single link to `USAGE.md`.
## [0.2.19] - 2026-06-14
### Added
+324
View File
@@ -0,0 +1,324 @@
# 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` `GL045`) 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:` |
| GL045 | WARN | `include: remote:` uses plain `http://` — CI templates fetched unencrypted; prefer `https://` |
### Job structure
| ID | Sev | Rule |
|----|-----|------|
| GL003 | ERR | Job missing required `script:` (or `run:`) |
| GL004 | ERR | Job `stage:` references a stage not declared in `stages:` |
| GL005 | ERR | `only:` and `rules:` used together |
| GL006 | ERR | `except:` and `rules:` used together |
| GL007 | WARN | `only:` / `except:` used (deprecated; prefer `rules:`) |
### Keyword constraints
| ID | Sev | Rule |
|----|-----|------|
| GL008 | ERR | `when:` has an invalid value |
| GL009 | ERR | `when: delayed` without `start_in:` |
| GL010 | ERR | `start_in:` set but `when:` is not `delayed` |
| GL011 | ERR | `parallel:` integer not in range 2200, or map form missing `matrix:` |
| GL012 | ERR | `retry:` integer not in range 02, or `retry.max` out of range |
| GL013 | ERR | `retry.when:` contains an unrecognised failure type |
| GL014 | ERR | `allow_failure:` is not a boolean or a map with `exit_codes:` |
| GL015 | ERR | `interruptible:` is not a boolean |
| GL016 | ERR | Trigger job also defines `script:` |
| GL017 | ERR | `trigger:` map missing `project:` or `include:` |
| GL018 | ERR | `coverage:` is not a regex wrapped in `/…/` |
| GL019 | ERR | `release:` missing required `tag_name:`, or is not a map |
| GL020 | ERR | `environment.url` set without `environment.name`, or invalid `action:` |
| GL021 | ERR | `artifacts.when` invalid, or `expose_as` set without `paths` |
| GL022 | WARN | `pages` job `artifacts.paths` does not include `public/` |
| GL023 | ERR | `cache.when` or `cache.policy` has an invalid value |
| GL024 | ERR | `rules[*].when` has an invalid value |
| GL025 | ERR | `image:` map form missing `name:` |
| GL026 | ERR | `inherit.default` / `inherit.variables` is not a boolean or list |
| GL034 | ERR | `services:` map form missing `name:`, or `alias:` is not a valid DNS label |
| GL036 | ERR | `timeout:` is not a valid GitLab CI duration string |
| GL037 | ERR | `id_tokens:` entry missing required `aud:` |
| GL038 | ERR | `secrets:` entry missing a provider key (`vault`, `gcp_secret_manager`, `azure_key_vault`) |
| GL039 | WARN | Job uses `pages:` keyword but `artifacts.paths` doesn't include the publish directory |
| GL041 | WARN | `cache.key.files` entry looks like a glob; must be an exact file path |
### Cross-job graph
| ID | Sev | Rule |
|----|-----|------|
| GL027 | ERR/WARN | `needs:` references a job that doesn't exist (WARN when `optional: true`) |
| GL028 | ERR | `needs:` references a job in a later stage |
| GL029 | ERR | Circular dependency in `needs:` graph |
| GL030 | ERR | `dependencies:` references a job that doesn't exist |
| GL031 | ERR | `dependencies:` references a job in the same or a later stage |
| GL044 | ERR/WARN | `rules:needs:` references a job that doesn't exist (WARN when `optional: true`) |
### Expression & reachability
| ID | Sev | Rule |
|----|-----|------|
| GL032 | WARN | `rules:if:` references `$VAR` not declared in any `variables:` block (may be false-positive for project-setting variables) |
| GL033 | WARN | Every rule in `rules:` has `when: never` — job permanently excluded (provable without evaluating `if:` expressions) |
| GL035 | WARN | `rules:changes` / `rules:exists` path is absolute — absolute paths never match in GitLab CI |
| GL042 | WARN | Every `rules:if:` condition evaluates to false given the declared variable values — job statically unreachable (only fires when all referenced variables are declared in YAML) |
### Inheritance
| ID | Sev | Rule |
|----|-----|------|
| GL043 | WARN | `inherit: default:` declared but the pipeline has no `default:` block (dead declaration), or the list form names fields not set in `default:` |
### Hidden jobs
Jobs whose name starts with `.` are reusable templates; most rules are skipped for them. This matches GitLab CI's own behaviour.
---
## Include resolution
| Capability | Notes |
|------------|-------|
| `include: local:` | Read from disk, recursively merged before linting |
| `include: remote:` | HTTPS URL fetched unauthenticated; unreachable URLs produce a warning, linting continues |
| `include: project:` | Fetched from the GitLab REST API using the configured token; skipped with a warning when no token is available |
| `include: component:` | Fetched from the GitLab CI/CD Catalog; public components work without a token |
| `include: inputs:` | `$[[ inputs.KEY ]]` and `$[[ inputs.KEY \| default(…) ]]` placeholders substituted from the `with:` block before parsing |
| Depth limit | Include chains capped at 100 levels (matches GitLab); circular cross-file references detected via visited-set tracking |
| Offline mode | `--offline` serves all remote includes from the cache; missing entries produce a warning |
| Include cache | `--cache-dir DIR` (or `~/.cache/glint` by default with `--offline`) persists fetched templates; keyed by SHA-256 of the request coordinates |
**Token resolution order** (first non-empty wins):
| Source | Header |
|--------|--------|
| `--token` flag / `GITLAB_TOKEN` env | `PRIVATE-TOKEN` |
| `CI_JOB_TOKEN` env | `JOB-TOKEN` |
| `GITLAB_PRIVATE_TOKEN` env | `PRIVATE-TOKEN` |
**URL resolution order:** `--gitlab-url` flag → `CI_SERVER_URL` env → `GITLAB_URL` env → `https://gitlab.com`
---
## Context simulation
Pass `--branch`, `--tag`, `--source`, or `--var` to evaluate `rules:if:` and
`only`/`except` filters against a specific pipeline event. When no context flag
is given glint defaults to `--branch main --source push` so that `rules:if:`
expressions are always evaluated.
**Evaluated at lint time:**
- `rules:if:` — full expression language: `==`, `!=`, `=~`, `!~`, `&&`, `||`, `!`, `(…)`, `$VAR`/`${VAR}`, string literals, `null`, regex flags (`/pat/i`)
- `only:` / `except:` — ref keywords, branch-name globs, and `/regex/` patterns
- `workflow:rules:` — evaluated to determine whether the pipeline would run; matching rule's `variables:` are injected before job evaluation
- `rules:changes:` — path-glob patterns evaluated against the supplied changed-file list (see `--changes` / `--changes-from` flags); `*` matches within a segment, `**` crosses `/` boundaries; the extended `{paths: [...], compare_to: ...}` form is supported; without changed-file data the condition is always treated as matching (permissive)
- Variable expansion — `$VAR` / `${VAR}` references in variable values expanded after all sources merge; transitive chains resolved (up to 10 passes)
**Not evaluated** (no git tree at lint time): `rules:exists:`.
**Predefined variables** set by shortcut flags:
| Flag | Variables populated |
|------|---------------------|
| `--branch NAME` | `CI_COMMIT_BRANCH`, `CI_COMMIT_REF_NAME`, `CI_COMMIT_REF_SLUG`, `CI_PIPELINE_SOURCE=push` |
| `--tag NAME` | `CI_COMMIT_TAG`, `CI_COMMIT_REF_NAME`, `CI_COMMIT_REF_SLUG`, `CI_PIPELINE_SOURCE=push`; clears `CI_COMMIT_BRANCH` |
| `--source EVENT` | `CI_PIPELINE_SOURCE` |
| `--var KEY=VALUE` | any variable; overrides shortcuts; repeatable |
Use `--list-vars` to print the resolved variable table to stderr.
**Changed-file flags** (for `rules:changes:` evaluation):
| Flag | Effect |
|------|--------|
| `--changes PATH` | Mark PATH as changed; repeatable |
| `--changes-from REF` | Run `git diff --name-only REF` to auto-detect changed files |
**Context-scoped linting** (single-context mode):
When a context is given, jobs evaluated as `skipped` are excluded from `needs:` and `dependencies:` cross-job checks (GL027GL031). This eliminates false positives for jobs that are intentionally gated to specific pipeline events:
```yaml
deploy-job:
needs: [build-job] # GL027 suppressed on branch pipelines; only checked on tag pipelines
rules:
- if: '$CI_COMMIT_TAG != ""'
```
**Multi-context comparison** (`--context`):
Pass `--context KEY=VALUE[,...]` (repeatable) instead of `--branch`/`--tag`/`--source` to evaluate every job across multiple contexts simultaneously. Each `--context` flag defines one column; glint prints a table showing `active`, `manual`, `skipped`, or `blocked` per job per context.
Known context keys: `branch`, `tag`, `source` (case-insensitive). Any other `KEY=VALUE` pair is injected as a CI variable override. The `--changes`/`--changes-from` file list is shared across all contexts.
```
glint check --context branch=main --context branch=develop .gitlab-ci.yml
Context comparison:
JOB branch=main branch=develop
---------- ----------- -------------
build-job active active
deploy-job active skipped
test-job active active
```
---
## Output formats
Pass `--format` to `glint check`. In structured formats the summary line is
written to stderr so stdout contains only the machine-readable payload.
| Format | Flag | Description |
|--------|------|-------------|
| Text (default) | `--format text` | Ruff-style `file:line: RULE [sev] message` |
| JSON | `--format json` | Stable schema (version 1); `findings` array + `summary` block |
| SARIF 2.1.0 | `--format sarif` | Consumed by GitHub Code Scanning and GitLab SAST |
| JUnit XML | `--format junit` | CI test-report artifact (`artifacts:reports:junit`) |
| GitHub annotations | `--format github` | `::error file=…,line=…,title=RULE::message` inline PR comments |
**JSON schema (`schema_version: 1`):**
```json
{
"schema_version": 1,
"glint_version": "v0.2.20",
"pipeline": ".gitlab-ci.yml",
"findings": [
{"rule":"GL004","severity":"error","file":".gitlab-ci.yml","line":14,
"job":"deploy","message":"stage \"production\" is not defined in 'stages'"}
],
"summary": {"total": 1, "errors": 1, "warnings": 0}
}
```
---
## Configuration
### `.glint.yml` project config file
Searched from the pipeline file's directory upward to the first `.git` boundary.
```yaml
# Suppress rules globally for this project.
ignore:
- GL007 # migrating from only:/except:
- GL032 # dynamic variables injected by CI
# Override rule severity (error | warning | ignore).
# 'ignore' is equivalent to listing the rule in ignore:.
severity:
GL004: warning # demote during a stage migration
GL035: error # promote to hard error for this project
# Extra stage names valid beyond those in the pipeline's own stages: block.
stages:
- quality
- security
# Default GitLab token (lower priority than --token and GITLAB_TOKEN).
token: glpat-xxxx
# Default GitLab instance URL.
url: https://gitlab.example.com
# Default cache directory.
cache_dir: ~/.cache/glint
# HTTP proxy for remote includes and GitLab API calls.
# Overrides HTTP_PROXY / HTTPS_PROXY env vars when set.
# Leave empty to use system proxy settings.
proxy: http://proxy.example.com:8080
```
**Priority chain:** `--token`/`--gitlab-url`/`--proxy` flags > `.glint.yml` > environment variables.
### Inline suppression (`# glint: ignore`)
Suppress a finding for a specific job by placing a comment immediately before it:
```yaml
# glint: ignore GL007
legacy-job:
only: [main]
script: echo ok
# Multiple rules (comma- or space-separated):
# glint: ignore GL007, GL032
other-job:
script: echo ok
# Suppress every rule for this job:
# glint: ignore all
noisy-job:
script: echo ok
```
Suppressions are scoped to the single job they precede and do not affect other
jobs or pipeline-level findings.
---
## Graph visualization (`glint graph`)
| Mode | Output |
|------|--------|
| `tree` (default) | Terminal job tree: stages as branches, jobs as leaves; annotated with `[manual]`, `[delayed]`, `[trigger]` where applicable |
| `includes` | Mermaid flowchart to stdout; colour-coded nodes by include type (local, remote, project, component, template) |
| `pipeline` | GitLab CI-style SVG/PNG written to `--out` directory (default: `glint-out/`); converted to PNG when `rsvg-convert`, `inkscape`, or `magick` is available |
| `all` | `includes` to stdout + `pipeline` file path to stderr |
**`glint graph pipeline --format <FORMAT>`**
| Format | Effect |
|--------|--------|
| `svg` (default) | Write GitLab CI-style SVG/PNG to `--out` |
| `mermaid` | Print Mermaid flowchart to stdout (paste into [mermaid.live](https://mermaid.live)) |
| `html` | Write self-contained HTML to `--out` with mouse pan/zoom and a click-to-open job-detail sidebar |
**Visual distinctions in SVG and HTML output:**
- **Regular** — blue circle with checkmark
- **Manual** — orange circle with play triangle
- **Trigger** — purple circle with chevron
- **Delayed** — yellow circle with clock
- **`when: on_failure`** — red circle (`#d9534f`) with X mark; dashed chip border
- **Skipped** (with `--branch`/`--tag`/`--source` context flags) — grey circle, dimmed job name
In DAG pipelines (any job has `needs:`) the pipeline graph uses job-to-job
Bézier connectors. In classic mode a bus-bar pattern (vertical rail + per-job
stubs) accurately connects every job across adjacent stages.
Each chip carries a `<title>` and `<desc>` with stage, when, image, and needs —
shown as a tooltip in SVG viewers and as a sidebar panel in HTML output.
---
## Developer tools
| Tool | Description |
|------|-------------|
| `glint explain <RULE>` | Print description, rationale, bad-YAML example, and fix for a rule. Case-insensitive (`gl007` = `GL007`). |
| `glint explain` | List all rules with ID, severity, and title. |
| `--list-vars` | Print all resolved pipeline variables (pipeline + workflow rules + context) to stderr before linting. |
| `--version` / `-v` | Print the compiled version string. |
+72 -398
View File
@@ -6,50 +6,26 @@
<p align="center">
<a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache%202.0-blue.svg" alt="License"></a>
<a href="CHANGELOG.md"><img src="https://img.shields.io/badge/release-v0.2.19-blue.svg" alt="Release"></a>
<a href="CHANGELOG.md"><img src="https://img.shields.io/badge/release-v0.3.0-blue.svg" alt="Release"></a>
</p>
> **Disclaimer:** This tool was built through iterative AI-assisted development with [Claude](https://claude.ai). It is experimental, incomplete, and not intended for production use. Coverage of GitLab CI keywords is best-effort and may lag behind GitLab's evolving spec. Use it at your own discretion — no correctness guarantees are made. Contributions and bug reports are welcome.
A local tool to validate and lint `.gitlab-ci.yml` pipelines without needing a GitLab server.
## Features
## What it does
- **YAML validation** — detects malformed pipeline files early
- **Stage validation** — every job's `stage` must be declared in `stages`
- **`extends:` resolution** — resolves single and multi-level template inheritance before linting, so derived jobs are evaluated against their fully merged definition
- **`needs:` DAG validation** — checks that `needs:` references exist, respect stage ordering, and contain no circular dependencies
- **`dependencies:` validation** — checks that artifact dependency references exist and are in earlier stages
- **Keyword validation** — validates constraints on `when`, `parallel`, `retry`, `allow_failure`, `trigger`, `artifacts`, `cache`, `release`, `environment`, `coverage`, `rules`, and more
- **Remote project includes** — fetches `include: project:` templates from the GitLab API so extends/needs can be validated against the full merged pipeline
- **CI/CD catalog components** — resolves `include: component:` references from the GitLab CI/CD Catalog; public components work without a token
- **Deprecation warnings** — flags `only`/`except` usage in favour of `rules`
- **Local include resolution** — `include: local:` entries are read from disk and recursively merged before linting, so multi-file pipelines are fully validated
- **Extended variable declarations** — `variables:` entries may use the `{value, description, options}` map form (GitLab CI 13.7+); `default.image` accepts both string and map form; `rules.changes`/`rules.exists` accept both list and `{paths, compare_to}` map form
- **Graph output** — `glint graph` prints a job tree (stages → jobs) to the terminal; `glint graph includes` emits a Mermaid include dependency diagram; `glint graph pipeline` renders a GitLab CI-style PNG/SVG
- **Context simulation** — pass `--branch`, `--tag`, or `--source` to `glint check` or `glint graph` to see which jobs would be active, manual, or skipped for a specific pipeline event; evaluates `rules:if:` expressions and `only`/`except` filters
- **Variable expansion** — `$VAR` and `${VAR}` references inside variable values are expanded after all sources are merged (pipeline defaults → workflow-rule overrides → CLI flags); transitive chains resolve automatically; visible via `--list-vars`
- **Non-string variable scalars** — `BUILD: true`, `RETRIES: 3` and other bare boolean/integer variable values are handled correctly throughout: they render in `--list-vars` output and are injected into the evaluation context as their string equivalents, matching GitLab CI's behaviour
- **Static reachability (GL033)** — warns when a job's `rules:` block can never activate: if every rule has `when: never` the job is permanently excluded from any pipeline run, provable without evaluating any `if:` expressions
- **`services:` validation (GL034)** — map form requires a `name` key; `alias` must be a valid DNS label (letters, digits, hyphens, dots; no leading/trailing hyphens)
- **`rules:changes` / `rules:exists` glob safety (GL035)** — warns when paths are absolute (start with `/`), which can never match since GitLab CI paths are always relative to the repository root
- **`timeout:` format (GL036)** — validates that job and `default:` timeout values are valid GitLab CI duration strings (`1h 30m`, `90 minutes`, `2 hours`, etc.)
- **`id_tokens:` validation (GL037)** — each OIDC token entry must have an `aud` key (missing `aud` is a GitLab API error at runtime)
- **`secrets:` validation (GL038)** — each secret entry must declare exactly one provider (`vault`, `gcp_secret_manager`, or `azure_key_vault`)
- **`pages:` keyword + `artifacts.paths` (GL039)** — warns when a job uses the `pages:` keyword but `artifacts.paths` does not include the publish directory (default: `public`)
- **Duplicate stage names (GL040)** — warns when a stage name appears more than once in `stages:`; GitLab silently merges duplicates, which can cause confusing ordering
- **`cache.key.files` glob detection (GL041)** — warns when `cache.key.files` entries contain glob metacharacters; this field requires exact file paths, not patterns
- **Recursive include depth limit** — include chains are capped at 100 nesting levels (matching GitLab's own limit); project and component includes are now tracked in the visited-file set to prevent cross-include cycles
- **`include: inputs:` substitution** — when a `component:` entry has a `with:` block, all `$[[ inputs.KEY ]]` and `$[[ inputs.KEY | default(…) ]]` placeholders in the fetched template are substituted before parsing, so component-scoped jobs get their correct `stage:` and keyword values instead of `$[[…]]` placeholders
- **Offline mode + include cache** — pass `--cache-dir DIR` to cache fetched remote templates (project: and component: includes) to disk; `--offline` serves entirely from the cache without making network calls
- **Structured output formats** — `--format json` emits a stable JSON report; `--format sarif` emits SARIF 2.1.0 (consumed by GitHub Code Scanning and GitLab SAST); `--format junit` emits JUnit XML (consumable as a CI test-report artifact); `--format github` emits GitHub Actions annotation lines (`::error file=…::`) so findings appear as inline PR comments
- **`.glint.yml` project config** — rule suppression (`ignore: [GL007]`), severity overrides (`severity: {GL004: warning}`), extra stages allowlist (`stages: [quality]`), and default token/URL/cache-dir so flags are not needed on every invocation
- **Inline suppression comments** — `# glint: ignore GL007` (or `# glint: ignore all`) immediately before a job definition suppresses the specified rule(s) for that job without touching other jobs
- **Sorted findings output** — findings are sorted by source file then line number, so all issues from the same file appear together in order; pipeline-level findings (no file) sort first
- **Consistent ruff-style warnings** — all warnings (unresolvable includes, skipped extends chains, workflow non-start) use the same `path: [warning] message` format as lint findings
- **`--version` / `-v` flag** — prints the compiled version string (e.g. `glint v0.2.14`); the version is also shown at the top of every `--help` output
- **Lints** — 45 rules covering pipeline structure, keyword constraints, `needs:`/`dependencies:` graphs, expression reachability, and deprecations (GL001GL045); run `glint explain <ID>` for any rule
- **Resolves includes** — local files, HTTPS URLs, GitLab project templates, and CI/CD Catalog components, with offline cache support and HTTP proxy support (`--proxy` flag or `proxy:` in `.glint.yml`)
- **Renders merged pipeline** — `glint render` resolves all includes and `extends:` chains into a single flat YAML file, matching what GitLab CI actually processes
- **Simulates context** — `--branch`, `--tag`, `--source` flags evaluate `rules:if:` and `only`/`except` to show which jobs would be active, manual, or skipped; `--context branch=main --context branch=develop` prints a multi-column comparison table across multiple contexts in one run
- **Multiple output formats** — `--format text` (default, colorized and column-aligned), `json`, `sarif` (GitHub Code Scanning / GitLab SAST), `junit`, `github` (PR annotations); exits `2` on errors, `10` on warnings only
- **Project config** — `.glint.yml` for rule suppression, severity overrides, token/URL/proxy defaults; `# glint: ignore RULE` for per-job inline suppression; `--no-warn` flag to suppress all warnings
- **Graph visualization** — `glint graph` prints a terminal job tree; `glint graph pipeline` renders a GitLab CI-style SVG/PNG; `--format mermaid` emits a Mermaid flowchart; `--format html` produces a self-contained HTML file with pan/zoom and a job-detail sidebar; `--no-skipped` hides jobs that would not run in the given context
- **LSP server** — `glint lsp` starts a Language Server Protocol server over stdin/stdout; connect with any LSP client to get inline diagnostics (rule ID as code, error/warning severity) in VS Code, Neovim, Emacs, JetBrains, etc.
- **VS Code extension** — `editors/vscode/` wraps the LSP server; inline squiggles for every glint rule directly in the editor
See [ROADMAP.md](ROADMAP.md) for planned improvements.
See [FEATURES.md](FEATURES.md) for the complete feature reference and lint rules table, and [ROADMAP.md](ROADMAP.md) for planned improvements.
## Requirements
@@ -59,7 +35,7 @@ See [ROADMAP.md](ROADMAP.md) for planned improvements.
## Installation
```bash
git clone https://git.k3nny.fr/glint
git clone https://git.k3nny.fr/k3nny/glint
cd glint
go build -o glint ./cmd/glint/...
```
@@ -78,395 +54,83 @@ glint [OPTIONS] <COMMAND>
Commands:
check Lint a pipeline file — exits 0 (clean) or 1 (errors found)
graph Visualise the pipeline as a job tree or Mermaid graph
explain Print description and fix for a lint rule
lsp Start a Language Server Protocol server (stdin/stdout)
```
Run `glint <command> --help` for command-specific options and examples.
Run `glint <command> --help` for all flags. See [USAGE.md](USAGE.md) for full
examples covering output formats, context simulation, remote includes, cache,
graph modes, and project configuration.
### `glint check`
## Integrations
```bash
glint check .gitlab-ci.yml
```
### Pre-commit hook
Exits `0` when no errors are found, `1` when at least one error is reported.
### Output formats
Pass `--format` to control the output. Plain text is the default.
```bash
# Default: ruff-style text (human-readable)
glint check .gitlab-ci.yml
# JSON — stable schema, machine-readable
glint check --format json .gitlab-ci.yml
# SARIF 2.1.0 — GitHub Code Scanning / GitLab SAST
glint check --format sarif .gitlab-ci.yml > glint.sarif
# JUnit XML — CI test-report artifact (GitLab: artifacts:reports:junit)
glint check --format junit .gitlab-ci.yml > glint-junit.xml
# GitHub Actions annotations — inline PR diff comments
glint check --format github .gitlab-ci.yml
```
In structured formats (`json`, `sarif`, `junit`, `github`) the summary line
(`OK: … no issues found` or `N finding(s): M error(s)`) is written to stderr
so stdout contains only the machine-readable payload.
**JSON schema (`schema_version: 1`):**
```json
{
"schema_version": 1,
"glint_version": "v0.2.18",
"pipeline": ".gitlab-ci.yml",
"findings": [
{"rule":"GL004","severity":"error","file":".gitlab-ci.yml","line":14,
"job":"deploy","message":"stage \"production\" is not defined in 'stages'"}
],
"summary": {"total": 1, "errors": 1, "warnings": 0}
}
```
**GitHub annotation lines:**
```
::error file=.gitlab-ci.yml,line=14,title=GL004::job "deploy": stage "production" is not defined in 'stages'
```
### Project configuration (`.glint.yml`)
Place a `.glint.yml` file next to your pipeline (or anywhere in the directory tree up to the repository root) to configure glint for that project. glint searches upward from the pipeline file's directory, stopping at the first `.git` boundary.
Add to `.pre-commit-config.yaml` in your repository to run glint automatically whenever `.gitlab-ci.yml` changes:
```yaml
# .glint.yml
# Suppress specific rules entirely.
ignore:
- GL007 # we still use only:/except:, migration in progress
- GL032 # lots of dynamic variables injected by CI
# Override the severity of specific rules.
severity:
GL004: warning # demote stage errors to warnings during a migration
GL035: error # promote absolute-path warning to error for this project
# Extra stages that are valid but not declared in the pipeline YAML itself
# (e.g. injected by an include template we can't edit).
stages:
- quality
- security
# Default token — overridden by --token flag and GITLAB_TOKEN env.
token: glpat-xxxx
# Default GitLab instance URL.
url: https://gitlab.example.com
# Default cache directory for fetched remote includes.
cache_dir: ~/.cache/glint
repos:
- repo: https://git.k3nny.fr/k3nny/glint
rev: v0.2.28
hooks:
- id: glint
```
**Priority chain for token and URL:** `--token`/`--gitlab-url` flags > `.glint.yml` values > `GITLAB_TOKEN`/`CI_SERVER_URL` environment variables.
Requires [pre-commit](https://pre-commit.com) and Go 1.21+. On first run, pre-commit builds glint from source automatically.
### Inline suppression (`# glint: ignore`)
### GitLab CI component
Suppress a finding for a specific job by placing a `# glint: ignore RULE` comment immediately before the job definition:
Copy [`templates/check.yml`](templates/check.yml) into your repository and include it as a local file, or publish this repository to a GitLab CI/CD Catalog and reference it as a component:
```yaml
# glint: ignore GL007
legacy-job:
stage: build
only:
- main
script: echo ok
# As a local include (copy templates/check.yml to your repo first):
include:
- local: .gitlab/glint-check.yml
# Multiple rules — comma- or space-separated:
# glint: ignore GL007, GL032
another-job:
stage: build
script: echo ok
# Suppress all rules for this job:
# glint: ignore all
noisy-job:
stage: build
script: echo ok
# As a Catalog component (after publishing to a GitLab instance):
include:
- component: $CI_SERVER_FQDN/k3nny/glint/check@v0.2.28
inputs:
stage: validate # optional, default: validate
allow_failure: true # optional, default: false
```
Inline suppressions are scoped to the single job they precede. They do not affect other jobs or pipeline-level findings. For project-wide suppression use `.glint.yml` `ignore:`.
The component downloads the glint Linux binary, runs `glint check`, and respects all inputs defined in the `spec:` block.
### Remote project includes
### GitHub Actions
Pipelines that include templates from other GitLab projects are supported.
Provide a token so `glint` can fetch them:
Copy [`action.yml`](action.yml) from this repository, or mirror this repo to GitHub as `k3nny/glint` and reference it directly:
```yaml
- uses: k3nny/glint@v0.2.28
with:
file: .gitlab-ci.yml # optional, default: .gitlab-ci.yml
args: '--format sarif' # optional
```
The action downloads the glint Linux binary into `$RUNNER_TEMP` and runs `glint check`. Only Linux runners are supported (matches the available release binary).
### VS Code extension
Clone this repository and load the extension from `editors/vscode/`:
```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
cd editors/vscode
npm install # install dependencies (once)
npm run compile # compile TypeScript → out/
```
**Project includes** require a token; without one they are skipped with a
warning and the rest of the pipeline is linted as-is.
Then in VS Code: **Run → Start Debugging** (F5) — this opens an Extension Development Host with glint diagnostics active for any `.gitlab-ci.yml` you open.
### Include cache and offline mode
Make sure `glint` is on your `PATH`, or set `glint.executablePath` in VS Code settings to the full path of the binary.
Pass `--cache-dir` to cache fetched remote templates so repeated runs skip the network:
To package a `.vsix` for local installation:
```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
task ext-package # produces glint-X.Y.Z.vsix
code --install-extension glint-X.Y.Z.vsix
```
Cache entries are keyed by SHA-256 of the full request URL/coordinates and stored as plain YAML files in the cache directory. There is currently no automatic expiry — delete the directory or individual entries to force a fresh fetch.
**Component includes** (`include: component: ...`) attempt the fetch
unauthenticated, so public [CI/CD Catalog](https://gitlab.com/explore/catalog)
components work without a token. A warning is emitted if the fetch fails.
Token resolution order (first non-empty wins):
| Source | Header used |
|--------|-------------|
| `--token` flag / `GITLAB_TOKEN` | `PRIVATE-TOKEN` |
| `CI_JOB_TOKEN` | `JOB-TOKEN` |
| `GITLAB_PRIVATE_TOKEN` | `PRIVATE-TOKEN` |
Instance URL resolution order: `--gitlab-url` flag → `CI_SERVER_URL`
`GITLAB_URL``https://gitlab.com`
### Component reference format
```
<host>/<project-path>/<component-name>@<version>
gitlab.com/components/secret-detection/secret-detection@v0.1.0
gitlab.com/my-org/ci-catalog/lint@main
gitlab.example.com/platform/components/build@~latest
```
The component file is looked up in order:
1. `templates/<component-name>.yml` (single-file layout)
2. `templates/<component-name>/template.yml` (directory layout)
Component input parameters (`with:`) are not validated — they are resolved by
GitLab at runtime. Jobs in fetched components may use `$[[ inputs.xxx ]]`
placeholders in fields like `stage`; `glint` skips those fields rather
than producing false positive errors.
### `glint graph`
Visualise the pipeline. Without a mode word, prints a job tree and the include
dependency graph separated by `---`.
```bash
# Default: job tree + include dependency graph
glint graph .gitlab-ci.yml
# Job tree only (stages → jobs, like the tree command)
glint graph tree .gitlab-ci.yml
# Include dependency graph → Mermaid flowchart to stdout
glint graph includes .gitlab-ci.yml > includes.mmd
# GitLab-like pipeline layout → PNG (or SVG fallback) written to --out dir
glint graph pipeline .gitlab-ci.yml
# prints the output file path, e.g.: glint-out/pipeline-20260607-143022.png
# Mermaid to stdout + pipeline file path to stderr
glint graph all .gitlab-ci.yml > includes.mmd
# Custom output directory (pipeline mode)
glint graph pipeline --out /tmp/graphs .gitlab-ci.yml
```
**Job tree** (`graph tree`) — stages as branches, jobs as leaves. Jobs with
`when: manual`, `when: delayed`, or `trigger:` are annotated in brackets.
**Include graph** (`graph includes`) — [Mermaid](https://mermaid.js.org) flowchart written to stdout.
Pipe to a `.mmd` file or paste into [mermaid.live](https://mermaid.live).
One node per include entry, colour-coded by type:
- Orange (bold): the main pipeline file
- Purple: `project:` includes
- Green: `component:` includes
- Blue: `local:` includes
- Grey: `remote:` URL includes
- Light orange: GitLab-provided `template:` includes
**Pipeline graph** (`graph pipeline`) — GitLab CI-style SVG rendered to a timestamped file
in the `--out` directory (default: `glint-out/`). Converted to PNG automatically
when `rsvg-convert`, `inkscape`, or `magick` is available; falls back to SVG otherwise.
Jobs are colour-coded by type:
- Blue (`#1f75cb`): regular jobs
- Orange (`#fc6d26`): `when: manual` jobs
- Purple (`#6b4fbb`): `trigger:` jobs
- Amber (`#fca326`): `when: delayed` jobs
DAG mode (job-to-job Bézier arrows) activates automatically when any job has a `needs:` list.
Classic mode draws L-shaped or straight connectors between stage columns otherwise.
### Context simulation
Pass `--branch`, `--tag`, or `--source` to `glint check` to see which jobs
would run for a given pipeline event. The pipeline is still fully linted;
context output is printed first.
```bash
# What runs on a push to develop?
glint check --branch develop .gitlab-ci.yml
# What runs when a v1.2.0 tag is pushed?
glint check --tag v1.2.0 .gitlab-ci.yml
# Merge request pipeline
glint check --source merge_request_event .gitlab-ci.yml
# Arbitrary variable overrides (repeatable)
glint check --branch main --var DEPLOY_ENV=production .gitlab-ci.yml
```
**Evaluated:**
- `rules:if:` — full expression language: `==`, `!=`, `=~`, `!~`, `&&`, `||`, `!`, `()`, `$VAR`, string literals, `null`
- `only:` / `except:` — ref keywords (`branches`, `tags`, `merge_requests`, `schedules`, …), branch name globs (`feat/*`), and `/regex/` patterns
- Variable expansion — `$VAR` / `${VAR}` references within variable values are expanded after all sources are merged; use `--list-vars` to inspect the resolved values
**Not evaluated** (no git tree at lint time): `rules:changes:`, `rules:exists:`.
Rules without an `if:` clause always match.
**Predefined variables** set automatically by the shortcut flags:
| Flag | Variables populated |
|------|---------------------|
| `--branch <name>` | `CI_COMMIT_BRANCH`, `CI_COMMIT_REF_NAME`, `CI_COMMIT_REF_SLUG`, `CI_PIPELINE_SOURCE=push` |
| `--tag <name>` | `CI_COMMIT_TAG`, `CI_COMMIT_REF_NAME`, `CI_COMMIT_REF_SLUG`, `CI_PIPELINE_SOURCE=push` (clears `CI_COMMIT_BRANCH`) |
| `--source <event>` | `CI_PIPELINE_SOURCE` |
| `--var KEY=VALUE` | any variable; overrides shortcuts |
### Example output
```
# Clean pipeline (implicit default: --branch main --source push)
Context: branch=main, source=push
Active (5): build, deploy-staging, test, ...
OK: .gitlab-ci.yml — no issues found (5 job(s), 3 stage(s))
# With --branch develop context
Context: branch=develop, source=push
Active (3): build, deploy-staging, test
Skipped (2): deploy-prod, release-notes
OK: .gitlab-ci.yml — no issues found (5 job(s), 3 stage(s))
# With --tag v1.0.0 context
Context: tag=v1.0.0, source=push
Active (4): build, deploy-prod, release-notes, test
Skipped (1): deploy-staging
OK: .gitlab-ci.yml — no issues found (5 job(s), 3 stage(s))
# Pipeline with issues
.gitlab-ci.yml:14: GL004 [error] job "deploy": stage "production" is not defined in 'stages'
.gitlab-ci.yml:22: GL027 [error] job "test": needs unknown job "build-app"
.gitlab-ci.yml:31: GL007 [warning] job "old-job": 'only'/'except' are deprecated; prefer 'rules'
3 finding(s): 2 error(s)
```
## Lint rules
Every finding includes a stable rule ID (e.g. `GL003`) that can be used to filter output or reference a specific check in documentation.
### Pipeline-level
| ID | Severity | Rule |
|----|----------|------|
| GL002 | ERROR | `workflow.rules[*].when` is not `always` or `never` |
| GL001 | WARNING | No `stages` defined (GitLab falls back to default stages) |
| GL036 | ERROR | `default.timeout` is not a valid GitLab CI duration string |
| GL040 | WARNING | A stage name appears more than once in `stages:` |
### Job-level — structure
| ID | Severity | Rule |
|----|----------|------|
| GL003 | ERROR | Job is missing required `script` (or `run`) — non-trigger, non-template jobs |
| GL004 | ERROR | Job references a `stage` not declared in `stages` |
| GL005 | ERROR | `only` and `rules` used together on the same job |
| GL006 | ERROR | `except` and `rules` used together on the same job |
| GL007 | WARNING | `only`/`except` used (deprecated, prefer `rules`) |
### Job-level — keyword constraints
| ID | Severity | Rule |
|----|----------|------|
| GL008 | ERROR | `when` is not one of `on_success`, `on_failure`, `always`, `manual`, `delayed`, `never` |
| GL009 | ERROR | `when: delayed` without `start_in` |
| GL010 | ERROR | `start_in` set but `when` is not `delayed` |
| GL011 | ERROR | `parallel` integer not in range 2200, or map form missing `matrix` key |
| GL012 | ERROR | `retry` integer not in range 02, or `retry.max` out of range |
| GL013 | ERROR | `retry.when` contains an unrecognised failure type |
| GL014 | ERROR | `allow_failure` is not a boolean or a map with `exit_codes` |
| GL015 | ERROR | `interruptible` is not a boolean |
| GL016 | ERROR | `trigger` job also has `script` |
| GL017 | ERROR | `trigger` map missing `project` or `include` |
| GL018 | ERROR | `coverage` is not a regex pattern wrapped in `/` |
| GL019 | ERROR | `release` missing required `tag_name`, or is not a map |
| GL020 | ERROR | `environment.url` set without `environment.name`, or invalid `environment.action` |
| GL021 | ERROR | `artifacts.when` invalid, or `artifacts.expose_as` set without `artifacts.paths` |
| GL022 | WARNING | `pages` job `artifacts.paths` does not include `public` |
| GL023 | ERROR | `cache.when` or `cache.policy` has an invalid value |
| GL024 | ERROR | `rules[*].when` is not one of the valid `when` values |
| GL025 | ERROR | `image` map form missing `name` key |
| GL026 | ERROR | `inherit.default` / `inherit.variables` is not a boolean or list |
| GL034 | ERROR | `services:` map form missing `name`, or `alias` is not a valid DNS label |
| GL036 | ERROR | `timeout:` is not a valid GitLab CI duration string (e.g. `1h 30m`, `90 minutes`) |
| GL037 | ERROR | `id_tokens:` entry is missing the required `aud` key |
| GL038 | ERROR | `secrets:` entry is missing a provider key (`vault`, `gcp_secret_manager`, or `azure_key_vault`) |
| GL039 | WARNING | Job has `pages:` keyword but `artifacts.paths` does not include the publish directory |
| GL041 | WARNING | `cache.key.files` entry looks like a glob pattern; must be an exact file path |
### Cross-job graph
| ID | Severity | Rule |
|----|----------|------|
| GL027 | ERROR/WARNING | `needs:` references a job that does not exist (WARNING when `optional: true`) |
| GL028 | ERROR | `needs:` references a job in a later stage |
| GL029 | ERROR | Circular dependency detected in `needs:` graph |
| GL030 | ERROR | `dependencies:` references a job that does not exist |
| GL031 | ERROR | `dependencies:` references a job in the same or a later stage |
### Expression validation
| ID | Severity | Rule |
|----|----------|------|
| GL032 | WARNING | `rules:if:` references `$VAR` not declared in `variables:` (pipeline, job, or `workflow:rules:variables:`) — may be a false positive for variables set in GitLab CI/CD project settings |
| GL033 | WARNING | Every rule in `rules:` has `when: never` — job is permanently excluded from the pipeline (statically provable without context) |
| GL035 | WARNING | `rules:changes` / `rules:exists` path is absolute; GitLab CI paths are relative to the repo root — absolute paths will never match |
### Hidden jobs (templates)
Jobs whose name starts with `.` are treated as reusable templates and skipped for most rules. This matches GitLab's own behaviour.
## Development
This project uses [Task](https://taskfile.dev) as a task runner.
@@ -478,11 +142,21 @@ task test # run Go unit tests
task lint-go # run go vet
task validate # run the binary against all testdata fixtures
task ci # full check: vet → test → build → validate
task fuzz # run fuzz tests for the YAML parser (Ctrl-C to stop; FUZZ_TIME=60s to set duration)
task changelog # regenerate CHANGELOG.md from git history via git-cliff
task changelog-next # preview unreleased section (dry-run, no file written)
task ext-install # install VS Code extension npm dependencies
task ext-compile # compile the VS Code extension TypeScript source
task ext-package # package the VS Code extension as a .vsix
task build-windows # cross-compile for Windows x64 (requires a tagged commit → glint-<tag>.exe)
task build-linux # cross-compile for Linux x64 (requires a tagged commit → glint-<tag>-linux-amd64)
task clean # remove build artifacts
```
**Optional tools:**
- [git-cliff](https://git-cliff.org) — changelog generator used by `task changelog`. Install with `brew install git-cliff` or `cargo install git-cliff`.
## Project structure
```
+88 -100
View File
@@ -4,55 +4,27 @@ This document tracks planned improvements to `glint`. Items are grouped by theme
---
## Context-aware validation — ✓ single-context shipped in v0.2.0; expression evaluator hardened post-v0.2.0; implicit defaults and --list-vars shipped v0.2.11; variable expansion and scalar handling shipped v0.2.13; workflow evaluation and output fixes shipped v0.2.14
## Context simulation
Single-context simulation is fully implemented. Pass `--branch`, `--tag`, `--source`, or `--var` to either `glint check` or `glint graph`; jobs are evaluated and shown as active / manual / skipped.
Pass `--branch`, `--tag`, `--source`, or `--var` to `glint check` or `glint graph` to evaluate `rules:if:` expressions and `only`/`except` filters against a specific pipeline event.
```bash
# shipped: single-context simulation
glint check --branch develop .gitlab-ci.yml
glint check --tag v1.2.0 .gitlab-ci.yml
glint check --source merge_request_event --var CI_MERGE_REQUEST_TARGET_BRANCH_NAME=main .gitlab-ci.yml
glint graph tree --branch main .gitlab-ci.yml # tree annotated with [skipped] / [manual]
```
**Shipped post-v0.2.0 (unreleased)**
-**`workflow:rules:variables:` propagation** — variables defined on the matching `workflow:rules:` entry are injected into the evaluation context before job `rules:if:` expressions are evaluated. Pipeline-level `variables:` defaults are also available. Priority chain (highest wins): `--var` > shortcuts > workflow-rule vars > pipeline defaults.
-**Expression evaluator: multi-line expressions** — newlines in block-scalar and folded YAML `if:` values are now treated as whitespace; `||` / `&&` on a continuation line evaluate correctly.
-**Expression evaluator: `${VAR}` curly-brace syntax**`${CI_COMMIT_BRANCH}` is equivalent to `$CI_COMMIT_BRANCH` everywhere.
-**Expression evaluator: regex flags**`/pattern/i`, `/pattern/m`, `/pattern/s` are now supported; `i` maps to `(?i)` in Go's regexp.
-**Expression evaluator: variable as regex RHS**`$BRANCH =~ $PATTERN` where `$PATTERN` holds a `/regex/` string is evaluated correctly.
-**Expression evaluator: bare `true` / `false` keywords** — treated as the strings `"true"` / `"false"` matching GitLab CI's own behaviour; `$GATEWAY_ENABLED == true` now evaluates correctly.
-**Expression evaluator: integer literals**`$COUNT == 4`, `$ENABLED == 1`, `$DISABLED == 0` compare as decimal strings.
~~**Implicit default context**~~ — ✓ shipped v0.2.11; `glint check` and `glint graph` default to `--branch main --source push` when no context flag is given, so `rules:if:` expressions are always evaluated out of the box.
~~**`--list-vars` debug flag**~~ — ✓ shipped v0.2.11; prints sorted `KEY=VALUE` of all collected variables (pipeline YAML + included files + workflow-rule union + effective context) to stderr.
**Shipped in v0.2.13**
-**Variable expansion**`$VAR` / `${VAR}` references within variable values are expanded after all sources are merged; transitive chains resolve over multiple passes; visible in `--list-vars` effective-context output.
-**Non-string scalar variables**`BUILD: true`, `RETRIES: 3` and similar bare boolean/integer values now render correctly in `--list-vars` and are injected into the evaluation context as string equivalents; previously shown as `(complex)` and silently dropped.
-**YAML `\/` escape in double-quoted strings** — regex patterns like `/^us\//` in double-quoted `if:` blocks no longer cause a parse error; the raw bytes are preprocessed before YAML unmarshalling.
**Shipped in v0.2.14**
-**Workflow rule strict evaluation** — workflow `rules:if:` now uses strict mode (parse failure → skip rule, not match); fixes premature matching that blocked later rules and injected wrong variables.
-**Single `=` operator**`=` is now accepted as an alias for `==` in `rules:if:` expressions, matching common user intent.
-**Source location through `extends:` resolution**`File` and `Line` are now preserved when a job is rebuilt via extends, so findings reference the correct source location.
-**Sorted findings output** — findings are sorted by `(File, Line, Rule)`; same-file issues group together in line order.
-**Consistent warning format** — all warnings use ruff-style `path: [warning] message` format.
-**`--version` / `-v` flag** — prints compiled version; version also shown at the top of every `--help` output.
**Remaining work**
- **Multi-context simulation** — run multiple contexts in one invocation and print a comparison table:
```bash
glint check --context branch=main --context branch=develop --context tag=v1.0.0 .gitlab-ci.yml
```
- **Context-scoped linting** — skip `needs:`/`dependencies:` cross-checks for jobs that are statically unreachable in the given context
- **`rules:changes:` evaluation** — path glob evaluation against the local git tree
- [x] **Single-context simulation** — shipped v0.2.0; `--branch`, `--tag`, `--source`, `--var` flags on both subcommands; jobs classified as active / manual / skipped
- [x] **`workflow:rules:variables:` propagation** — shipped post-v0.2.0; variables from the matching workflow rule entry injected into the evaluation context before job `rules:if:` expressions are evaluated
- [x] **Expression evaluator: multi-line `if:` values** — shipped post-v0.2.0; newlines in block-scalar and folded YAML `if:` values treated as whitespace
- [x] **Expression evaluator: `${VAR}` syntax** — shipped post-v0.2.0; `${CI_COMMIT_BRANCH}` equivalent to `$CI_COMMIT_BRANCH` everywhere
- [x] **Expression evaluator: regex flags** — shipped post-v0.2.0; `/pattern/i`, `/pattern/m`, `/pattern/s` supported
- [x] **Expression evaluator: variable as regex RHS** — shipped post-v0.2.0; `$BRANCH =~ $PATTERN` where `$PATTERN` holds a `/regex/` string evaluates correctly
- [x] **Expression evaluator: bare `true`/`false` and integer literals** — shipped post-v0.2.0; `$FLAG == true`, `$COUNT == 4` compare as decimal strings matching GitLab CI behaviour
- [x] **Implicit default context** — shipped v0.2.11; defaults to `--branch main --source push` when no context flag is given, so `rules:if:` is always evaluated
- [x] **`--list-vars` debug flag** — shipped v0.2.11; prints sorted `KEY=VALUE` of all collected pipeline variables (root file + includes + workflow-rule union + effective context) to stderr
- [x] **Variable expansion** — shipped v0.2.13; `$VAR`/`${VAR}` references within variable values expanded after all sources are merged; transitive chains resolved; visible in `--list-vars`
- [x] **Non-string scalar variables**shipped v0.2.13; `BUILD: true`, `RETRIES: 3` rendered and injected correctly instead of being silently dropped
- [x] **YAML `\/` escape in double-quoted strings** — shipped v0.2.13; regex patterns like `/^us\//` in double-quoted `if:` blocks no longer cause a parse error
- [x] **Workflow rule strict evaluation** — shipped v0.2.14; unparseable `if:` skips the rule instead of matching everything; prevents wrong variables being injected
- [x] **Single `=` operator** — shipped v0.2.14; bare `=` accepted as alias for `==` in `rules:if:` expressions
- [x] **`rules:changes:` evaluation** — shipped v0.2.21; `--changes PATH` and `--changes-from REF` flags; doublestar glob matching (`*` within segment, `**` across segments); permissive when no file list provided
- [x] **Multi-context simulation** — shipped v0.2.22; `--context KEY=VALUE[,...]`; repeatable; prints a comparison table of `active`/`manual`/`skipped`/`blocked` per job across all contexts
- [x] **Context-scoped linting** — shipped v0.2.23; jobs evaluated as `JobSkipped` in the supplied context are excluded from `needs:`/`dependencies:` cross-checks (GL027GL031) to eliminate false-positive errors for conditionally-gated jobs
---
@@ -60,36 +32,37 @@ glint graph tree --branch main .gitlab-ci.yml # tree annotated with [skipped]
The current rule set covers the most common sources of broken pipelines. These are the gaps most likely to matter in practice.
- ~~**Variable reference validation (GL032)**~~ — ✓ shipped v0.2.11; warns when a `rules:if:` expression references `$VAR` / `${VAR}` not declared anywhere in pipeline YAML; predefined GitLab namespaces (`CI_*`, `GITLAB_*`, …) exempt; variables from included files are also considered
- ~~**`rules:if:` static reachability (GL033)**~~ — ✓ shipped v0.2.15; warns when every rule in a job's `rules:` block has `when: never`, making the job permanently excluded from any pipeline run; no `if:` evaluation required
- ~~**`services:` validation (GL034)**~~ — ✓ shipped v0.2.16; map form requires `name`; `alias` must be a valid DNS label
- ~~**`rules:changes` / `rules:exists` absolute path detection (GL035)**~~ — ✓ shipped v0.2.16; warns when a path starts with `/`; GitLab CI paths are always relative to the repo root
- ~~**`timeout` format validation (GL036)**~~ shipped v0.2.16; validates job-level and `default.timeout` against recognised GitLab CI duration strings
- ~~**`id_tokens:` / `secrets:` required-key checks (GL037, GL038)**~~ shipped v0.2.16; `id_tokens` entries must have `aud`; `secrets` entries must declare a provider
- ~~**`pages:publish` + `artifacts.paths` consistency (GL039)**~~ — ✓ shipped v0.2.16; warns when the publish directory is missing from `artifacts.paths`
- ~~**Duplicate stage names (GL040)**~~ — ✓ shipped v0.2.16; warns when a stage appears more than once in `stages:`
- ~~**`cache:key:files` must be exact paths (GL041)**~~ — ✓ shipped v0.2.16; warns when entries look like glob patterns
- ~~**Unreachable jobs**~~ — covered by GL033 (shipped v0.2.15); every-`when:never` rules block is statically dead
- **`inherit:` completeness** — flag when a job overrides a default field that would require `inherit: default: false` to suppress
- [x] **Variable reference validation (GL032)** shipped v0.2.11; warns when a `rules:if:` expression references `$VAR` / `${VAR}` not declared anywhere in pipeline YAML; predefined GitLab namespaces (`CI_*`, `GITLAB_*`, …) exempt; variables from included files are also considered
- [x] **`rules:if:` static reachability (GL033)** shipped v0.2.15; warns when every rule in a job's `rules:` block has `when: never`, making the job permanently excluded from any pipeline run; no `if:` evaluation required
- [x] **`services:` validation (GL034)** shipped v0.2.16; map form requires `name`; `alias` must be a valid DNS label
- [x] **`rules:changes` / `rules:exists` absolute path detection (GL035)** shipped v0.2.16; warns when a path starts with `/`; GitLab CI paths are always relative to the repo root
- [x] **`timeout` format validation (GL036)** — shipped v0.2.16; validates job-level and `default.timeout` against recognised GitLab CI duration strings
- [x] **`id_tokens:` / `secrets:` required-key checks (GL037, GL038)** — shipped v0.2.16; `id_tokens` entries must have `aud`; `secrets` entries must declare a provider
- [x] **`pages:publish` + `artifacts.paths` consistency (GL039)** shipped v0.2.16; warns when the publish directory is missing from `artifacts.paths`
- [x] **Duplicate stage names (GL040)** shipped v0.2.16; warns when a stage appears more than once in `stages:`
- [x] **`cache:key:files` must be exact paths (GL041)** shipped v0.2.16; warns when entries look like glob patterns
- [x] **Unreachable jobs** — covered by GL033 (shipped v0.2.15); every-`when:never` rules block is statically dead
- [x] **`inherit:` completeness (GL043)** — shipped v0.2.20; warns when `inherit: default:` is declared but there's no `default:` block, or list form names fields not set in `default:`
---
## Include resolution
- ~~**`include: local:`** full resolution~~ — ✓ shipped in v0.2.0; local files are read from disk, recursively resolved, and merged before linting
- ~~**`include: remote:`** (URL)~~ — ✓ shipped post-v0.2.0; plain HTTPS URLs are fetched (unauthenticated), parsed, and merged; sub-includes are resolved recursively; unreachable URLs emit `[WARNING]` and linting continues
- ~~**Recursive include depth limit**~~ — ✓ shipped v0.2.17; depth capped at 100 (matching GitLab); project/component includes now tracked in visited set to prevent cross-file cycles
- ~~**Offline mode / cache**~~ shipped v0.2.17; `--cache-dir DIR` persists fetched templates; `--offline` serves from cache only; default cache dir (`~/.cache/glint`) used automatically with `--offline`
- ~~**`include: inputs:`**~~ — ✓ shipped v0.2.17; `$[[ inputs.KEY ]]` and `$[[ inputs.KEY | default(…) ]]` placeholders in fetched component YAML are substituted from the include's `with:` block before parsing
- [x] **`include: local:` full resolution** — shipped v0.2.0; local files are read from disk, recursively resolved, and merged before linting
- [x] **`include: remote:` (URL)** — shipped post-v0.2.0; plain HTTPS URLs are fetched (unauthenticated), parsed, and merged; sub-includes are resolved recursively; unreachable URLs emit `[WARNING]` and linting continues
- [x] **Recursive include depth limit** shipped v0.2.17; depth capped at 100 (matching GitLab); project/component includes now tracked in visited set to prevent cross-file cycles
- [x] **Offline mode / cache** — shipped v0.2.17; `--cache-dir DIR` persists fetched templates; `--offline` serves from cache only; default cache dir (`~/.cache/glint`) used automatically with `--offline`
- [x] **`include: inputs:`** — shipped v0.2.17; `$[[ inputs.KEY ]]` and `$[[ inputs.KEY | default(…) ]]` placeholders in fetched component YAML are substituted from the include's `with:` block before parsing
- [x] **`glint render` subcommand** — shipped v0.3.0; resolves all `include:` and `extends:` chains into a single flat YAML file; strips consumed keys; accepts same network flags as `glint check`; default output `rendered.gitlab-ci.yml`, use `--output -` for stdout
---
## Output formats — ✓ shipped v0.2.18
## Output formats
- ~~**JSON** (`--format json`)~~ — ✓ shipped v0.2.18; machine-readable findings with stable schema (version 1)
- ~~**SARIF** (`--format sarif`)~~ — ✓ shipped v0.2.18; SARIF 2.1.0; consumed natively by GitHub Code Scanning and GitLab SAST
- ~~**JUnit XML** (`--format junit`)~~ — ✓ shipped v0.2.18; lets CI pipelines publish lint results as a test report artifact
- ~~**GitHub annotation format** (`--format github`)~~ shipped v0.2.18; emits `::error file=…,line=…,title=RULE::message` lines so findings appear as inline comments in PR diffs
- [x] **JSON** (`--format json`) — shipped v0.2.18; machine-readable findings with stable schema (version 1)
- [x] **SARIF** (`--format sarif`) — shipped v0.2.18; SARIF 2.1.0; consumed natively by GitHub Code Scanning and GitLab SAST
- [x] **JUnit XML** (`--format junit`) — shipped v0.2.18; lets CI pipelines publish lint results as a test report artifact
- [x] **GitHub annotation format** (`--format github`) — shipped v0.2.18; emits `::error file=…,line=…,title=RULE::message` lines so findings appear as inline comments in PR diffs
---
@@ -97,53 +70,68 @@ The current rule set covers the most common sources of broken pipelines. These a
The SVG renderer and terminal tree cover the basic layout. These would bring it closer to GitLab's full interactive view.
- ~~**Terminal job tree**~~ — ✓ shipped in v0.2.0 as `glint graph tree`; stages as branches, jobs as leaves, context-aware annotations
- ~~**`glint graph includes` shows jobs per file**~~ — ✓ shipped post-v0.2.0; each include node shows the jobs it defines as dashed-arrow rounded nodes in a distinct style
- **Multi-job connector accuracy** — draw one connector per job pair rather than one per stage pair in classic mode, so pipelines with uneven columns look correct
- **Job tooltip / detail panel** — embed a hidden `<title>` and `<desc>` per chip so SVG viewers show `stage`, `when`, `image`, and `needs` on hover
- **`when: on_failure` visual distinction** — dashed border or distinct icon for failure-path jobs
- **Blocked / skipped state colouring** — grey out jobs that are statically unreachable given known `rules:` conditions
- **Interactive HTML output** — self-contained `.html` file with pan/zoom and a job-detail sidebar; no external dependencies
- **Mermaid pipeline output** — keep `pipeline.go` but wire it up through `--graph pipeline --format mermaid` for users who want to paste into mermaid.live
- [x] **Terminal job tree** shipped v0.2.0 as `glint graph tree`; stages as branches, jobs as leaves, context-aware annotations
- [x] **`glint graph includes` shows jobs per file** shipped post-v0.2.0; each include node shows the jobs it defines as dashed-arrow rounded nodes in a distinct style
- [x] **Multi-job connector accuracy**shipped v0.2.25; classic mode uses a bus-bar pattern (vertical rail at the midpoint + horizontal stubs per job) instead of a single center-to-center line, so uneven columns look correct
- [x] **Job tooltip / detail panel**shipped v0.2.25; each chip is wrapped in `<g data-job="…"><title>…</title><desc>…</desc>` SVG viewers show stage, when, image, and needs on hover; HTML output uses the data for the sidebar
- [x] **`when: on_failure` visual distinction** — shipped v0.2.25; dashed chip border + X-mark icon + red circle (`#d9534f`); legend entry added; Mermaid `on_failure` class wired
- [x] **Blocked / skipped state colouring**shipped v0.2.25; `glint graph pipeline` accepts context flags (`--branch`, `--tag`, etc.); jobs evaluated as skipped are greyed out (`#868686`) with dimmed text and no icon
- [x] **Interactive HTML output** shipped v0.2.25; `glint graph pipeline --format html` writes a self-contained `.html` file with mouse pan/zoom and a click-to-open job-detail sidebar; no external dependencies
- [x] **Mermaid pipeline output**shipped v0.2.25; `glint graph pipeline --format mermaid` prints a Mermaid flowchart to stdout (paste into mermaid.live)
- [x] **Same-stage job ordering** — shipped v0.2.26; jobs within a stage that have `needs:` between each other are placed in topological sub-columns (left-to-right by depth); stage header spans all sub-columns
- [x] **Graph links rendered behind job chips** — shipped v0.2.26; SVG connectors (Bézier curves and bus-bar stubs) are drawn before job chips so lines pass behind rectangles
- [x] **`glint graph --no-skipped`** — shipped v0.3.0; removes jobs evaluated as `skipped` in the given context from tree, SVG, HTML, and Mermaid output
---
## Findings quality — ✓ file and line numbers shipped post-v0.2.0; ruff-style format shipped v0.2.11
## Findings quality
~~**File and line numbers on findings**~~ — ✓ shipped post-v0.2.0; every finding includes the source file and exact line of the job key. Works across local includes, remote project templates, and fetched component templates.
~~**Ruff-style output format**~~ shipped v0.2.11; findings follow `file:line: RULEID [severity] message` matching the convention used by ruff and other modern linters.
**Remaining improvements**
- ~~**`needs: optional: true` false-positive errors**~~ shipped post-v0.2.0; optional missing needs are downgraded to `[WARNING]`
- ~~**`extends:` jobs with missing script false errors**~~ — ✓ shipped post-v0.2.0; jobs using `extends:` that have no `script` after resolution emit `[WARNING]` (the script may come from an unfetchable remote base)
- **`rules:if:` static reachability** — report when a job's entire `rules:` block can never evaluate to `when: on_success` given the declared pipeline variables (pure static, no context required)
- [x] **File and line numbers on findings** shipped post-v0.2.0; every finding includes the source file and exact line of the job key; works across local includes, remote project templates, and fetched component templates
- [x] **Ruff-style output format** — shipped v0.2.11; findings follow `file:line: RULEID [severity] message` matching the convention used by ruff and other modern linters
- [x] **Colorized, columnized text output** — shipped v0.3.0; four aligned columns (location, rule, severity, message); `error` in bold red, `warning` in bold orange; auto-detected terminal color (respects `NO_COLOR`)
- [x] **`line:col` locations** — shipped v0.3.0; `Column int` on `model.Job` and `Finding`; text output and `Finding.String()` emit `file:line:col` when column is known
- [x] **`needs: optional: true` false-positive errors** — shipped post-v0.2.0; optional missing needs are downgraded to `[WARNING]`
- [x] **`extends:` jobs with missing script false errors** — shipped post-v0.2.0; jobs using `extends:` that have no `script` after resolution emit `[WARNING]` (the script may come from an unfetchable remote base)
- [x] **`rules:if:` static reachability (GL042)** — shipped v0.2.20; warns when all `rules:if:` conditions evaluate to false given declared variable values (only fires when all referenced vars are declared in YAML)
---
## CI / editor integration
- **GitLab CI template** — a `.gitlab-ci.yml` snippet that runs `glint` as a pipeline-validation job before the real pipeline executes; publishable to the GitLab CI/CD Catalog
- **GitHub Actions action** — `uses: k3nny/glint@v1` wrapper for repositories that mirror or manage GitLab pipelines from GitHub
- **Pre-commit hook** — entry for [pre-commit](https://pre-commit.com) so `glint` runs automatically on `git commit` when `.gitlab-ci.yml` changes
- **LSP server** — `glint lsp` mode exposing diagnostics over the Language Server Protocol; enables inline squiggles in VS Code, JetBrains, Neovim, etc. without a dedicated extension
- **VS Code extension** — thin wrapper around the LSP server with syntax highlighting for `.gitlab-ci.yml`
- [x] **GitLab CI template**shipped v0.2.28; `templates/check.yml` is a GitLab CI/CD Catalog component with `spec:` inputs for stage, file, version, allow_failure, and extra args; also usable as a plain local/remote include
- [x] **GitHub Actions action**shipped v0.2.28; `action.yml` composite action downloads the glint Linux binary and runs `glint check`; mirror to GitHub as `k3nny/glint` to reference as `uses: k3nny/glint@v0.2.28`
- [x] **Pre-commit hook**shipped v0.2.28; `.pre-commit-hooks.yaml` defines `language: golang` hook; pre-commit builds glint from source on first run and re-runs on staged `.gitlab-ci.yml` changes
- [x] **LSP server**shipped v0.2.29; `glint lsp` runs a JSON-RPC 2.0 LSP server over stdin/stdout; `textDocument/didOpen`, `didChange`, `didSave`, `didClose` all publish diagnostics; rule IDs appear as the diagnostic `code`; include resolution is best-effort using env-var token and default cache dir
- [x] **VS Code extension**shipped v0.2.30; `editors/vscode/` TypeScript extension wraps `glint lsp` via `vscode-languageclient`; activates on YAML files and restricts LSP processing to `**/.gitlab-ci.yml`; configurable binary path via `glint.executablePath`; build with `task ext-compile`, package with `task ext-package`
---
## Configuration — ✓ shipped v0.2.19
## Configuration
- ~~**`.glint.yml` config file**~~ shipped v0.2.19; `ignore:`, `severity:`, `stages:`, `token:`, `url:`, `cache_dir:`; searched from the pipeline directory up to the git root
- ~~**Inline suppression comments**~~ — ✓ shipped v0.2.19; `# glint: ignore GL007` before a job definition; comma/space-separated rules; `# glint: ignore all` wildcard
- [x] **`.glint.yml` config file** — shipped v0.2.19; `ignore:`, `severity:`, `stages:`, `token:`, `url:`, `cache_dir:`; searched from the pipeline directory up to the git root
- [x] **Inline suppression comments** shipped v0.2.19; `# glint: ignore GL007` before a job definition; comma/space-separated rules; `# glint: ignore all` wildcard
- [x] **Proxy support** — shipped v0.2.31; `--proxy` flag on `check`, `graph`, and `lsp` subcommands; also configurable via `proxy:` in `.glint.yml`; overrides `HTTP_PROXY` / `HTTPS_PROXY` env vars when set
---
## Security & hardening
- [x] **Path traversal guard for local includes** — shipped v0.2.31; `include: local:` paths containing `../../` or similar sequences are rejected instead of reading files outside the repository root
- [x] **HTTP timeout on remote fetches** — shipped v0.2.31; all HTTP calls via the fetcher use a 30-second timeout to prevent indefinite hangs on slow or unresponsive servers
- [x] **Unbounded response size cap** — shipped v0.2.31; remote include and GitLab API responses are capped at 10 MiB; responses exceeding the limit are rejected to prevent memory exhaustion
- [x] **Cache file permissions** — shipped v0.2.31; cache directories are created with mode `0700` and cache files with mode `0600`; previously used world-readable `0755`/`0644`
- [x] **GL045: HTTP remote include warning** — shipped v0.2.31; warns when `include: remote:` uses a plain `http://` URL; CI templates fetched unencrypted are at risk of tampering
- [x] **LSP Content-Length DoS cap** — shipped v0.2.31; `glint lsp` rejects messages with `Content-Length` exceeding 64 MiB to prevent memory exhaustion from a malicious client
---
## Reliability and developer experience
- ~~**Structured rule IDs**~~ — ✓ shipped post-v0.2.0; GL001GL031 assigned; GL032 added v0.2.11; GL033 added v0.2.15; GL034GL041 added v0.2.16; output formats (--format json/sarif/junit/github) added v0.2.18
- **`--explain <rule-id>`** — print the rule description, rationale, and an example fix
- ~~**Semantic versioning and first release**~~ — shipped as `v0.1.0` (2026-06-07)
- ~~**Subcommand CLI**~~ — shipped as `v0.2.0` (2026-06-11); `glint check` / `glint graph [mode]` with ruff-style `--help`
- **Changelog automation** — generate release notes from Conventional Commits via `git-cliff` or similar
- **Fuzz testing** — add a `go test -fuzz` target for the YAML parser to harden it against malformed input
- [x] **Structured rule IDs** shipped post-v0.2.0; GL001GL031 assigned; GL032 added v0.2.11; GL033 added v0.2.15; GL034GL041 added v0.2.16; output formats added v0.2.18; GL042GL043 added v0.2.20; GL044 added v0.2.24; graph improvements shipped v0.2.25v0.2.26
- [x] **`glint explain <rule-id>`** — shipped v0.2.20; prints rule description, rationale, bad-YAML example, and fix; `glint explain` (no arg) lists all rules
- [x] **Semantic versioning and first release** — shipped v0.1.0 (2026-06-07)
- [x] **Subcommand CLI** — shipped v0.2.0 (2026-06-11); `glint check` / `glint graph [mode]` with ruff-style `--help`
- [x] **Changelog automation**shipped v0.2.27; `cliff.toml` configures git-cliff to produce Keep-a-Changelogcompatible release notes from Conventional Commits; `task changelog` regenerates `CHANGELOG.md`, `task changelog-next` previews unreleased entries
- [x] **Fuzz testing**shipped v0.2.27; `FuzzParseBytes` and `FuzzSanitizeYAMLEscapes` in `internal/model/fuzz_test.go`; seeds run as regular tests in CI; `task fuzz` runs them continuously (default 30 s)
- [x] **`glint check --no-warn`** — shipped v0.3.0; discards all warning findings before output and exit-code calculation; mixed pipelines (errors + warnings) still exit 2 but only errors are printed; warnings-only pipelines exit 0
- [x] **Exit codes 2 / 10** *(breaking)* — shipped v0.3.0; `glint check` exits `2` when errors are present (previously `1`) and `10` when findings contain only warnings; exit `0` for clean
+63 -10
View File
@@ -68,31 +68,31 @@ tasks:
- cmd: ./{{.BINARY}} check --branch feat/x testdata/rules_if_expr.yml
ignore_error: false
- cmd: ./{{.BINARY}} check testdata/workflow_vars.yml
ignore_error: false
ignore_error: true
- cmd: ./{{.BINARY}} check --branch main testdata/workflow_vars.yml
ignore_error: false
ignore_error: true
- cmd: ./{{.BINARY}} check --branch develop testdata/workflow_vars.yml
ignore_error: false
ignore_error: true
- cmd: ./{{.BINARY}} check --branch feat/x testdata/workflow_vars.yml
ignore_error: false
ignore_error: true
- cmd: ./{{.BINARY}} check testdata/workflow_escape.yml
ignore_error: false
ignore_error: true
- cmd: ./{{.BINARY}} check testdata/variable_refs.yml
ignore_error: false
- cmd: ./{{.BINARY}} check testdata/variable_refs_included.yml
ignore_error: false
- cmd: ./{{.BINARY}} check testdata/dead_rules.yml
ignore_error: false
ignore_error: true
- cmd: ./{{.BINARY}} check testdata/new_rules_valid.yml
ignore_error: false
- cmd: ./{{.BINARY}} check testdata/new_rules_invalid.yml
ignore_error: true
- cmd: ./{{.BINARY}} check testdata/samba/.gitlab-ci.yml
ignore_error: false
ignore_error: true
- cmd: ./{{.BINARY}} check testdata/samba/.gitlab-ci-coverage.yml
ignore_error: false
ignore_error: true
- cmd: ./{{.BINARY}} check testdata/samba/.gitlab-ci-private.yml
ignore_error: false
ignore_error: true
- cmd: ./{{.BINARY}} check --format json testdata/valid.yml
ignore_error: false
- cmd: ./{{.BINARY}} check --format sarif testdata/valid.yml
@@ -104,8 +104,28 @@ tasks:
- cmd: ./{{.BINARY}} check testdata/config_ignored/.gitlab-ci.yml
ignore_error: false
- cmd: ./{{.BINARY}} check testdata/config_severity/.gitlab-ci.yml
ignore_error: false
ignore_error: true
- cmd: ./{{.BINARY}} check testdata/config_suppress/.gitlab-ci.yml
ignore_error: true
- cmd: ./{{.BINARY}} check testdata/static_dead_rules.yml
ignore_error: true
- cmd: ./{{.BINARY}} check testdata/inherit_dead.yml
ignore_error: true
- cmd: ./{{.BINARY}} check testdata/inherit_dead_fields.yml
ignore_error: true
- cmd: ./{{.BINARY}} check testdata/rules_needs_valid.yml
ignore_error: false
- cmd: ./{{.BINARY}} check testdata/rules_needs_invalid.yml
ignore_error: true
- cmd: ./{{.BINARY}} explain GL007
ignore_error: false
- cmd: ./{{.BINARY}} explain gl042
ignore_error: false
- cmd: ./{{.BINARY}} explain GL044
ignore_error: false
- cmd: ./{{.BINARY}} check testdata/insecure_remote_include.yml
ignore_error: true
- cmd: ./{{.BINARY}} explain
ignore_error: false
lint-go:
@@ -157,6 +177,39 @@ tasks:
generates:
- "{{.BINARY}}-{{.TAG}}-linux-amd64"
fuzz:
desc: "Run all fuzz targets (set FUZZ_TIME=60s to control per-target duration, default 30s)"
cmds:
- "{{.GO}} test -fuzz=FuzzParseBytes -fuzztime=${FUZZ_TIME:-30s} ./internal/model/"
- "{{.GO}} test -fuzz=FuzzSanitizeYAMLEscapes -fuzztime=${FUZZ_TIME:-30s} ./internal/model/"
- "{{.GO}} test -fuzz=FuzzEvalIf -fuzztime=${FUZZ_TIME:-30s} ./internal/cicontext/"
- "{{.GO}} test -fuzz=FuzzExpandVarRefs -fuzztime=${FUZZ_TIME:-30s} ./internal/cicontext/"
- "{{.GO}} test -fuzz=FuzzLint -fuzztime=${FUZZ_TIME:-30s} ./internal/linter/"
changelog:
desc: "Regenerate CHANGELOG.md from git history (requires git-cliff — see README)"
cmd: git cliff --config cliff.toml --output CHANGELOG.md
changelog-next:
desc: "Preview unreleased changelog entries without writing (requires git-cliff)"
cmd: git cliff --config cliff.toml --unreleased
ext-install:
desc: Install VS Code extension npm dependencies (run once after checkout)
dir: editors/vscode
cmd: npm install
ext-compile:
desc: Compile the VS Code extension TypeScript source
dir: editors/vscode
cmd: npm run compile
ext-package:
desc: Package the VS Code extension into a .vsix file
dir: editors/vscode
deps: [ext-compile]
cmd: npm run package
clean:
desc: Remove build artifacts
cmd: rm -f {{.BINARY}} {{.BINARY}}-*.exe {{.BINARY}}-*-linux-amd64
+345
View File
@@ -0,0 +1,345 @@
# glint — usage reference
Full examples and option descriptions for every command.
For the lint rules reference see [FEATURES.md](FEATURES.md).
---
## `glint check`
Lint a pipeline file. Exits `0` when no errors are found, `1` when at least
one error is reported (warnings alone do not fail).
```bash
glint check .gitlab-ci.yml
```
### Output formats
Pass `--format` to control the output. Plain text is the default.
```bash
# Default: ruff-style text (human-readable)
glint check .gitlab-ci.yml
# JSON — stable schema, machine-readable
glint check --format json .gitlab-ci.yml
# SARIF 2.1.0 — GitHub Code Scanning / GitLab SAST
glint check --format sarif .gitlab-ci.yml > glint.sarif
# JUnit XML — CI test-report artifact (GitLab: artifacts:reports:junit)
glint check --format junit .gitlab-ci.yml > glint-junit.xml
# GitHub Actions annotations — inline PR diff comments
glint check --format github .gitlab-ci.yml
```
In structured formats (`json`, `sarif`, `junit`, `github`) the summary line is
written to stderr so stdout contains only the machine-readable payload.
**JSON schema (`schema_version: 1`):**
```json
{
"schema_version": 1,
"glint_version": "v0.2.20",
"pipeline": ".gitlab-ci.yml",
"findings": [
{"rule":"GL004","severity":"error","file":".gitlab-ci.yml","line":14,
"job":"deploy","message":"stage \"production\" is not defined in 'stages'"}
],
"summary": {"total": 1, "errors": 1, "warnings": 0}
}
```
**GitHub annotation lines:**
```
::error file=.gitlab-ci.yml,line=14,title=GL004::job "deploy": stage "production" is not defined in 'stages'
```
### Context simulation
Pass `--branch`, `--tag`, or `--source` to evaluate `rules:if:` and
`only`/`except` against a specific pipeline event. The pipeline is still fully
linted; the context summary is printed first. When no context flag is given,
glint defaults to `--branch main --source push`.
```bash
# What runs on a push to develop?
glint check --branch develop .gitlab-ci.yml
# What runs when a v1.2.0 tag is pushed?
glint check --tag v1.2.0 .gitlab-ci.yml
# Merge request pipeline
glint check --source merge_request_event .gitlab-ci.yml
# Arbitrary variable overrides (repeatable)
glint check --branch main --var DEPLOY_ENV=production .gitlab-ci.yml
# Debug variable resolution
glint check --list-vars .gitlab-ci.yml
```
**Predefined variables** set by the shortcut flags:
| Flag | Variables populated |
|------|---------------------|
| `--branch NAME` | `CI_COMMIT_BRANCH`, `CI_COMMIT_REF_NAME`, `CI_COMMIT_REF_SLUG`, `CI_PIPELINE_SOURCE=push` |
| `--tag NAME` | `CI_COMMIT_TAG`, `CI_COMMIT_REF_NAME`, `CI_COMMIT_REF_SLUG`, `CI_PIPELINE_SOURCE=push`; clears `CI_COMMIT_BRANCH` |
| `--source EVENT` | `CI_PIPELINE_SOURCE` |
| `--var KEY=VALUE` | any variable; overrides shortcuts; repeatable |
### Remote project includes
Provide a token so glint can fetch `include: project:` templates:
```bash
# Personal access token (read_api scope)
GITLAB_TOKEN=glpat-xxxx glint check .gitlab-ci.yml
# CI/CD job token (when running inside a pipeline)
CI_JOB_TOKEN=$CI_JOB_TOKEN glint check .gitlab-ci.yml
# Self-hosted GitLab
GITLAB_TOKEN=glpat-xxxx GITLAB_URL=https://gitlab.example.com glint check .gitlab-ci.yml
# Via flags (override env vars)
glint check --token glpat-xxxx --gitlab-url https://gitlab.example.com .gitlab-ci.yml
```
Project includes are skipped with a warning when no token is available; linting
continues with whatever is resolved.
**Token resolution order** (first non-empty wins):
| Source | Header |
|--------|--------|
| `--token` flag / `GITLAB_TOKEN` env | `PRIVATE-TOKEN` |
| `CI_JOB_TOKEN` env | `JOB-TOKEN` |
| `GITLAB_PRIVATE_TOKEN` env | `PRIVATE-TOKEN` |
**URL resolution order:** `--gitlab-url` flag → `CI_SERVER_URL` env → `GITLAB_URL` env → `https://gitlab.com`
### Include cache and offline mode
```bash
# Cache fetched templates to disk (keyed by SHA-256 of the request coordinates)
glint check --cache-dir ~/.cache/glint .gitlab-ci.yml
# Fully offline — serve from cache, warn on misses, make no network calls
glint check --offline .gitlab-ci.yml
glint check --offline --cache-dir /path/to/cache .gitlab-ci.yml
```
`--offline` without `--cache-dir` uses `~/.cache/glint` automatically.
There is no automatic cache expiry; delete the directory or individual entries
to force a re-fetch.
### Component reference format
`include: component:` references follow the format:
```
<host>/<project-path>/<component-name>@<version>
gitlab.com/components/secret-detection/secret-detection@v0.1.0
gitlab.com/my-org/ci-catalog/lint@main
gitlab.example.com/platform/components/build@~latest
```
The component file is looked up in order:
1. `templates/<component-name>.yml` (single-file layout)
2. `templates/<component-name>/template.yml` (directory layout)
Public Catalog components work without a token. `with:` input parameters are
substituted locally before parsing; they are not validated against the
component spec.
### Example output
```
# Clean pipeline
Context: branch=main, source=push
Active (5): build, deploy-staging, test, lint, security-scan
OK: .gitlab-ci.yml — no issues found (5 job(s), 3 stage(s))
# With --branch develop
Context: branch=develop, source=push
Active (3): build, deploy-staging, test
Skipped (2): deploy-prod, release-notes
OK: .gitlab-ci.yml — no issues found (5 job(s), 3 stage(s))
# Pipeline with issues
.gitlab-ci.yml:14: GL004 [error] job "deploy": stage "production" is not defined in 'stages'
.gitlab-ci.yml:22: GL027 [error] job "test": needs unknown job "build-app"
.gitlab-ci.yml:31: GL007 [warning] job "old-job": 'only'/'except' are deprecated; prefer 'rules'
3 finding(s): 2 error(s)
```
---
## `glint graph`
Visualise the pipeline. Without a mode word, prints a job tree and the include
dependency graph separated by `---`.
```bash
# Default: job tree + include dependency graph
glint graph .gitlab-ci.yml
# Job tree only
glint graph tree .gitlab-ci.yml
# Job tree with context (shows active/manual/skipped annotations)
glint graph tree --branch develop .gitlab-ci.yml
# Include dependency graph → Mermaid flowchart to stdout
glint graph includes .gitlab-ci.yml > includes.mmd
# GitLab-like pipeline layout → PNG/SVG written to --out dir
glint graph pipeline .gitlab-ci.yml
# prints the output path, e.g.: glint-out/pipeline-20260614-143022.png
# Pipeline graph with skipped-job colouring (grey out context-unreachable jobs)
glint graph pipeline --branch main .gitlab-ci.yml
# Interactive HTML with pan/zoom and job-detail sidebar
glint graph pipeline --format html .gitlab-ci.yml
# Mermaid flowchart of the pipeline to stdout
glint graph pipeline --format mermaid .gitlab-ci.yml
# Mermaid includes graph to stdout + pipeline SVG file path to stderr
glint graph all .gitlab-ci.yml > includes.mmd
# Custom output directory
glint graph pipeline --out /tmp/graphs .gitlab-ci.yml
```
**Job tree** — stages as branches, jobs as leaves; annotated with `[manual]`,
`[delayed]`, `[trigger]` where applicable. Context flags apply the same
evaluation as `glint check`.
**Include graph** — [Mermaid](https://mermaid.js.org) flowchart; pipe to `.mmd`
or paste into [mermaid.live](https://mermaid.live). Nodes are colour-coded by
include type: orange (main file), purple (project), green (component), blue
(local), grey (remote URL), light orange (GitLab template).
**Pipeline graph** — GitLab CI-style SVG rendered to a timestamped file.
Converted to PNG when `rsvg-convert`, `inkscape`, or `magick` is available.
**Pipeline graph formats** (`--format`):
| Flag | Output |
|------|--------|
| `svg` (default) | Write SVG/PNG to `--out` |
| `html` | Write self-contained HTML to `--out` — inline SVG with mouse pan/zoom, drag, and a job-detail sidebar that opens on chip click |
| `mermaid` | Print Mermaid flowchart to stdout |
**Visual chip styles:**
| Type | Colour | Icon | Border |
|------|--------|------|--------|
| regular | blue `#1f75cb` | checkmark | solid |
| manual | orange `#fc6d26` | play triangle | solid |
| trigger | purple `#6b4fbb` | chevron | solid |
| delayed | yellow `#fca326` | clock | solid |
| on_failure | red `#d9534f` | X mark | dashed |
| skipped (context) | grey `#868686` | none | solid |
Pass context flags (`--branch`, `--tag`, `--source`, `--var`) to grey out jobs
that would be skipped in the given pipeline event.
DAG mode (Bézier arrows between jobs) activates automatically when any job has
a `needs:` list; classic mode uses stage-column connectors otherwise.
---
## `glint explain`
Print the documentation for a specific lint rule.
```bash
# Description, example, and fix for GL007
glint explain GL007
# Case-insensitive
glint explain gl007
# List all rules with ID, severity, and title
glint explain
```
---
## Project configuration (`.glint.yml`)
Place a `.glint.yml` anywhere in the directory tree from the pipeline file up
to the first `.git` boundary. glint searches upward and uses the first file it
finds.
```yaml
# Suppress rules globally for this project.
ignore:
- GL007 # migrating from only:/except:
- GL032 # dynamic variables injected by CI
# Override rule severity: error | warning | ignore
# 'ignore' is equivalent to listing the rule under ignore:.
severity:
GL004: warning # demote during a stage migration
GL035: error # promote to hard error for this project
# Extra stage names valid beyond those declared in the pipeline YAML
# (e.g. injected by an include template you don't own).
stages:
- quality
- security
# Default token — lower priority than --token and GITLAB_TOKEN.
token: glpat-xxxx
# Default GitLab instance URL.
url: https://gitlab.example.com
# Default cache directory for fetched remote includes.
cache_dir: ~/.cache/glint
```
**Priority chain:** `--token`/`--gitlab-url` flags > `.glint.yml` > environment variables.
---
## Inline suppression (`# glint: ignore`)
Suppress a finding for a specific job by placing a comment immediately before
the job definition in the pipeline YAML:
```yaml
# glint: ignore GL007
legacy-job:
stage: build
only: [main]
script: echo ok
# Multiple rules — comma- or space-separated:
# glint: ignore GL007, GL032
other-job:
stage: build
script: echo ok
# Suppress every rule for this job:
# glint: ignore all
noisy-job:
stage: build
script: echo ok
```
Suppressions are scoped to the single job they precede and do not affect other
jobs or pipeline-level findings. For project-wide suppression, use `.glint.yml`
`ignore:` instead.
+62
View File
@@ -0,0 +1,62 @@
# GitHub Actions composite action — glint pipeline validator
#
# To use this action, mirror this repository to GitHub as k3nny/glint, then:
#
# - uses: k3nny/glint@v0.2.28
# with:
# file: .gitlab-ci.yml # optional
#
# Alternatively, copy this file into your own repository and reference it
# as a local action:
#
# - uses: ./.github/actions/glint
name: 'glint'
description: 'Validate a GitLab CI pipeline file with glint'
author: 'k3nny'
branding:
icon: 'check-circle'
color: 'orange'
inputs:
version:
description: >-
glint release tag to install (e.g. 'v0.2.28'). Defaults to 'latest'
which resolves to the newest published release.
required: false
default: 'latest'
file:
description: 'Path to the pipeline file to validate.'
required: false
default: '.gitlab-ci.yml'
args:
description: 'Additional arguments passed to glint check (e.g. --format sarif).'
required: false
default: ''
runs:
using: 'composite'
steps:
- name: Install glint
shell: bash
env:
GLINT_VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
if [ "$GLINT_VERSION" = "latest" ]; then
GLINT_VERSION=$(curl -sf \
"https://git.k3nny.fr/api/v1/repos/k3nny/glint/releases?limit=1" \
| grep '"tag_name"' | head -1 | cut -d'"' -f4)
fi
DEST="$RUNNER_TEMP/glint-bin"
mkdir -p "$DEST"
URL="https://git.k3nny.fr/k3nny/glint/releases/download/${GLINT_VERSION}/glint-${GLINT_VERSION}-linux-amd64"
curl -sfL "$URL" -o "$DEST/glint"
chmod +x "$DEST/glint"
echo "$DEST" >> "$GITHUB_PATH"
"$DEST/glint" --version
- name: Run glint check
shell: bash
run: glint check ${{ inputs.args }} "${{ inputs.file }}"
+65
View File
@@ -0,0 +1,65 @@
# git-cliff configuration for glint
# Install: brew install git-cliff OR cargo install git-cliff
# Usage: task changelog -- regenerate full CHANGELOG.md
# task changelog-next -- preview unreleased section (dry-run)
[changelog]
header = """
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
This project uses [Semantic Versioning](https://semver.org).
"""
body = """
{% if version %}\
## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
{% else %}\
## [Unreleased]
{% endif %}\
{% for group, commits in commits | group_by(attribute="group") %}\
### {{ group | upper_first }}
{% for commit in commits %}\
- {% if commit.scope %}**{{ commit.scope }}**: {% endif %}\
{{ commit.message | upper_first }}\
{% if commit.breaking %} **[BREAKING]**{% endif %}
{% endfor %}\
{% endfor %}\
"""
trim = true
footer = ""
postprocessors = []
[git]
conventional_commits = true
filter_unconventional = true
split_commits = false
commit_preprocessors = [
# Drop Co-Authored-By trailers (should not appear in subjects, but guard anyway).
{ pattern = "Co-Authored-By:.*", replace = "" },
]
commit_parsers = [
# Breaking changes (type! or scope!) — promote above everything else.
{ message = "^[a-z]+\\([a-z-]+\\)!:|^[a-z]+!:", group = "Breaking Changes" },
{ message = "^feat", group = "Added" },
{ message = "^fix", group = "Fixed" },
{ message = "^perf", group = "Changed" },
{ message = "^refactor", group = "Changed" },
# Maintenance commits — omit from the changelog body.
{ message = "^docs", skip = true },
{ message = "^style", skip = true },
{ message = "^test", skip = true },
{ message = "^chore", skip = true },
{ message = "^build", skip = true },
{ message = "^claude", skip = true },
]
protect_breaking_commits = false
filter_commits = true
tag_pattern = "v[0-9].*"
topo_order = false
sort_commits = "oldest"
+70
View File
@@ -0,0 +1,70 @@
package main
import (
"fmt"
"os"
"sort"
"strings"
"git.k3nny.fr/glint/internal/linter"
)
func cmdExplain(args []string) {
if len(args) == 0 {
printRuleList()
return
}
ruleID := strings.ToUpper(args[0])
entry, ok := linter.RuleCatalog[ruleID]
if !ok {
fmt.Fprintf(os.Stderr, "glint explain: unknown rule %q\n\nRun 'glint explain' to list all rules.\n", ruleID)
exit(2)
return
}
printRuleEntry(ruleID, entry)
}
func printRuleEntry(id string, e linter.RuleEntry) {
sev := strings.ToLower(string(e.Severity))
header := fmt.Sprintf("%s [%s] %s", id, sev, e.Title)
rule := fmt.Sprintf("\n%s\n%s\n\n%s\n",
header,
strings.Repeat("─", len(header)),
e.Description,
)
fmt.Print(rule)
if e.Example != "" {
fmt.Println("Example:")
fmt.Println()
for _, line := range strings.Split(e.Example, "\n") {
fmt.Printf(" %s\n", line)
}
fmt.Println()
}
if e.Fix != "" {
fmt.Println("Fix:")
fmt.Println()
for _, line := range strings.Split(e.Fix, "\n") {
fmt.Printf(" %s\n", line)
}
fmt.Println()
}
}
func printRuleList() {
ids := make([]string, 0, len(linter.RuleCatalog))
for id := range linter.RuleCatalog {
ids = append(ids, id)
}
sort.Strings(ids)
fmt.Printf("glint %s — lint rules\n\n", version)
for _, id := range ids {
e := linter.RuleCatalog[id]
sev := strings.ToLower(string(e.Severity))
fmt.Printf(" %-6s [%-7s] %s\n", id, sev, e.Title)
}
fmt.Println("\nUse 'glint explain <RULE>' for details on a specific rule.")
}
+100
View File
@@ -0,0 +1,100 @@
package main
import (
"flag"
"fmt"
"os"
"git.k3nny.fr/glint/internal/config"
"git.k3nny.fr/glint/internal/fetcher"
"git.k3nny.fr/glint/internal/lsp"
)
func cmdLSP(args []string) {
fs := flag.NewFlagSet("glint lsp", flag.ExitOnError)
token := fs.String("token", "", "GitLab personal access token (overrides GITLAB_TOKEN)")
gitlabURL := fs.String("gitlab-url", "", "GitLab instance URL (overrides CI_SERVER_URL / GITLAB_URL)")
cacheDir := fs.String("cache-dir", "", "directory for caching fetched remote includes")
offline := fs.Bool("offline", false, "skip all network calls; serve only from --cache-dir")
proxy := fs.String("proxy", "", "HTTP proxy URL for remote includes and GitLab API calls (e.g. http://proxy:8080); overrides system proxy env vars")
fs.Usage = func() {
fmt.Fprintf(os.Stderr, "glint %s\n\n", version)
fmt.Fprint(os.Stderr, `Start a Language Server Protocol server for .gitlab-ci.yml files.
Reads JSON-RPC 2.0 messages from stdin and writes responses to stdout using
the standard Content-Length framing. Connect with any LSP client (VS Code,
Neovim, Emacs, etc.).
Usage: glint lsp [OPTIONS]
Options:
--token <TOKEN>
GitLab personal access token used for resolving project: and
component: includes. Defaults to GITLAB_TOKEN env var.
--gitlab-url <URL>
GitLab instance URL for resolving remote includes.
[env: CI_SERVER_URL | GITLAB_URL] [default: https://gitlab.com]
--cache-dir <DIR>
Cache directory for fetched remote includes. Defaults to
~/.cache/glint so subsequent opens are served from cache.
--offline
Do not make any network calls; resolve only local includes.
Implies --cache-dir default (~/.cache/glint) when not set.
--proxy <URL>
HTTP proxy URL for remote includes and GitLab API calls
(e.g. http://proxy:8080). Overrides system proxy env vars
(HTTP_PROXY / HTTPS_PROXY). Also configurable via proxy: in
.glint.yml.
-h, --help
Print help
Examples:
glint lsp
glint lsp --token glpat-xxxx --cache-dir ~/.cache/glint
glint lsp --offline
glint lsp --proxy http://proxy.example.com:8080
`)
}
_ = fs.Parse(args)
// Load project config from the working directory (the project root from
// which the LSP server is launched). CLI flags take priority.
wd, _ := os.Getwd()
glintCfg, cfgErr := config.Load(wd)
if cfgErr != nil {
fmt.Fprintf(os.Stderr, "glint lsp: [warning] %s: %v\n", config.Filename, cfgErr)
}
fetcherToken := *token
if fetcherToken == "" {
fetcherToken = glintCfg.Token
}
fetcherURL := *gitlabURL
if fetcherURL == "" {
fetcherURL = glintCfg.URL
}
resolvedCacheDir := *cacheDir
if resolvedCacheDir == "" {
resolvedCacheDir = glintCfg.CacheDir
}
if resolvedCacheDir == "" {
resolvedCacheDir = defaultCacheDir()
}
resolvedProxy := *proxy
if resolvedProxy == "" {
resolvedProxy = glintCfg.Proxy
}
cfg := fetcher.AutoConfig().WithOverrides(fetcherURL, fetcherToken, resolvedCacheDir, *offline).WithProxy(resolvedProxy)
srv := lsp.New(os.Stdin, os.Stdout, cfg, version)
if err := srv.Run(); err != nil {
fmt.Fprintf(os.Stderr, "glint lsp: %v\n", err)
exit(2)
}
}
+436 -43
View File
@@ -4,6 +4,7 @@ import (
"flag"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
@@ -20,25 +21,56 @@ import (
// version is set at build time via -ldflags "-X main.version=vX.Y.Z".
var version = "dev"
// exit is a variable so tests can capture exit calls without terminating.
var exit = os.Exit
// userHomeDirFn is a variable so tests can simulate UserHomeDir failure.
var userHomeDirFn = os.UserHomeDir
// defaultCacheDir returns the platform-default glint cache directory:
// $XDG_CACHE_HOME/glint or ~/.cache/glint.
func defaultCacheDir() string {
if xdg := os.Getenv("XDG_CACHE_HOME"); xdg != "" {
return filepath.Join(xdg, "glint")
}
if home, err := os.UserHomeDir(); err == nil {
if home, err := userHomeDirFn(); err == nil {
return filepath.Join(home, ".cache", "glint")
}
return ""
}
// execCommandOutput is a variable so tests can mock external command execution.
var execCommandOutput = func(name string, args ...string) ([]byte, error) {
return exec.Command(name, args...).Output()
}
// gitDiffFiles runs "git diff --name-only <ref>" and returns the list of changed
// file paths. Returns nil + error when the command fails (e.g. not in a git repo
// or the ref doesn't exist).
func gitDiffFiles(ref string) ([]string, error) {
out, err := execCommandOutput("git", "diff", "--name-only", ref)
if err != nil {
return nil, fmt.Errorf("git diff --name-only %s: %w", ref, err)
}
var files []string
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
if line != "" {
files = append(files, line)
}
}
return files, nil
}
const globalUsage = `glint: Lint and visualise GitLab CI pipelines locally.
Usage: glint [OPTIONS] <COMMAND>
Commands:
check Lint a pipeline file — exits 0 (clean) or 1 (errors found)
check Lint a pipeline file — exits 0 (clean), 2 (errors), or 10 (warnings only)
render Resolve all includes and extends into a single merged YAML file
graph Visualise the pipeline as a job tree or Mermaid graph
explain Show description and fix for a lint rule (e.g. glint explain GL007)
lsp Start a Language Server Protocol server (stdin/stdout)
Options:
-h, --help Print help
@@ -50,13 +82,20 @@ For help with a specific command, see: ` + "`glint <command> --help`" + `.
func main() {
if len(os.Args) < 2 {
fmt.Fprint(os.Stderr, globalUsage)
os.Exit(2)
exit(2)
return
}
switch os.Args[1] {
case "check":
cmdCheck(os.Args[2:])
case "render":
cmdRender(os.Args[2:])
case "graph":
cmdGraph(os.Args[2:])
case "explain":
cmdExplain(os.Args[2:])
case "lsp":
cmdLSP(os.Args[2:])
case "-h", "--help", "help":
fmt.Fprintf(os.Stderr, "glint %s\n\n", version)
fmt.Fprint(os.Stderr, globalUsage)
@@ -64,7 +103,7 @@ func main() {
fmt.Printf("glint %s\n", version)
default:
fmt.Fprintf(os.Stderr, "glint: unknown command %q\n\n%s", os.Args[1], globalUsage)
os.Exit(2)
exit(2)
}
}
@@ -83,19 +122,30 @@ func cmdCheck(args []string) {
gitlabURL := fs.String("gitlab-url", "", "GitLab instance URL (overrides CI_SERVER_URL / GITLAB_URL)")
cacheDir := fs.String("cache-dir", "", "directory to cache fetched remote includes (created if needed)")
offline := fs.Bool("offline", false, "skip all network calls; serve only from --cache-dir")
proxy := fs.String("proxy", "", "HTTP proxy URL for remote includes and GitLab API calls (e.g. http://proxy:8080); overrides system proxy env vars")
format := fs.String("format", "text", "output format: text, json, sarif, junit, github")
noWarn := fs.Bool("no-warn", false, "suppress warning findings; only errors are shown and affect the exit code")
branch := fs.String("branch", "", "simulate a branch push (sets CI_COMMIT_BRANCH, …)")
tag := fs.String("tag", "", "simulate a tag push (sets CI_COMMIT_TAG, …)")
source := fs.String("source", "", "set CI_PIPELINE_SOURCE")
listVars := fs.Bool("list-vars", false, "print all collected pipeline variables (from root and included files) to stderr, then continue")
var vars multiFlag
fs.Var(&vars, "var", "set a CI variable as KEY=VALUE; repeatable")
var changesFiles multiFlag
fs.Var(&changesFiles, "changes", "mark a file path as changed for rules:changes: evaluation; repeatable")
changesFrom := fs.String("changes-from", "", "git ref to diff against for rules:changes: evaluation (e.g. HEAD~1, origin/main)")
var contexts multiFlag
fs.Var(&contexts, "context", "simulation context as KEY=VALUE[,...]; repeatable for multi-context comparison table")
fs.Usage = func() {
fmt.Fprintf(os.Stderr, "glint %s\n\n", version)
fmt.Fprint(os.Stderr, `Lint a GitLab CI pipeline file.
Resolves local includes and extends chains, then runs all lint rules.
Exits 0 when no errors are found, 1 when at least one error is reported.
Exit codes:
0 no findings (clean)
2 one or more errors
10 one or more warnings, no errors
Usage: glint check [OPTIONS] <PIPELINE>
@@ -107,6 +157,10 @@ Options:
Output format for findings.
[default: text] [possible values: text, json, sarif, junit, github]
--no-warn
Suppress warning findings. Only errors are printed and counted toward
the exit code; warnings are ignored entirely.
--token <TOKEN>
GitLab personal access token. Required to fetch project: includes;
component: includes are attempted unauthenticated.
@@ -144,6 +198,25 @@ Options:
Set or override a CI variable. Takes precedence over --branch, --tag,
and --source. Repeatable.
--changes <PATH>
Mark a file as changed for rules:changes: evaluation. Repeatable.
When given, only jobs whose rules:changes: patterns match at least one
--changes path will have that rule fire; without --changes or
--changes-from the condition is treated as always matching (permissive).
--changes-from <REF>
Run "git diff --name-only <REF>" to determine changed files for
rules:changes: evaluation. Combined with --changes if both are given.
--context <KEY=VALUE[,...]>
Define a simulation context. Repeatable: each --context flag adds one
column to a comparison table showing every job's state across contexts.
Known keys: branch, tag, source. Any other KEY=VALUE is treated as a
CI variable override. Examples:
--context branch=main --context branch=develop
--context tag=v1.0.0
--context branch=main,DEPLOY_ENV=prod
--list-vars
Print all pipeline-level variables collected from the root file and
every included file (sorted KEY=VALUE) to stderr, then continue
@@ -171,12 +244,16 @@ Examples:
glint check --branch main --var DEPLOY_ENV=production .gitlab-ci.yml
glint check --cache-dir ~/.cache/glint .gitlab-ci.yml
glint check --offline --cache-dir ~/.cache/glint .gitlab-ci.yml
glint check --changes src/main.go --changes Dockerfile .gitlab-ci.yml
glint check --changes-from origin/main .gitlab-ci.yml
glint check --context branch=main --context branch=develop .gitlab-ci.yml
glint check --context branch=main --context tag=v1.0.0 --context source=schedule .gitlab-ci.yml
`)
}
_ = fs.Parse(args)
// Apply implicit defaults when no context flag is given at all.
if *branch == "" && *tag == "" && *source == "" && len(vars) == 0 {
// Apply implicit defaults only in single-context mode when no flags are given.
if len(contexts) == 0 && *branch == "" && *tag == "" && *source == "" && len(vars) == 0 {
*branch = "main"
*source = "push"
}
@@ -186,12 +263,14 @@ Examples:
}
if !validFormats[*format] {
fmt.Fprintf(os.Stderr, "glint: unknown format %q; valid: text, json, sarif, junit, github\n", *format)
os.Exit(2)
exit(2)
return
}
if fs.NArg() != 1 {
fs.Usage()
os.Exit(2)
exit(2)
return
}
path := fs.Arg(0)
rootDir := filepath.Dir(filepath.Clean(path))
@@ -220,13 +299,18 @@ Examples:
if *offline && resolvedCacheDir == "" {
resolvedCacheDir = defaultCacheDir()
}
resolvedProxy := *proxy
if resolvedProxy == "" {
resolvedProxy = glintCfg.Proxy
}
cfg := fetcher.AutoConfig().WithOverrides(fetcherURL, fetcherToken, resolvedCacheDir, *offline)
cfg := fetcher.AutoConfig().WithOverrides(fetcherURL, fetcherToken, resolvedCacheDir, *offline).WithProxy(resolvedProxy)
p, err := model.Parse(path)
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(2)
exit(2)
return
}
// Merge config-defined stages into the pipeline before linting so that
@@ -250,30 +334,99 @@ Examples:
extWarnings, err := resolver.Resolve(p)
if err != nil {
fmt.Fprintf(os.Stderr, "error: resolving extends: %v\n", err)
os.Exit(2)
exit(2)
return
}
for _, w := range extWarnings {
fmt.Fprintf(os.Stderr, "%s: [warning] job %q extends unknown job %q; extends chain skipped\n", path, w.Job, w.Base)
}
ctx := cicontext.New(*branch, *tag, *source, vars)
if !ctx.IsEmpty() {
if !enrichContext(ctx, p) {
fmt.Fprintf(os.Stderr, "%s: [warning] workflow:rules: pipeline would not start for this context\n", path)
// Compute changed files once; shared across single and multi-context modes.
var allChanged []string
var changesReliable bool
if *changesFrom != "" || len(changesFiles) > 0 {
changesReliable = len(changesFiles) > 0
if *changesFrom != "" {
files, err := gitDiffFiles(*changesFrom)
if err != nil {
fmt.Fprintf(os.Stderr, "%s: [warning] --changes-from: %v\n", path, err)
} else {
allChanged = append(allChanged, files...)
changesReliable = true
}
}
allChanged = append(allChanged, changesFiles...)
if changesReliable && allChanged == nil {
allChanged = []string{}
}
}
if *listVars {
printVars(p, ctx)
}
// Context summary only makes sense in plain-text output; suppress it in
// structured formats so stdout contains only the machine-readable payload.
if !ctx.IsEmpty() && *format == "text" {
printContext(p, ctx)
var skipped map[string]bool // jobs excluded from cross-job lint checks
if len(contexts) > 0 {
// Multi-context mode: build one context per --context flag, then print a comparison table.
ctxList := make([]*cicontext.Context, 0, len(contexts))
runs := make([]bool, 0, len(contexts))
for _, spec := range contexts {
cb, ct, cs, cv := parseContextSpec(spec)
c := cicontext.New(cb, ct, cs, cv)
if changesReliable {
c.SetChangedFiles(allChanged)
}
ran := enrichContext(c, p)
ctxList = append(ctxList, c)
runs = append(runs, ran)
}
if *format == "text" {
printContextTable(p, ctxList, contexts, runs)
}
} else {
// Single-context mode: existing flow.
ctx := cicontext.New(*branch, *tag, *source, vars)
if changesReliable {
ctx.SetChangedFiles(allChanged)
}
if !ctx.IsEmpty() {
if !enrichContext(ctx, p) {
fmt.Fprintf(os.Stderr, "%s: [warning] workflow:rules: pipeline would not start for this context\n", path)
}
// Build skipped set: jobs statically unreachable in this context are
// excluded from needs:/dependencies: cross-checks to avoid false positives.
skipped = make(map[string]bool)
for name, job := range p.Jobs {
if cicontext.EvalJob(job, ctx) == cicontext.JobSkipped {
skipped[name] = true
}
}
if len(skipped) == 0 {
skipped = nil
}
}
if *listVars {
printVars(p, ctx)
}
// Context summary only makes sense in plain-text output; suppress it in
// structured formats so stdout contains only the machine-readable payload.
if !ctx.IsEmpty() && *format == "text" {
printContext(p, ctx)
}
}
findings := linter.Lint(p)
findings := linter.Lint(p, skipped)
findings = applyConfig(findings, glintCfg, p.Suppressions)
errCount, _ := countSeverities(findings)
// --no-warn: discard warnings before any output or exit-code calculation.
if *noWarn {
kept := findings[:0]
for _, f := range findings {
if f.Severity != linter.Warning {
kept = append(kept, f)
}
}
findings = kept
}
errCount, warnCount := countSeverities(findings)
// In structured formats the summary line goes to stderr so stdout is clean.
summaryOut := os.Stdout
@@ -291,19 +444,27 @@ Examples:
case "github":
writeGitHub(os.Stdout, findings)
default: // "text"
for _, f := range findings {
fmt.Println(f)
}
writeTextFindings(os.Stdout, findings)
}
if len(findings) == 0 {
fmt.Fprintf(summaryOut, "OK: %s — no issues found (%d job(s), %d stage(s))\n", path, len(p.Jobs), len(p.Stages))
} else {
fmt.Fprintf(summaryOut, "%d finding(s): %d error(s)\n", len(findings), errCount)
switch {
case errCount > 0 && warnCount > 0:
fmt.Fprintf(summaryOut, "%d finding(s): %d error(s), %d warning(s)\n", len(findings), errCount, warnCount)
case errCount > 0:
fmt.Fprintf(summaryOut, "%d finding(s): %d error(s)\n", len(findings), errCount)
default:
fmt.Fprintf(summaryOut, "%d finding(s): %d warning(s)\n", len(findings), warnCount)
}
}
if errCount > 0 {
os.Exit(1)
switch {
case errCount > 0:
exit(2)
case warnCount > 0:
exit(10)
}
}
@@ -324,7 +485,9 @@ func cmdGraph(args []string) {
gitlabURL := fs.String("gitlab-url", "", "GitLab instance URL (overrides CI_SERVER_URL / GITLAB_URL)")
cacheDir := fs.String("cache-dir", "", "directory to cache fetched remote includes (created if needed)")
offline := fs.Bool("offline", false, "skip all network calls; serve only from --cache-dir")
out := fs.String("out", "glint-out", "output directory for Mermaid graph files (pipeline mode)")
proxy := fs.String("proxy", "", "HTTP proxy URL for remote includes and GitLab API calls (e.g. http://proxy:8080)")
out := fs.String("out", "glint-out", "output directory for rendered graph files (pipeline mode)")
format := fs.String("format", "svg", "pipeline output format: svg, mermaid, or html")
fs.Usage = func() {
fmt.Fprintf(os.Stderr, "glint %s\n\n", version)
fmt.Fprint(os.Stderr, `Visualise the pipeline as a job tree and/or Mermaid graph.
@@ -341,6 +504,15 @@ Options:
Output directory for rendered graph files.
Used by the pipeline and all modes only. [default: glint-out]
--format <FORMAT>
Output format for pipeline mode: svg (default), mermaid, or html.
svg: write a GitLab CI-style SVG/PNG to --out (converted to PNG
when rsvg-convert, inkscape, or magick is available).
mermaid: print a Mermaid flowchart to stdout (paste into mermaid.live).
html: write a self-contained HTML file with pan/zoom and a
job-detail sidebar to --out.
[default: svg] [possible values: svg, mermaid, html]
--token <TOKEN>
GitLab personal access token. Used to fetch remote project: includes
when building the include dependency graph.
@@ -368,6 +540,19 @@ Options:
--var <KEY=VALUE>
Set or override a CI variable. Repeatable.
--changes <PATH>
Mark a file path as changed for rules:changes: evaluation. Repeatable.
--changes-from <REF>
Run "git diff --name-only <REF>" to determine changed files for
rules:changes: evaluation.
--no-skipped
Omit jobs that would be skipped in the given context. Requires at
least one context flag (--branch, --tag, --source, --var) or the
implicit default context (branch=main). Skipped jobs are removed
from the tree, SVG, HTML, and Mermaid output entirely.
--list-vars
Print all pipeline-level variables collected from the root file and
every included file (sorted KEY=VALUE) to stderr, then continue
@@ -386,18 +571,25 @@ Examples:
glint graph tree --branch develop .gitlab-ci.yml
glint graph tree --tag v1.0.0 .gitlab-ci.yml
glint graph tree --list-vars .gitlab-ci.yml
glint graph tree --changes src/main.go .gitlab-ci.yml
glint graph includes .gitlab-ci.yml > includes.mmd
glint graph pipeline .gitlab-ci.yml
glint graph pipeline --out /tmp/graphs .gitlab-ci.yml
glint graph pipeline --format mermaid .gitlab-ci.yml
glint graph pipeline --format html .gitlab-ci.yml
glint graph all .gitlab-ci.yml > includes.mmd
`)
}
branch := fs.String("branch", "", "simulate a branch push (sets CI_COMMIT_BRANCH, …)")
tag := fs.String("tag", "", "simulate a tag push (sets CI_COMMIT_TAG, …)")
source := fs.String("source", "", "set CI_PIPELINE_SOURCE")
noSkipped := fs.Bool("no-skipped", false, "hide jobs that would be skipped in the given context (requires a context)")
listVars := fs.Bool("list-vars", false, "print all collected pipeline variables to stderr, then continue")
var vars multiFlag
fs.Var(&vars, "var", "set a CI variable as KEY=VALUE; repeatable")
var changesFiles multiFlag
fs.Var(&changesFiles, "changes", "mark a file path as changed for rules:changes: evaluation; repeatable")
changesFrom := fs.String("changes-from", "", "git ref to diff against for rules:changes: evaluation (e.g. HEAD~1, origin/main)")
_ = fs.Parse(args)
// Apply implicit defaults when no context flag is given at all.
@@ -408,28 +600,71 @@ Examples:
if fs.NArg() != 1 {
fs.Usage()
os.Exit(2)
exit(2)
return
}
path := fs.Arg(0)
rootDir := filepath.Dir(filepath.Clean(path))
glintCfg, cfgErr := config.Load(rootDir)
if cfgErr != nil {
fmt.Fprintf(os.Stderr, "%s: [warning] %s: %v\n", path, config.Filename, cfgErr)
}
fetcherToken := *token
if fetcherToken == "" {
fetcherToken = glintCfg.Token
}
fetcherURL := *gitlabURL
if fetcherURL == "" {
fetcherURL = glintCfg.URL
}
resolvedCacheDir := *cacheDir
if resolvedCacheDir == "" {
resolvedCacheDir = glintCfg.CacheDir
}
if *offline && resolvedCacheDir == "" {
resolvedCacheDir = defaultCacheDir()
}
resolvedProxy := *proxy
if resolvedProxy == "" {
resolvedProxy = glintCfg.Proxy
}
cfg := fetcher.AutoConfig().WithOverrides(*gitlabURL, *token, resolvedCacheDir, *offline)
cfg := fetcher.AutoConfig().WithOverrides(fetcherURL, fetcherToken, resolvedCacheDir, *offline).WithProxy(resolvedProxy)
p, err := model.Parse(path)
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(2)
exit(2)
return
}
rootDir := filepath.Dir(filepath.Clean(path))
resolver.ResolveIncludes(p, cfg, rootDir) //nolint:errcheck
resolver.Resolve(p) //nolint:errcheck
ctx := cicontext.New(*branch, *tag, *source, vars)
// Wire up rules:changes: evaluation when file-change data is provided.
if *changesFrom != "" || len(changesFiles) > 0 {
var allChanged []string
reliable := len(changesFiles) > 0
if *changesFrom != "" {
files, err := gitDiffFiles(*changesFrom)
if err != nil {
fmt.Fprintf(os.Stderr, "%s: [warning] --changes-from: %v\n", path, err)
} else {
allChanged = append(allChanged, files...)
reliable = true
}
}
allChanged = append(allChanged, changesFiles...)
if reliable {
if allChanged == nil {
allChanged = []string{}
}
ctx.SetChangedFiles(allChanged)
}
}
if !ctx.IsEmpty() {
enrichContext(ctx, p)
}
@@ -437,6 +672,16 @@ Examples:
printVars(p, ctx)
}
// --no-skipped: remove jobs that evaluate to skipped in the current context
// before handing the pipeline to any graph function.
if *noSkipped && !ctx.IsEmpty() {
for name, job := range p.Jobs {
if !strings.HasPrefix(name, ".") && cicontext.EvalJob(job, ctx) == cicontext.JobSkipped {
delete(p.Jobs, name)
}
}
}
switch mode {
case "default":
fmt.Print(graph.Tree(p, ctx))
@@ -447,18 +692,33 @@ Examples:
case "includes":
fmt.Print(graph.Includes(path, p.Include, cfg))
case "pipeline":
outPath, err := graph.RenderPipeline(p, *out)
if err != nil {
fmt.Fprintf(os.Stderr, "error: rendering pipeline graph: %v\n", err)
os.Exit(2)
switch *format {
case "mermaid":
fmt.Print(graph.Pipeline(p))
case "html":
outPath, err := graph.RenderHTML(p, *out, ctx)
if err != nil {
fmt.Fprintf(os.Stderr, "error: rendering pipeline graph: %v\n", err)
exit(2)
return
}
fmt.Println(outPath)
default: // "svg"
outPath, err := graph.RenderPipeline(p, *out, ctx)
if err != nil {
fmt.Fprintf(os.Stderr, "error: rendering pipeline graph: %v\n", err)
exit(2)
return
}
fmt.Println(outPath)
}
fmt.Println(outPath)
case "all":
fmt.Print(graph.Includes(path, p.Include, cfg))
outPath, err := graph.RenderPipeline(p, *out)
outPath, err := graph.RenderPipeline(p, *out, ctx)
if err != nil {
fmt.Fprintf(os.Stderr, "error: rendering pipeline graph: %v\n", err)
os.Exit(2)
exit(2)
return
}
fmt.Fprintln(os.Stderr, outPath)
}
@@ -570,3 +830,136 @@ func printJobGroup(label string, jobs []string) {
}
fmt.Printf("%s (%d): %s\n", label, len(jobs), strings.Join(jobs, ", "))
}
// parseContextSpec parses "branch=main,DEPLOY_ENV=prod" into its parts.
// Known keys (branch, tag, source) are extracted; everything else goes into extraVars.
func parseContextSpec(spec string) (branch, tag, source string, extraVars []string) {
for _, kv := range strings.Split(spec, ",") {
kv = strings.TrimSpace(kv)
if kv == "" {
continue
}
parts := strings.SplitN(kv, "=", 2)
key := parts[0]
val := ""
if len(parts) == 2 {
val = parts[1]
}
switch strings.ToLower(key) {
case "branch":
branch = val
case "tag":
tag = val
case "source":
source = val
default:
extraVars = append(extraVars, kv)
}
}
return
}
// sortedJobNames returns non-hidden job names ordered by stage position, then alphabetically.
func sortedJobNames(p *model.Pipeline) []string {
stageIdx := make(map[string]int, len(p.Stages))
for i, s := range p.Stages {
stageIdx[s] = i
}
type entry struct {
name string
stage int
}
entries := make([]entry, 0, len(p.Jobs))
for name, job := range p.Jobs {
if strings.HasPrefix(name, ".") {
continue
}
idx, ok := stageIdx[job.Stage]
if !ok {
idx = len(p.Stages)
}
entries = append(entries, entry{name: name, stage: idx})
}
sort.Slice(entries, func(i, j int) bool {
if entries[i].stage != entries[j].stage {
return entries[i].stage < entries[j].stage
}
return entries[i].name < entries[j].name
})
names := make([]string, len(entries))
for i, e := range entries {
names[i] = e.name
}
return names
}
// printContextTable prints a side-by-side comparison of job states across contexts.
func printContextTable(p *model.Pipeline, ctxs []*cicontext.Context, labels []string, runs []bool) {
jobs := sortedJobNames(p)
if len(jobs) == 0 {
return
}
// Evaluate all jobs for all contexts up front so we can compute column widths.
states := make([][]string, len(jobs))
for r, name := range jobs {
states[r] = make([]string, len(ctxs))
for c, ctx := range ctxs {
if !runs[c] {
states[r][c] = "blocked"
} else {
switch cicontext.EvalJob(p.Jobs[name], ctx) {
case cicontext.JobActive:
states[r][c] = "active"
case cicontext.JobManual:
states[r][c] = "manual"
default:
states[r][c] = "skipped"
}
}
}
}
// Compute column widths.
jobCol := len("JOB")
for _, name := range jobs {
if len(name) > jobCol {
jobCol = len(name)
}
}
ctxCols := make([]int, len(labels))
for i, lbl := range labels {
ctxCols[i] = len(lbl)
}
for r := range jobs {
for c, s := range states[r] {
if len(s) > ctxCols[c] {
ctxCols[c] = len(s)
}
}
}
// Print header.
fmt.Println("Context comparison:")
fmt.Println()
fmt.Printf("%-*s", jobCol, "JOB")
for i, lbl := range labels {
fmt.Printf(" %-*s", ctxCols[i], lbl)
}
fmt.Println()
fmt.Print(strings.Repeat("-", jobCol))
for _, w := range ctxCols {
fmt.Print(" " + strings.Repeat("-", w))
}
fmt.Println()
// Print rows.
for r, name := range jobs {
fmt.Printf("%-*s", jobCol, name)
for c, s := range states[r] {
fmt.Printf(" %-*s", ctxCols[c], s)
}
fmt.Println()
}
fmt.Println()
}
File diff suppressed because it is too large Load Diff
+111
View File
@@ -0,0 +1,111 @@
package main
import (
"fmt"
"io"
"os"
"strings"
"git.k3nny.fr/glint/internal/linter"
)
// ANSI escape sequences used for colorized output.
const (
ansiReset = "\033[0m"
ansiBold = "\033[1m"
ansiDim = "\033[2m"
ansiRed = "\033[31m"
ansiOrange = "\033[33m" // rendered as orange/amber in most terminals
)
// colorEnabled reports whether ANSI color should be used when writing to w.
// Colors are suppressed when NO_COLOR is set or when w is not a terminal.
func colorEnabled(w io.Writer) bool {
if os.Getenv("NO_COLOR") != "" {
return false
}
f, ok := w.(*os.File)
if !ok {
return false
}
fi, err := f.Stat()
return err == nil && (fi.Mode()&os.ModeCharDevice) != 0
}
// writeTextFindings prints findings in a columnized format with optional
// ANSI color. All findings are scanned first to compute column widths so
// that each field aligns across all output lines.
//
// Output columns (space-separated, no borders):
//
// location RULE severity message
func writeTextFindings(w io.Writer, findings []linter.Finding) {
if len(findings) == 0 {
return
}
color := colorEnabled(w)
// Pre-compute locations and maximum location width.
locs := make([]string, len(findings))
maxLoc := 0
for i, f := range findings {
locs[i] = findingLocation(f)
if len(locs[i]) > maxLoc {
maxLoc = len(locs[i])
}
}
// Rule IDs are always 5 chars (GL001GL999).
const ruleWidth = 5
// Severity width: "warning" = 7 chars.
const sevWidth = 7
for i, f := range findings {
loc := locs[i]
rule := f.Rule
sev := strings.ToLower(string(f.Severity))
msg := f.Message
if f.Job != "" {
msg = fmt.Sprintf("job %q: %s", f.Job, msg)
}
if color {
var sevSeq string
if f.Severity == linter.Error {
sevSeq = ansiRed + ansiBold
} else {
sevSeq = ansiOrange + ansiBold
}
fmt.Fprintf(w, "%s%-*s%s %s%-*s%s %s%-*s%s %s\n",
ansiDim, maxLoc, loc, ansiReset,
ansiBold, ruleWidth, rule, ansiReset,
sevSeq, sevWidth, sev, ansiReset,
msg,
)
} else {
fmt.Fprintf(w, "%-*s %-*s %-*s %s\n",
maxLoc, loc,
ruleWidth, rule,
sevWidth, sev,
msg,
)
}
}
}
// findingLocation formats the file:line:col location string for a finding.
func findingLocation(f linter.Finding) string {
if f.File == "" {
return ""
}
switch {
case f.Line > 0 && f.Column > 0:
return fmt.Sprintf("%s:%d:%d", f.File, f.Line, f.Column)
case f.Line > 0:
return fmt.Sprintf("%s:%d", f.File, f.Line)
default:
return f.File
}
}
+269
View File
@@ -0,0 +1,269 @@
package main
import (
"flag"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"git.k3nny.fr/glint/internal/config"
"git.k3nny.fr/glint/internal/fetcher"
"git.k3nny.fr/glint/internal/model"
"git.k3nny.fr/glint/internal/resolver"
"gopkg.in/yaml.v3"
)
func cmdRender(args []string) {
fs := flag.NewFlagSet("glint render", flag.ExitOnError)
output := fs.String("output", "", "output file path (default: rendered.gitlab-ci.yml; use - for stdout)")
token := fs.String("token", "", "GitLab personal access token (overrides GITLAB_TOKEN)")
gitlabURL := fs.String("gitlab-url", "", "GitLab instance URL (overrides CI_SERVER_URL / GITLAB_URL)")
cacheDir := fs.String("cache-dir", "", "directory to cache fetched remote includes")
offline := fs.Bool("offline", false, "skip all network calls; serve only from --cache-dir")
proxy := fs.String("proxy", "", "HTTP proxy URL for remote includes (e.g. http://proxy:8080)")
fs.Usage = func() {
fmt.Fprintf(os.Stderr, "glint %s\n\n", version)
fmt.Fprint(os.Stderr, `Resolve all includes and extends into a single merged YAML file.
Performs the same include resolution and extends merging that GitLab CI
does server-side, then writes the fully flattened pipeline to a single file.
Useful for inspecting the resolved pipeline or running further local tooling.
The output file strips 'include:' (consumed by resolution) and 'extends:'
(applied to each job) keys. All other fields are preserved verbatim.
Template jobs (names starting with '.') are retained.
Usage: glint render [OPTIONS] <PIPELINE>
Arguments:
<PIPELINE> Path to the .gitlab-ci.yml file to resolve
Options:
--output <FILE>
Write the rendered pipeline to FILE.
Use '-' to write to stdout.
[default: rendered.gitlab-ci.yml]
--token <TOKEN>
GitLab personal access token for fetching project: and component:
includes.
[env: GITLAB_TOKEN | CI_JOB_TOKEN | GITLAB_PRIVATE_TOKEN]
--gitlab-url <URL>
GitLab instance URL.
[env: CI_SERVER_URL | GITLAB_URL] [default: https://gitlab.com]
--cache-dir <DIR>
Cache directory for fetched remote includes.
--offline
Do not make any network calls; use cache only.
--proxy <URL>
HTTP proxy URL for remote includes and GitLab API calls.
-h, --help
Print help
Examples:
glint render .gitlab-ci.yml
glint render --output merged.yml .gitlab-ci.yml
glint render --output - .gitlab-ci.yml | yq .
glint render --offline --cache-dir ~/.cache/glint .gitlab-ci.yml
`)
}
_ = fs.Parse(args)
if fs.NArg() != 1 {
fs.Usage()
exit(2)
return
}
path := fs.Arg(0)
rootDir := filepath.Dir(filepath.Clean(path))
glintCfg, cfgErr := config.Load(rootDir)
if cfgErr != nil {
fmt.Fprintf(os.Stderr, "%s: [warning] %s: %v\n", path, config.Filename, cfgErr)
}
fetcherToken := *token
if fetcherToken == "" {
fetcherToken = glintCfg.Token
}
fetcherURL := *gitlabURL
if fetcherURL == "" {
fetcherURL = glintCfg.URL
}
resolvedCacheDir := *cacheDir
if resolvedCacheDir == "" {
resolvedCacheDir = glintCfg.CacheDir
}
if *offline && resolvedCacheDir == "" {
resolvedCacheDir = defaultCacheDir()
}
resolvedProxy := *proxy
if resolvedProxy == "" {
resolvedProxy = glintCfg.Proxy
}
cfg := fetcher.AutoConfig().WithOverrides(fetcherURL, fetcherToken, resolvedCacheDir, *offline).WithProxy(resolvedProxy)
p, err := model.Parse(path)
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
exit(2)
return
}
warnings, _ := resolver.ResolveIncludes(p, cfg, rootDir)
for _, w := range warnings {
fmt.Fprintf(os.Stderr, "%s: [warning] include %s\n", path, w)
}
extWarnings, err := resolver.Resolve(p)
if err != nil {
fmt.Fprintf(os.Stderr, "error: resolving extends: %v\n", err)
exit(2)
return
}
for _, w := range extWarnings {
fmt.Fprintf(os.Stderr, "%s: [warning] job %q extends unknown job %q; extends chain skipped\n", path, w.Job, w.Base)
}
doc, err := buildRenderDoc(p)
if err != nil {
fmt.Fprintf(os.Stderr, "error: building output: %v\n", err)
exit(2)
return
}
outPath := *output
if outPath == "" {
outPath = "rendered.gitlab-ci.yml"
}
var w interface{ Write([]byte) (int, error) }
if outPath == "-" {
w = os.Stdout
} else {
f, err := os.Create(outPath)
if err != nil {
fmt.Fprintf(os.Stderr, "error: creating output file: %v\n", err)
exit(2)
return
}
defer f.Close()
w = f
}
enc := yaml.NewEncoder(w)
enc.SetIndent(2)
if err := enc.Encode(doc); err != nil {
fmt.Fprintf(os.Stderr, "error: writing output: %v\n", err)
exit(2)
return
}
_ = enc.Close()
if outPath != "-" {
jobCount := 0
for name := range p.Jobs {
if !strings.HasPrefix(name, ".") {
jobCount++
}
}
fmt.Fprintf(os.Stderr, "rendered: %s (%d job(s), %d stage(s))\n", outPath, jobCount, len(p.Stages))
}
}
// buildRenderDoc constructs an ordered yaml.Node document from the resolved
// pipeline. Pipeline-level keys (stages, variables, default, workflow) come
// first, followed by template jobs (.name) then regular jobs, both sorted
// alphabetically. 'include:' and 'extends:' are omitted — they have been
// consumed by the resolution passes.
func buildRenderDoc(p *model.Pipeline) (*yaml.Node, error) {
root := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
addField := func(key string, val any) error {
n, err := anyToNode(val)
if err != nil {
return fmt.Errorf("encoding %q: %w", key, err)
}
root.Content = append(root.Content,
&yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: key},
n,
)
return nil
}
if len(p.Stages) > 0 {
if err := addField("stages", p.Stages); err != nil {
return nil, err
}
}
if len(p.Variables) > 0 {
if err := addField("variables", p.Variables); err != nil {
return nil, err
}
}
if p.Default != nil {
if err := addField("default", p.Default); err != nil {
return nil, err
}
}
if p.Workflow != nil {
if err := addField("workflow", p.Workflow); err != nil {
return nil, err
}
}
// Collect and sort job names: template jobs first, then regular jobs.
var templates, regular []string
for name := range p.RawJobs {
if strings.HasPrefix(name, ".") {
templates = append(templates, name)
} else {
regular = append(regular, name)
}
}
sort.Strings(templates)
sort.Strings(regular)
for _, name := range append(templates, regular...) {
raw := p.RawJobs[name]
// Copy to avoid mutating the shared map; strip resolution-consumed keys.
cleaned := make(map[string]any, len(raw))
for k, v := range raw {
if k == "extends" {
continue
}
cleaned[k] = v
}
if err := addField(name, cleaned); err != nil {
return nil, err
}
}
doc := &yaml.Node{Kind: yaml.DocumentNode, Content: []*yaml.Node{root}}
return doc, nil
}
// anyToNode converts an arbitrary Go value to a *yaml.Node by round-tripping
// through yaml.Marshal / yaml.Unmarshal, which preserves all value types.
func anyToNode(v any) (*yaml.Node, error) {
data, err := yaml.Marshal(v)
if err != nil {
return nil, err
}
var doc yaml.Node
if err := yaml.Unmarshal(data, &doc); err != nil {
return nil, err
}
if doc.Kind == yaml.DocumentNode && len(doc.Content) > 0 {
return doc.Content[0], nil
}
return &doc, nil
}
+3
View File
@@ -0,0 +1,3 @@
node_modules/
out/
*.vsix
+8
View File
@@ -0,0 +1,8 @@
src/**
tsconfig.json
.gitignore
node_modules/**
!node_modules/vscode-languageclient/**
!node_modules/vscode-languageserver-protocol/**
!node_modules/vscode-languageserver-types/**
!node_modules/vscode-jsonrpc/**
+2532
View File
File diff suppressed because it is too large Load Diff
+56
View File
@@ -0,0 +1,56 @@
{
"name": "glint",
"displayName": "glint — GitLab CI Linter",
"description": "Inline diagnostics for .gitlab-ci.yml pipelines powered by the glint language server",
"version": "0.2.29",
"publisher": "k3nny",
"license": "Apache-2.0",
"repository": {
"type": "git",
"url": "https://git.k3nny.fr/k3nny/glint"
},
"engines": {
"vscode": "^1.82.0"
},
"categories": [
"Linters"
],
"keywords": [
"gitlab",
"ci",
"yaml",
"lint",
"pipeline"
],
"activationEvents": [
"onLanguage:yaml"
],
"main": "./out/extension.js",
"contributes": {
"configuration": {
"type": "object",
"title": "glint",
"properties": {
"glint.executablePath": {
"type": "string",
"default": "glint",
"markdownDescription": "Path to the `glint` binary. Leave as `glint` if it is on your `PATH`, or set an absolute path (e.g. `/usr/local/bin/glint`)."
}
}
}
},
"scripts": {
"compile": "tsc -p ./",
"watch": "tsc --watch -p ./",
"package": "vsce package"
},
"dependencies": {
"vscode-languageclient": "^9.0.1"
},
"devDependencies": {
"@types/node": "^20",
"@types/vscode": "^1.82.0",
"@vscode/vsce": "^2.22.0",
"typescript": "^5.3.0"
}
}
+41
View File
@@ -0,0 +1,41 @@
import * as vscode from 'vscode';
import {
LanguageClient,
LanguageClientOptions,
ServerOptions,
TransportKind,
} from 'vscode-languageclient/node';
let client: LanguageClient | undefined;
export function activate(context: vscode.ExtensionContext): void {
const cfg = vscode.workspace.getConfiguration('glint');
const glintPath = cfg.get<string>('executablePath') || 'glint';
const serverOptions: ServerOptions = {
command: glintPath,
args: ['lsp'],
transport: TransportKind.stdio,
};
const clientOptions: LanguageClientOptions = {
// Only process .gitlab-ci.yml files, not all YAML.
documentSelector: [
{ scheme: 'file', language: 'yaml', pattern: '**/.gitlab-ci.yml' },
],
};
client = new LanguageClient(
'glint',
'glint — GitLab CI Linter',
serverOptions,
clientOptions,
);
client.start();
context.subscriptions.push(client);
}
export async function deactivate(): Promise<void> {
await client?.stop();
}
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"module": "commonjs",
"target": "ES2020",
"lib": ["ES2020"],
"outDir": "out",
"rootDir": "src",
"strict": true,
"sourceMap": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src"],
"exclude": ["node_modules", "out"]
}
+6 -5
View File
@@ -2,14 +2,15 @@ module git.k3nny.fr/glint
go 1.26.4
require gopkg.in/yaml.v3 v3.0.1
require (
github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c // indirect
golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678 // indirect
golang.org/x/mod v0.31.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/tools v0.40.1-0.20260108161641-ca281cf95054 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
honnef.co/go/tools v0.7.0 // indirect
golang.org/x/mod v0.35.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/tools v0.44.1-0.20260420230617-19499e7caabc // indirect
honnef.co/go/tools v0.8.0-rc.1 // indirect
)
tool honnef.co/go/tools/cmd/staticcheck
+13 -9
View File
@@ -1,16 +1,20 @@
github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c h1:pxW6RcqyfI9/kWtOwnv/G+AzdKuy2ZrqINhenH4HyNs=
github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa h1:FRnLl4eNAQl8hwxVVC17teOw8kdjVDVAiFMtgUdTSRQ=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678 h1:1P7xPZEwZMoBoz0Yze5Nx2/4pxj6nw9ZqHWXqP0iRgQ=
golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/tools v0.40.1-0.20260108161641-ca281cf95054 h1:CHVDrNHx9ZoOrNN9kKWYIbT5Rj+WF2rlwPkhbQQ5V4U=
golang.org/x/tools v0.40.1-0.20260108161641-ca281cf95054/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/tools v0.44.1-0.20260420230617-19499e7caabc h1:vSv/HN1q9eoPD7lMyJYVJ/GPYnqtqu6adMxUmrxOB78=
golang.org/x/tools v0.44.1-0.20260420230617-19499e7caabc/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM=
golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.7.0 h1:w6WUp1VbkqPEgLz4rkBzH/CSU6HkoqNLp6GstyTx3lU=
honnef.co/go/tools v0.7.0/go.mod h1:pm29oPxeP3P82ISxZDgIYeOaf9ta6Pi0EWvCFoLG2vc=
honnef.co/go/tools v0.8.0-rc.1 h1:wqMm2kjcEXMOr+6yau+pdKqJKe6l2N1aKPkpini+Kzk=
honnef.co/go/tools v0.8.0-rc.1/go.mod h1:XA+OnlRA9EDh/ukGvXMNSZNKGwFQJ+5dER0ioUkOxks=
+291
View File
@@ -0,0 +1,291 @@
package cicontext
import (
"testing"
"git.k3nny.fr/glint/internal/model"
)
// ── doublestarMatch ───────────────────────────────────────────────────────────
func TestDoublestarMatch(t *testing.T) {
cases := []struct {
pattern string
path string
want bool
}{
// exact match
{"Dockerfile", "Dockerfile", true},
{"Dockerfile", "dockerfile", false},
// single-segment wildcard
{"*.go", "main.go", true},
{"*.go", "main.py", false},
// * does not cross /
{"*.go", "src/main.go", false},
// ** matches multiple segments
{"**/*.go", "src/main.go", true},
{"**/*.go", "src/pkg/util.go", true},
{"**/*.go", "src/main.py", false},
// ** at end matches everything
{"src/**", "src/main.go", true},
{"src/**", "src/a/b/c.go", true},
{"src/**", "other/main.go", false},
// ** in the middle
{"src/**/*.go", "src/pkg/main.go", true},
{"src/**/*.go", "src/a/b/main.go", true},
{"src/**/*.go", "test/pkg/main.go", false},
// bare ** matches everything
{"**", "anything/and/everything.txt", true},
{"**", "file.go", true},
// no wildcard, multi-segment
{"src/main.go", "src/main.go", true},
{"src/main.go", "src/other.go", false},
// ** matches zero segments too
{"src/**/main.go", "src/main.go", true},
{"src/**/main.go", "src/pkg/main.go", true},
// malformed pattern (gracefully handled)
{"[invalid", "file.go", false},
}
for _, tc := range cases {
t.Run(tc.pattern+"~"+tc.path, func(t *testing.T) {
got := doublestarMatch(tc.pattern, tc.path)
if got != tc.want {
t.Errorf("doublestarMatch(%q, %q) = %v; want %v", tc.pattern, tc.path, got, tc.want)
}
})
}
}
// ── extractChangesPaths ───────────────────────────────────────────────────────
func TestExtractChangesPaths(t *testing.T) {
t.Run("nil → nil", func(t *testing.T) {
if extractChangesPaths(nil) != nil {
t.Error("expected nil")
}
})
t.Run("string", func(t *testing.T) {
got := extractChangesPaths("Dockerfile")
if len(got) != 1 || got[0] != "Dockerfile" {
t.Errorf("got %v", got)
}
})
t.Run("[]string", func(t *testing.T) {
got := extractChangesPaths([]string{"a.go", "b.go"})
if len(got) != 2 || got[0] != "a.go" || got[1] != "b.go" {
t.Errorf("got %v", got)
}
})
t.Run("[]any strings", func(t *testing.T) {
got := extractChangesPaths([]any{"src/**", "*.yml"})
if len(got) != 2 || got[0] != "src/**" || got[1] != "*.yml" {
t.Errorf("got %v", got)
}
})
t.Run("[]any ignores non-strings", func(t *testing.T) {
got := extractChangesPaths([]any{"ok", 42})
if len(got) != 1 || got[0] != "ok" {
t.Errorf("got %v", got)
}
})
t.Run("map with paths key", func(t *testing.T) {
got := extractChangesPaths(map[string]any{
"paths": []any{"src/**"},
"compare_to": "origin/main",
})
if len(got) != 1 || got[0] != "src/**" {
t.Errorf("got %v", got)
}
})
t.Run("map without paths key → nil", func(t *testing.T) {
got := extractChangesPaths(map[string]any{"compare_to": "origin/main"})
if got != nil {
t.Errorf("expected nil, got %v", got)
}
})
t.Run("unknown type → nil", func(t *testing.T) {
got := extractChangesPaths(12345)
if got != nil {
t.Errorf("expected nil, got %v", got)
}
})
}
// ── changesMatch ──────────────────────────────────────────────────────────────
func TestChangesMatch(t *testing.T) {
t.Run("nil changes → always true", func(t *testing.T) {
ctx := New("main", "", "", nil)
ctx.SetChangedFiles([]string{"anything.go"})
if !changesMatch(nil, ctx) {
t.Error("nil changes should always match")
}
})
t.Run("nil changedFiles → permissive true", func(t *testing.T) {
ctx := New("main", "", "", nil)
// changedFiles is nil by default → permissive
if !changesMatch([]any{"src/**"}, ctx) {
t.Error("nil changedFiles should be permissive (true)")
}
})
t.Run("no match → false", func(t *testing.T) {
ctx := New("main", "", "", nil)
ctx.SetChangedFiles([]string{"test/main_test.go"})
if changesMatch([]any{"src/**/*.go"}, ctx) {
t.Error("should not match: changed file not under src/")
}
})
t.Run("match → true", func(t *testing.T) {
ctx := New("main", "", "", nil)
ctx.SetChangedFiles([]string{"src/pkg/main.go"})
if !changesMatch([]any{"src/**/*.go"}, ctx) {
t.Error("should match: src/pkg/main.go satisfies src/**/*.go")
}
})
t.Run("empty changedFiles + non-nil changes → false", func(t *testing.T) {
ctx := New("main", "", "", nil)
ctx.SetChangedFiles([]string{}) // explicit empty list
if changesMatch([]any{"Dockerfile"}, ctx) {
t.Error("empty file list with active filter should be false")
}
})
t.Run("one of many changed files matches", func(t *testing.T) {
ctx := New("main", "", "", nil)
ctx.SetChangedFiles([]string{"README.md", "src/main.go", "go.sum"})
if !changesMatch([]any{"src/**"}, ctx) {
t.Error("src/main.go should satisfy src/**")
}
})
}
// ── EvalJob with rules:changes: ───────────────────────────────────────────────
func TestEvalJob_RulesChanges(t *testing.T) {
makeJob := func(rules []model.Rule) model.Job {
return model.Job{Name: "job", Script: []any{"echo"}, Rules: rules}
}
t.Run("no changed files (permissive) — rule fires", func(t *testing.T) {
ctx := New("main", "", "", nil)
// changedFiles nil → permissive
job := makeJob([]model.Rule{{Changes: []any{"src/**/*.go"}, When: "on_success"}})
if EvalJob(job, ctx) != JobActive {
t.Error("expected active: permissive when no file list")
}
})
t.Run("matching changed file — rule fires", func(t *testing.T) {
ctx := New("main", "", "", nil)
ctx.SetChangedFiles([]string{"src/main.go"})
job := makeJob([]model.Rule{{Changes: []any{"src/**"}, When: "on_success"}})
if EvalJob(job, ctx) != JobActive {
t.Error("expected active: src/main.go matches src/**")
}
})
t.Run("no matching file — rule skipped, no other rule → skipped", func(t *testing.T) {
ctx := New("main", "", "", nil)
ctx.SetChangedFiles([]string{"docs/README.md"})
job := makeJob([]model.Rule{{Changes: []any{"src/**"}, When: "on_success"}})
if EvalJob(job, ctx) != JobSkipped {
t.Error("expected skipped: docs/README.md does not match src/**")
}
})
t.Run("changes filter with if: both must pass", func(t *testing.T) {
ctx := New("main", "", "", nil)
ctx.SetChangedFiles([]string{"src/main.go"})
job := makeJob([]model.Rule{
{If: `$CI_COMMIT_BRANCH == "develop"`, Changes: []any{"src/**"}, When: "on_success"},
})
// if: fails → rule skipped even though changes match
if EvalJob(job, ctx) != JobSkipped {
t.Error("expected skipped: if: condition fails")
}
})
t.Run("map form changes: {paths: [...]}", func(t *testing.T) {
ctx := New("main", "", "", nil)
ctx.SetChangedFiles([]string{"Dockerfile"})
job := makeJob([]model.Rule{{
Changes: map[string]any{"paths": []any{"Dockerfile"}, "compare_to": "origin/main"},
When: "on_success",
}})
if EvalJob(job, ctx) != JobActive {
t.Error("expected active: map form changes should work")
}
})
}
// ── EvalWorkflow with rules:changes: ─────────────────────────────────────────
func TestEvalWorkflow_Changes(t *testing.T) {
makePipeline := func(rules []model.Rule) *model.Pipeline {
return &model.Pipeline{Workflow: &model.Workflow{Rules: rules}}
}
t.Run("no changedFiles (permissive) → pipeline runs", func(t *testing.T) {
ctx := New("main", "", "", nil)
p := makePipeline([]model.Rule{{Changes: []any{"src/**"}, When: "always"}})
runs, _ := EvalWorkflow(p, ctx)
if !runs {
t.Error("expected pipeline to run: permissive when no file list")
}
})
t.Run("matching file → pipeline runs", func(t *testing.T) {
ctx := New("main", "", "", nil)
ctx.SetChangedFiles([]string{"src/app.go"})
p := makePipeline([]model.Rule{{Changes: []any{"src/**"}, When: "always"}})
runs, _ := EvalWorkflow(p, ctx)
if !runs {
t.Error("expected pipeline to run: src/app.go matches src/**")
}
})
t.Run("no matching file → no rule matches → pipeline blocked", func(t *testing.T) {
ctx := New("main", "", "", nil)
ctx.SetChangedFiles([]string{"docs/README.md"})
p := makePipeline([]model.Rule{{Changes: []any{"src/**"}, When: "always"}})
runs, _ := EvalWorkflow(p, ctx)
if runs {
t.Error("expected pipeline blocked: docs/README.md does not match src/**")
}
})
}
// ── SetChangedFiles / Summary ─────────────────────────────────────────────────
func TestSetChangedFiles(t *testing.T) {
ctx := New("main", "", "", nil)
if ctx.changedFiles != nil {
t.Error("changedFiles should be nil initially")
}
ctx.SetChangedFiles([]string{"a.go", "b.go"})
if len(ctx.changedFiles) != 2 {
t.Errorf("expected 2 changed files, got %d", len(ctx.changedFiles))
}
// nil receiver should not panic
var nilCtx *Context
nilCtx.SetChangedFiles([]string{"x"})
}
func TestSummary_WithChangedFiles(t *testing.T) {
ctx := New("main", "", "", nil)
// Without changed files
if s := ctx.Summary(); s != "branch=main, source=push" {
t.Errorf("unexpected summary without changes: %q", s)
}
// With changed files
ctx.SetChangedFiles([]string{"a.go", "b.go"})
s := ctx.Summary()
if s != "branch=main, source=push, 2 changed file(s)" {
t.Errorf("unexpected summary with changes: %q", s)
}
// With empty changed files (strict, 0 files)
ctx.SetChangedFiles([]string{})
s = ctx.Summary()
if s != "branch=main, source=push, 0 changed file(s)" {
t.Errorf("unexpected summary with 0 changes: %q", s)
}
}
+18 -2
View File
@@ -9,8 +9,9 @@ import (
// pipeline evaluation (rules:if:, only:, except:, workflow:rules:).
// Variables are keyed by their name without the leading $.
type Context struct {
Vars map[string]string
pinned map[string]bool // vars set via --var or shortcuts; never overwritten by Inject
Vars map[string]string
pinned map[string]bool // vars set via --var or shortcuts; never overwritten by Inject
changedFiles []string // nil = not provided (permissive); non-nil = known set of changed files
}
// New builds a Context from high-level shortcut values and optional KEY=VALUE
@@ -101,6 +102,18 @@ func (c *Context) Inject(key, value string) {
c.Vars[key] = value
}
// SetChangedFiles records the list of files that changed for rules:changes:
// evaluation. A non-nil slice (even empty) enables strict matching: only jobs
// whose rules:changes: patterns match at least one file in the list will have
// that rule fire. A nil slice (the default) means "not provided" — rules:changes:
// conditions are treated as always satisfied (permissive), preserving the
// behaviour when no changed-file data is available.
func (c *Context) SetChangedFiles(files []string) {
if c != nil {
c.changedFiles = files
}
}
// Summary returns a short human-readable description of the context for CLI output.
func (c *Context) Summary() string {
if c.IsEmpty() {
@@ -115,6 +128,9 @@ func (c *Context) Summary() string {
if v := c.Get("CI_PIPELINE_SOURCE"); v != "" {
parts = append(parts, "source="+v)
}
if c.changedFiles != nil {
parts = append(parts, fmt.Sprintf("%d changed file(s)", len(c.changedFiles)))
}
return strings.Join(parts, ", ")
}
+253
View File
@@ -0,0 +1,253 @@
package cicontext
import (
"testing"
)
// ── New / IsEmpty / Get / Inject ──────────────────────────────────────────────
func TestNew_Empty(t *testing.T) {
ctx := New("", "", "", nil)
if !ctx.IsEmpty() {
t.Error("expected empty context")
}
if ctx.Get("CI_COMMIT_BRANCH") != "" {
t.Error("expected empty Get on empty context")
}
}
func TestNew_Branch(t *testing.T) {
ctx := New("feature/abc", "", "", nil)
if ctx.Get("CI_COMMIT_BRANCH") != "feature/abc" {
t.Errorf("unexpected branch: %q", ctx.Get("CI_COMMIT_BRANCH"))
}
if ctx.Get("CI_COMMIT_REF_SLUG") != "feature-abc" {
t.Errorf("unexpected slug: %q", ctx.Get("CI_COMMIT_REF_SLUG"))
}
if ctx.Get("CI_PIPELINE_SOURCE") != "push" {
t.Error("expected default source=push for branch")
}
}
func TestNew_Tag(t *testing.T) {
ctx := New("", "v1.0.0", "", nil)
if ctx.Get("CI_COMMIT_TAG") != "v1.0.0" {
t.Errorf("unexpected tag: %q", ctx.Get("CI_COMMIT_TAG"))
}
// tag should clear CI_COMMIT_BRANCH
if ctx.Get("CI_COMMIT_BRANCH") != "" {
t.Error("CI_COMMIT_BRANCH should be cleared when tag is set")
}
if ctx.Get("CI_PIPELINE_SOURCE") != "push" {
t.Error("expected default source=push for tag")
}
}
func TestNew_BranchAndTagTogether(t *testing.T) {
// branch set first, then tag overwrites REF_NAME and clears BRANCH
ctx := New("main", "v2.0", "", nil)
if ctx.Get("CI_COMMIT_BRANCH") != "" {
t.Error("BRANCH should be cleared by tag")
}
if ctx.Get("CI_COMMIT_TAG") != "v2.0" {
t.Errorf("unexpected tag %q", ctx.Get("CI_COMMIT_TAG"))
}
}
func TestNew_ExtraVars(t *testing.T) {
ctx := New("main", "", "", []string{"DEPLOY_ENV=prod", "BAD_NO_EQUALS"})
if ctx.Get("DEPLOY_ENV") != "prod" {
t.Errorf("extra var not set: %q", ctx.Get("DEPLOY_ENV"))
}
if ctx.Get("BAD_NO_EQUALS") != "" {
t.Error("malformed KEY=VALUE should not be set")
}
}
func TestNew_SourceOverride(t *testing.T) {
ctx := New("", "", "schedule", nil)
if ctx.Get("CI_PIPELINE_SOURCE") != "schedule" {
t.Errorf("unexpected source: %q", ctx.Get("CI_PIPELINE_SOURCE"))
}
}
func TestNew_DefaultBranch(t *testing.T) {
ctx := New("feature", "", "", nil)
if ctx.Get("CI_DEFAULT_BRANCH") != "main" {
t.Errorf("unexpected default branch: %q", ctx.Get("CI_DEFAULT_BRANCH"))
}
}
func TestInject(t *testing.T) {
ctx := New("main", "", "", nil)
// pinned var should not be overwritten
ctx.Inject("CI_COMMIT_BRANCH", "other")
if ctx.Get("CI_COMMIT_BRANCH") != "main" {
t.Error("pinned var was overwritten by Inject")
}
// non-pinned var should be injected
ctx.Inject("MY_VAR", "hello")
if ctx.Get("MY_VAR") != "hello" {
t.Errorf("Inject failed: %q", ctx.Get("MY_VAR"))
}
}
func TestInject_NilVarsMap(t *testing.T) {
ctx := &Context{}
ctx.Inject("K", "v") // should not panic
if ctx.Get("K") != "v" {
t.Error("Inject on nil Vars should initialise the map")
}
}
func TestGet_NilContext(t *testing.T) {
var ctx *Context
if ctx.Get("X") != "" {
t.Error("nil context Get should return empty string")
}
}
// ── Summary ───────────────────────────────────────────────────────────────────
func TestSummary(t *testing.T) {
cases := []struct {
name string
branch string
tag string
source string
want string
}{
{"empty context", "", "", "", ""},
{"branch only", "main", "", "", "branch=main, source=push"},
{"tag only", "", "v1.0", "", "tag=v1.0, source=push"},
{"source only", "", "", "schedule", "source=schedule"},
{"branch + source", "develop", "", "merge_request_event", "branch=develop, source=merge_request_event"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
ctx := New(tc.branch, tc.tag, tc.source, nil)
got := ctx.Summary()
if got != tc.want {
t.Errorf("got %q want %q", got, tc.want)
}
})
}
}
// ── ScalarString ──────────────────────────────────────────────────────────────
func TestScalarString(t *testing.T) {
cases := []struct {
name string
input any
wantS string
wantOK bool
}{
{"string", "hello", "hello", true},
{"bool true", true, "true", true},
{"bool false", false, "false", true},
{"int", 42, "42", true},
{"float64", 3.14, "3.14", true},
{"map with value key", map[string]any{"value": "v"}, "v", true},
{"map without value key", map[string]any{"description": "x"}, "", false},
{"nil", nil, "", false},
{"slice", []any{1, 2}, "", false},
{"nested map value", map[string]any{"value": 99}, "99", true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
s, ok := ScalarString(tc.input)
if ok != tc.wantOK || s != tc.wantS {
t.Errorf("got (%q, %v) want (%q, %v)", s, ok, tc.wantS, tc.wantOK)
}
})
}
}
// ── ExpandVars / expandVarRefs ────────────────────────────────────────────────
func TestExpandVars(t *testing.T) {
t.Run("simple expansion", func(t *testing.T) {
ctx := &Context{Vars: map[string]string{"A": "hello", "B": "$A world"}}
ctx.ExpandVars()
if ctx.Vars["B"] != "hello world" {
t.Errorf("got %q", ctx.Vars["B"])
}
})
t.Run("transitive chain", func(t *testing.T) {
ctx := &Context{Vars: map[string]string{"A": "x", "B": "$A", "C": "$B"}}
ctx.ExpandVars()
if ctx.Vars["C"] != "x" {
t.Errorf("got %q", ctx.Vars["C"])
}
})
t.Run("circular reference stops", func(t *testing.T) {
ctx := &Context{Vars: map[string]string{"A": "$B", "B": "$A"}}
ctx.ExpandVars() // must not infinite-loop
})
t.Run("nil context is noop", func(t *testing.T) {
var ctx *Context
ctx.ExpandVars() // must not panic
})
t.Run("empty vars is noop", func(t *testing.T) {
ctx := &Context{}
ctx.ExpandVars() // must not panic
})
}
func TestExpandVarRefs(t *testing.T) {
vars := map[string]string{"FOO": "bar", "X": "123"}
cases := []struct {
name string
input string
want string
}{
{"no dollar", "hello", "hello"},
{"simple ref", "$FOO", "bar"},
{"braced ref", "${FOO}", "bar"},
{"unknown ref unchanged", "$UNKNOWN", "$UNKNOWN"},
{"unknown braced unchanged", "${UNKNOWN}", "${UNKNOWN}"},
{"trailing dollar", "abc$", "abc$"},
{"dollar at end after ident", "$X_", "$X_"},
// Malformed ${…} without closing brace: "${" emitted, ident chars consumed.
{"malformed brace no close", "${FOO", "${"},
{"mixed", "pre_${FOO}_$X", "pre_bar_123"},
{"dollar no ident after", "$ abc", "$ abc"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := expandVarRefs(tc.input, vars)
if got != tc.want {
t.Errorf("got %q want %q", got, tc.want)
}
})
}
}
// ── slugify ───────────────────────────────────────────────────────────────────
func TestSlugify(t *testing.T) {
cases := []struct {
input string
want string
}{
{"main", "main"},
{"feature/my-branch", "feature-my-branch"},
{"Feature_123", "feature-123"},
{"--leading", "leading"},
{"trailing--", "trailing"},
{"v1.2.3", "v1-2-3"},
}
for _, tc := range cases {
t.Run(tc.input, func(t *testing.T) {
got := slugify(tc.input)
if got != tc.want {
t.Errorf("got %q want %q", got, tc.want)
}
})
}
}
-3
View File
@@ -339,9 +339,6 @@ func (p *exprParser) parseStringLiteral() (string, bool) {
}
func (p *exprParser) parseRegexLiteral() (string, bool) {
if p.peek() != '/' {
return "", false
}
p.pos++ // consume opening '/'
var sb strings.Builder
for p.pos < len(p.s) {
+146
View File
@@ -138,6 +138,152 @@ func TestEvalIf(t *testing.T) {
}
}
// TestEvalIfParserEdgeCases exercises low-level parser branches not reached
// by the main table-driven tests above.
func TestEvalIfParserEdgeCases(t *testing.T) {
vars := func(key string) string {
switch key {
case "BRANCH":
return "develop"
case "UNTERMINATED_RE":
return "/no-end"
case "ESCAPED_RE":
return `/^dev\./`
}
return ""
}
// parseOr: right side of || fails to parse.
if !EvalIf(`$BRANCH || !(`, vars) {
t.Error("parseOr bad right: expect permissive-true")
}
if EvalIfStrict(`$BRANCH || !(`, vars) {
t.Error("parseOr bad right: expect strict-false")
}
// parsePrimary: inner parseOr succeeds but no closing ')'.
if !EvalIf(`($BRANCH == "develop"`, vars) {
t.Error("unmatched paren: expect permissive-true")
}
if EvalIfStrict(`($BRANCH == "develop"`, vars) {
t.Error("unmatched paren: expect strict-false")
}
// parseComparison ==: right-hand parseValue fails (! is not a valid value start).
if !EvalIf(`$BRANCH == !invalid`, vars) {
t.Error("== bad rhs: expect permissive-true")
}
// parseComparison !=: right-hand parseValue fails.
if !EvalIf(`$BRANCH != ${`, vars) {
t.Error("!= bad rhs: expect permissive-true")
}
// parseRegexRHS: peek is neither '/' nor '$' → return "", false, false.
// parseComparison =~: patOk=false, permissive=false → return false, false.
if !EvalIf(`$BRANCH =~ "literal"`, vars) {
t.Error("=~ string literal rhs: expect permissive-true")
}
if EvalIfStrict(`$BRANCH =~ "literal"`, vars) {
t.Error("=~ string literal rhs: expect strict-false")
}
// parseComparison =~: bad regex pattern → compile error → return true, true.
if !EvalIf(`$BRANCH =~ /[unclosed/`, vars) {
t.Error("=~ bad regex: expect permissive-true")
}
// parseComparison !~: patOk=false → return false, false.
if !EvalIf(`$BRANCH !~ "literal"`, vars) {
t.Error("!~ string literal rhs: expect permissive-true")
}
if EvalIfStrict(`$BRANCH !~ "literal"`, vars) {
t.Error("!~ string literal rhs: expect strict-false")
}
// parseComparison !~: bad regex pattern → compile error → return true, true.
if !EvalIf(`$BRANCH !~ /[unclosed/`, vars) {
t.Error("!~ bad regex: expect permissive-true")
}
// parseComparison single =: RHS parseValue fails.
if !EvalIf(`$BRANCH = !invalid`, vars) {
t.Error("single = bad rhs: expect permissive-true")
}
// parseValue: '$' not followed by a valid identifier.
if !EvalIf(`$} == "develop"`, vars) {
t.Error("$ bad ident: expect permissive-true")
}
// parseValue: '${' with no closing '}' or empty name.
if !EvalIf(`${} == "develop"`, vars) {
t.Error("${} empty name: expect permissive-true")
}
if !EvalIf(`${BRANCH == "develop"`, vars) {
t.Error("${BRANCH no close brace: expect permissive-true")
}
// parseStringLiteral: escape sequence — \v is consumed and the next byte
// is written literally, so "de\velop" → "develop" which matches BRANCH.
if !EvalIf(`$BRANCH == "de\velop"`, vars) {
t.Error(`string escape: "de\velop" should decode to "develop"`)
}
// parseStringLiteral: unterminated string literal.
if !EvalIf(`$BRANCH == "no-end`, vars) {
t.Error("unterminated string: expect permissive-true")
}
if EvalIfStrict(`$BRANCH == "no-end`, vars) {
t.Error("unterminated string: expect strict-false")
}
// parseRegexLiteral: escape sequence in regex (backslash preserved).
// /^d\evelop/ → pattern "^d\evelop" — \e is invalid in Go regexp
// → compile error → permissive true.
if !EvalIf(`$BRANCH =~ /^d\evelop/`, vars) {
t.Error("regex escape (bad compile): expect permissive-true")
}
// parseRegexLiteral: unterminated regex (no closing '/').
if !EvalIf(`$BRANCH =~ /no-end`, vars) {
t.Error("unterminated regex: expect permissive-true")
}
if EvalIfStrict(`$BRANCH =~ /no-end`, vars) {
t.Error("unterminated regex: expect strict-false")
}
// applyRegexFlags: 'm' and 's' flags (exercises two additional switch cases).
if !EvalIf(`$BRANCH =~ /^DEV/ims`, vars) {
t.Error("regex /ims flags: case-insensitive should match 'develop'")
}
// extractRegexFromString: unterminated regex in variable value.
// UNTERMINATED_RE = "/no-end" (no closing '/') → permissive.
if !EvalIf(`$BRANCH =~ $UNTERMINATED_RE`, vars) {
t.Error("unterminated re in var: expect permissive-true")
}
// extractRegexFromString: escape sequence in variable value.
// ESCAPED_RE = /^dev\./ → pattern = "^dev\." (literal dot) → no match for "develop".
if EvalIf(`$BRANCH =~ $ESCAPED_RE`, vars) {
t.Error("ESCAPED_RE=/^dev\\./ requires a literal dot; 'develop' has no dot")
}
// !~ permissive path (line 203-205): var value is a plain string, not /regex/ → permissive.
// BRANCH = "develop" which does not start with '/' → extractRegexFromString returns ok=false
// → parseRegexRHS returns permissive=true → !~ case returns (true, true).
if !EvalIf(`$BRANCH !~ $BRANCH`, vars) {
t.Error("!~ plain-var rhs: plain variable value triggers permissive-true")
}
// parseRegexRHS: '$' in rhs but parseValue fails (line 244-246).
// '$}' — '$' followed by '}' which is not a valid identifier start → varOk=false.
if !EvalIf(`$BRANCH =~ $}`, vars) {
t.Error("=~ $}: parseValue fails → permissive-true (EvalIf overall)")
}
}
func TestEvalIfStrict(t *testing.T) {
vars := func(key string) string {
m := map[string]string{
+85
View File
@@ -0,0 +1,85 @@
package cicontext
import "testing"
// FuzzEvalIf ensures the rules:if: expression evaluator never panics on
// arbitrary input. It accepts two strings: the expression and a variable value
// substituted for every variable reference encountered.
// Run with: go test -fuzz=FuzzEvalIf ./internal/cicontext/
func FuzzEvalIf(f *testing.F) {
// Seed corpus: representative expressions exercising every code path in
// the hand-rolled recursive-descent parser.
seeds := []struct{ expr, varVal string }{
// Simple comparisons
{`$VAR == "main"`, "main"},
{`$VAR != "main"`, "main"},
{`$VAR == null`, ""},
{`$VAR != null`, "x"},
// Regex operators
{`$VAR =~ /^v\d+\.\d+/`, "v1.2.3"},
{`$VAR !~ /^v\d+\.\d+/`, "not-a-version"},
{`$VAR =~ /^us\//`, "us/west"},
// Boolean operators
{`$A == "x" && $B == "y"`, "x"},
{`$A == "x" || $B == "y"`, "z"},
{`!($VAR == "main")`, "main"},
// Nested parens
{`($VAR == "a" || $VAR == "b") && $VAR != "c"`, "a"},
// Bare true / false
{`$VAR == true`, "true"},
{`$VAR == false`, "false"},
// Integer comparison (GitLab CI compares as strings)
{`$VAR == 42`, "42"},
// Regex with flags
{`$VAR =~ /main/i`, "MAIN"},
// Syntax errors / incomplete expressions
{``, ""},
{`&&`, ""},
{`$VAR =~`, "x"},
{`($VAR`, "x"},
{`$VAR == `, "x"},
// Variable syntax variants
{`${VAR} == "main"`, "main"},
// Deeply nested
{`((($VAR == "a")))`, "a"},
// String with escapes
{`$VAR == "hello\nworld"`, "hello\nworld"},
// Null literal
{`null == null`, ""},
{`$VAR == null`, ""},
}
for _, s := range seeds {
f.Add(s.expr, s.varVal)
}
f.Fuzz(func(t *testing.T, expr, varVal string) {
// EvalIf must never panic; it may return any bool.
_ = EvalIf(expr, func(string) string { return varVal })
_ = EvalIfStrict(expr, func(string) string { return varVal })
})
}
// FuzzExpandVarRefs ensures variable expansion in expression strings never
// panics and never produces a longer output than the worst-case expansion bound.
func FuzzExpandVarRefs(f *testing.F) {
seeds := []struct{ s, val string }{
{"$VAR", "hello"},
{"${VAR}", "hello"},
{"$A $B $C", "x"},
{"no vars here", ""},
{"$$double", "x"},
{"$", "x"},
{"${", "x"},
{"${}", "x"},
{"prefix_$VAR_suffix", "mid"},
{"$1INVALID", "x"},
}
for _, s := range seeds {
f.Add(s.s, s.val)
}
f.Fuzz(func(t *testing.T, s, val string) {
vars := map[string]string{"VAR": val, "A": val, "B": val, "C": val}
_ = expandVarRefs(s, vars)
})
}
+95
View File
@@ -1,6 +1,7 @@
package cicontext
import (
"path"
"regexp"
"strings"
@@ -49,6 +50,9 @@ func EvalWorkflow(p *model.Pipeline, ctx *Context) (bool, map[string]string) {
if !ruleIfMatchesStrict(rule.If, vars) {
continue
}
if !changesMatch(rule.Changes, ctx) {
continue
}
when := rule.When
if when == "" {
when = "always"
@@ -74,6 +78,9 @@ func EvalJob(job model.Job, ctx *Context) JobState {
if !ruleIfMatches(rule.If, vars) {
continue
}
if !changesMatch(rule.Changes, ctx) {
continue
}
return whenToState(rule.When)
}
return JobSkipped // no rule matched → job is excluded
@@ -262,3 +269,91 @@ func matchGlob(pattern, s string) bool {
}
return len(s) == 0
}
// ── rules:changes: evaluation ─────────────────────────────────────────────────
// changesMatch reports whether the rule's changes: filter is satisfied.
//
// - rule.Changes == nil → no filter; always true
// - ctx.changedFiles == nil → file list not provided; always true (permissive)
// - otherwise → true iff at least one changed file matches at least one pattern
func changesMatch(changes any, ctx *Context) bool {
patterns := extractChangesPaths(changes)
if patterns == nil {
return true // no changes: filter
}
if ctx.changedFiles == nil {
return true // no file list provided → permissive
}
for _, changed := range ctx.changedFiles {
for _, pat := range patterns {
if doublestarMatch(pat, changed) {
return true
}
}
}
return false
}
// extractChangesPaths normalises a rule.Changes value into a []string of glob
// patterns. Returns nil when no changes: filter is present.
func extractChangesPaths(changes any) []string {
switch v := changes.(type) {
case nil:
return nil
case string:
return []string{v}
case []string:
return v
case []any:
var out []string
for _, item := range v {
if s, ok := item.(string); ok {
out = append(out, s)
}
}
return out
case map[string]any:
// Extended form: { paths: [...], compare_to: "..." }
if raw, ok := v["paths"]; ok {
return extractChangesPaths(raw)
}
return nil
}
return nil
}
// doublestarMatch reports whether pattern matches the file path using GitLab-style
// glob rules: '*' matches any character sequence within a single path segment;
// '**' matches zero or more path segments (crossing '/' boundaries).
func doublestarMatch(pattern, filePath string) bool {
return matchParts(strings.Split(pattern, "/"), strings.Split(filePath, "/"))
}
// matchParts is the recursive engine for doublestarMatch.
func matchParts(pat, name []string) bool {
for len(pat) > 0 {
if pat[0] == "**" {
if len(pat) == 1 {
return true // trailing ** matches everything remaining
}
// ** can match 0, 1, 2, … leading segments of name.
for i := 0; i <= len(name); i++ {
if matchParts(pat[1:], name[i:]) {
return true
}
}
return false
}
if len(name) == 0 {
return false
}
ok, err := path.Match(pat[0], name[0])
if err != nil || !ok {
return false
}
pat = pat[1:]
name = name[1:]
}
return len(name) == 0
}
@@ -0,0 +1,262 @@
package cicontext
import (
"testing"
"git.k3nny.fr/glint/internal/model"
)
// ── JobState.String ───────────────────────────────────────────────────────────
func TestJobStateString(t *testing.T) {
if JobActive.String() != "active" { t.Errorf("active: got %q", JobActive.String()) }
if JobManual.String() != "manual" { t.Errorf("manual: got %q", JobManual.String()) }
if JobSkipped.String() != "skipped" { t.Errorf("skipped: got %q", JobSkipped.String()) }
}
// ── whenToState ───────────────────────────────────────────────────────────────
func TestWhenToState(t *testing.T) {
cases := []struct { when string; want JobState }{
{"never", JobSkipped},
{"manual", JobManual},
{"on_success", JobActive},
{"always", JobActive},
{"", JobActive},
}
for _, tc := range cases {
got := whenToState(tc.when)
if got != tc.want {
t.Errorf("whenToState(%q)=%v want %v", tc.when, got, tc.want)
}
}
}
// ── EvalJob: only/except paths ────────────────────────────────────────────────
func TestEvalJob_OnlyExcept(t *testing.T) {
ctx := New("main", "", "", nil)
t.Run("only branches matches", func(t *testing.T) {
job := model.Job{Only: []any{"branches"}}
if EvalJob(job, ctx) != JobActive {
t.Error("expected active on branch")
}
})
t.Run("only tags excludes branch push", func(t *testing.T) {
job := model.Job{Only: []any{"tags"}}
if EvalJob(job, ctx) != JobSkipped {
t.Error("expected skipped — we are on a branch, not tag")
}
})
t.Run("except branches skips on branch push", func(t *testing.T) {
job := model.Job{Script: "echo ok", Except: []any{"branches"}}
if EvalJob(job, ctx) != JobSkipped {
t.Error("expected skipped — branches excluded")
}
})
t.Run("except non-matching source does not skip", func(t *testing.T) {
job := model.Job{Except: []any{"schedules"}}
if EvalJob(job, ctx) != JobActive {
t.Error("expected active — schedule is not the source")
}
})
t.Run("job when: manual with no filter", func(t *testing.T) {
job := model.Job{When: "manual"}
if EvalJob(job, ctx) != JobManual {
t.Error("expected manual")
}
})
t.Run("empty context returns active", func(t *testing.T) {
emptyCtx := New("", "", "", nil)
job := model.Job{Only: []any{"tags"}}
if EvalJob(job, emptyCtx) != JobActive {
t.Error("empty context should always return active")
}
})
}
// ── EvalJob: rules path ───────────────────────────────────────────────────────
func TestEvalJob_Rules(t *testing.T) {
ctx := New("main", "", "", nil)
t.Run("rule with never when excludes", func(t *testing.T) {
job := model.Job{Rules: []model.Rule{{When: "never"}}}
if EvalJob(job, ctx) != JobSkipped {
t.Error("expected skipped — when:never")
}
})
t.Run("no matching rule → skipped", func(t *testing.T) {
job := model.Job{Rules: []model.Rule{
{If: "$CI_COMMIT_BRANCH == \"develop\"", When: "on_success"},
}}
if EvalJob(job, ctx) != JobSkipped {
t.Error("expected skipped — no rule matches main branch with develop condition")
}
})
}
// ── onlyMatches / exceptMatches ───────────────────────────────────────────────
func TestOnlyMatches(t *testing.T) {
branchCtx := New("main", "", "", nil)
tagCtx := New("", "v1.0", "", nil)
mrCtx := New("", "", "merge_request_event", nil)
cases := []struct {
name string
only any
ctx *Context
want bool
}{
{"nil only always matches", nil, branchCtx, true},
{"unrecognised form permissive", 42, branchCtx, true},
{"string form — branches keyword", "branches", branchCtx, true},
{"string form — tags keyword miss", "tags", branchCtx, false},
{"slice — merge_requests keyword", []any{"merge_requests"}, mrCtx, true},
{"slice — schedules keyword", []any{"schedules"}, branchCtx, false},
{"slice — pipelines keyword", []any{"pipelines"}, New("","","pipeline",nil), true},
{"slice — pushes keyword", []any{"pushes"}, New("","","push",nil), true},
{"slice — web keyword", []any{"web"}, New("","","web",nil), true},
{"slice — api keyword", []any{"api"}, New("","","api",nil), true},
{"slice — tag keyword match", []any{"tags"}, tagCtx, true},
{"glob pattern matches", []any{"feat/*"}, New("feat/abc","","",nil), true},
{"glob no match", []any{"feat/*"}, branchCtx, false},
{"regex pattern matches branch", []any{"/^main.*/"}, branchCtx, true},
{"regex pattern no match", []any{"/^develop/"}, branchCtx, false},
{"invalid regex treated as glob", []any{"/[invalid/"}, branchCtx, false},
{"map form with refs key", map[string]any{"refs": []any{"branches"}}, branchCtx, true},
{"map form no refs — permissive", map[string]any{"variables": []any{}}, branchCtx, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := onlyMatches(tc.only, tc.ctx)
if got != tc.want {
t.Errorf("onlyMatches: got %v want %v", got, tc.want)
}
})
}
}
func TestExceptMatches(t *testing.T) {
branchCtx := New("main", "", "", nil)
cases := []struct {
name string
except any
want bool
}{
{"nil except never excludes", nil, false},
{"unrecognised form not excluded", 42, false},
{"branches excludes on branch push", []any{"branches"}, true},
{"tags does not exclude branch push", []any{"tags"}, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := exceptMatches(tc.except, branchCtx)
if got != tc.want {
t.Errorf("exceptMatches: got %v want %v", got, tc.want)
}
})
}
}
// ── extractRefList ────────────────────────────────────────────────────────────
func TestExtractRefList(t *testing.T) {
cases := []struct {
name string
input any
wantN int // -1 means nil expected
}{
{"string", "branches", 1},
{"slice of strings", []any{"a", "b", "c"}, 3},
{"slice with non-string items skipped", []any{"a", 42, "b"}, 2},
{"map with refs key", map[string]any{"refs": []any{"branches"}}, 1},
{"map without refs key returns nil", map[string]any{"variables": nil}, -1},
{"unknown type returns nil", 123, -1},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := extractRefList(tc.input)
if tc.wantN == -1 {
if got != nil { t.Errorf("expected nil, got %v", got) }
} else {
if len(got) != tc.wantN { t.Errorf("len: got %d want %d (%v)", len(got), tc.wantN, got) }
}
})
}
}
// ── globMatches / matchGlob ───────────────────────────────────────────────────
func TestGlobMatches(t *testing.T) {
cases := []struct {
pattern string
s string
want bool
}{
{"main", "main", true},
{"main", "develop", false},
{"feat/*", "feat/abc", true},
{"feat/*", "feat/", true},
{"feat/*", "main", false},
{"*", "anything", true},
{"*", "", true},
{"prefix*suffix", "prefixMIDDLEsuffix", true},
{"prefix*suffix", "prefixsuffix", true},
{"prefix*suffix", "prefixNO", false},
{"a*b*c", "aXbYc", true},
{"a*b*c", "abc", true},
}
for _, tc := range cases {
t.Run(tc.pattern+"/"+tc.s, func(t *testing.T) {
got := globMatches(tc.pattern, tc.s)
if got != tc.want {
t.Errorf("globMatches(%q, %q)=%v want %v", tc.pattern, tc.s, got, tc.want)
}
})
}
}
// ── refPatternMatches ─────────────────────────────────────────────────────────
func TestRefPatternMatches(t *testing.T) {
cases := []struct {
pattern string
ref string
want bool
}{
{"/^main$/", "main", true},
{"/^main$/", "develop", false},
{"/feature/", "feature/abc", true},
{"feat/*", "feat/x", true},
{"main", "main", true},
{"main", "develop", false},
{"", "main", false}, // empty ref for pattern with no wildcard
}
for _, tc := range cases {
t.Run(tc.pattern+"@"+tc.ref, func(t *testing.T) {
got := refPatternMatches(tc.pattern, tc.ref)
if got != tc.want {
t.Errorf("refPatternMatches(%q, %q)=%v want %v", tc.pattern, tc.ref, got, tc.want)
}
})
}
}
// TestRefListMatches_Schedule verifies the "schedules" keyword matches when
// CI_PIPELINE_SOURCE is "schedule".
func TestRefListMatches_Schedule(t *testing.T) {
ctx := New("", "", "schedule", nil)
if !refListMatches([]string{"schedules"}, ctx) {
t.Error("schedules keyword should match when source is 'schedule'")
}
}
+6
View File
@@ -38,6 +38,12 @@ type Config struct {
// CacheDir is the default directory for caching fetched remote includes.
// Overridden by the --cache-dir flag.
CacheDir string `yaml:"cache_dir"`
// Proxy is the HTTP proxy URL for fetching remote includes and GitLab API
// calls (e.g. "http://proxy.example.com:8080"). Overridden by the --proxy
// flag. When empty, system proxy settings (HTTP_PROXY / HTTPS_PROXY /
// NO_PROXY env vars) are used automatically.
Proxy string `yaml:"proxy"`
}
// Load searches for a .glint.yml file starting from dir and walking up toward
+15
View File
@@ -100,6 +100,21 @@ func TestLoad_StopsAtGitRoot(t *testing.T) {
}
}
// TestLoad_ReadError covers the !os.IsNotExist(err) branch (config.go:59-61)
// when the file exists but is not readable.
func TestLoad_ReadError(t *testing.T) {
tmp := t.TempDir()
cfgPath := filepath.Join(tmp, Filename)
if err := os.WriteFile(cfgPath, []byte("ignore: []"), 0o000); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { os.Chmod(cfgPath, 0o644) })
_, err := Load(tmp)
if err == nil {
t.Error("expected error for unreadable config file")
}
}
func TestLoad_InvalidYAML(t *testing.T) {
tmp := t.TempDir()
if err := os.WriteFile(filepath.Join(tmp, Filename), []byte("ignore: [unclosed\n"), 0o644); err != nil {
+2 -2
View File
@@ -26,10 +26,10 @@ func cacheWrite(dir, key string, data []byte) {
if dir == "" {
return
}
if err := os.MkdirAll(dir, 0o755); err != nil {
if err := os.MkdirAll(dir, 0o700); err != nil {
return
}
_ = os.WriteFile(cachePath(dir, key), data, 0o644)
_ = os.WriteFile(cachePath(dir, key), data, 0o600)
}
// cachePath returns the filesystem path for a cache entry.
+98
View File
@@ -0,0 +1,98 @@
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)
}
}
func TestCacheWrite_FilePermissions(t *testing.T) {
dir := t.TempDir()
cacheWrite(dir, "seckey", []byte("secret"))
info, err := os.Stat(cachePath(dir, "seckey"))
if err != nil {
t.Fatalf("stat cache file: %v", err)
}
if mode := info.Mode().Perm(); mode != 0o600 {
t.Errorf("cache file mode = %04o; want 0600", mode)
}
}
func TestCacheWrite_DirPermissions(t *testing.T) {
parent := t.TempDir()
dir := filepath.Join(parent, "glint-cache")
cacheWrite(dir, "k", []byte("v"))
info, err := os.Stat(dir)
if err != nil {
t.Fatalf("stat cache dir: %v", err)
}
if mode := info.Mode().Perm(); mode != 0o700 {
t.Errorf("cache dir mode = %04o; want 0700", mode)
}
}
+51 -4
View File
@@ -8,8 +8,18 @@ import (
"net/url"
"os"
"strings"
"time"
)
// httpClient is the shared HTTP client for all fetcher requests.
// Timeout guards against hung remote servers. Transport is intentionally nil
// so http.DefaultTransport is used dynamically — allowing tests to swap it.
var httpClient = &http.Client{Timeout: 30 * time.Second}
// maxResponseBytes is the per-response size cap. Responses larger than this
// are rejected to prevent memory exhaustion from pathological servers.
const maxResponseBytes int64 = 10 << 20 // 10 MiB
// TokenSource describes where a token was found, which determines the correct
// authentication header to use with the GitLab API.
type TokenSource int
@@ -27,6 +37,10 @@ type GitLabConfig struct {
Source TokenSource
CacheDir string // local cache directory; empty = caching disabled
Offline bool // when true, return an error instead of making network calls
// ProxyURL overrides the HTTP proxy for all requests made with this config.
// Empty string means use system proxy settings (HTTP_PROXY / HTTPS_PROXY /
// NO_PROXY env vars honoured automatically by http.DefaultTransport).
ProxyURL string
}
// AutoConfig builds a GitLabConfig from environment variables.
@@ -56,6 +70,33 @@ func AutoConfig() GitLabConfig {
return cfg
}
// WithProxy returns a copy of cfg with ProxyURL set.
func (cfg GitLabConfig) WithProxy(proxyURL string) GitLabConfig {
cfg.ProxyURL = proxyURL
return cfg
}
// client returns an HTTP client for this config.
// When ProxyURL is set it takes precedence over system proxy env vars;
// otherwise http.DefaultTransport's built-in ProxyFromEnvironment is used.
func (cfg GitLabConfig) client() *http.Client {
if cfg.ProxyURL == "" {
return httpClient
}
proxyURL, err := url.Parse(cfg.ProxyURL)
if err != nil {
return httpClient
}
// Clone the default transport so all TLS/dial settings are preserved.
t, ok := http.DefaultTransport.(*http.Transport)
if !ok {
return httpClient
}
transport := t.Clone()
transport.Proxy = http.ProxyURL(proxyURL)
return &http.Client{Timeout: httpClient.Timeout, Transport: transport}
}
// WithOverrides returns a copy of cfg with the provided overrides applied.
// Non-empty strings overwrite the corresponding field; booleans are always
// applied (so offline: false explicitly clears offline mode).
@@ -128,16 +169,19 @@ func (cfg GitLabConfig) FetchFile(project, filePath, ref string) ([]byte, error)
}
}
resp, err := http.DefaultClient.Do(req)
resp, err := cfg.client().Do(req)
if err != nil {
return nil, fmt.Errorf("GET %s: %w", apiURL, err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes+1))
if err != nil {
return nil, fmt.Errorf("reading response body: %w", err)
}
if int64(len(body)) > maxResponseBytes {
return nil, fmt.Errorf("response body exceeds maximum size (%d MiB)", maxResponseBytes>>20)
}
if resp.StatusCode != http.StatusOK {
msg := strings.TrimSpace(string(body))
@@ -169,15 +213,18 @@ func (cfg GitLabConfig) FetchURL(rawURL string) ([]byte, error) {
return nil, fmt.Errorf("offline mode: %s is not in the local cache (run without --offline first to populate the cache)", rawURL)
}
resp, err := http.Get(rawURL) //nolint:noctx
resp, err := cfg.client().Get(rawURL)
if err != nil {
return nil, fmt.Errorf("GET %s: %w", rawURL, err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes+1))
if err != nil {
return nil, fmt.Errorf("reading body: %w", err)
}
if int64(len(body)) > maxResponseBytes {
return nil, fmt.Errorf("response body exceeds maximum size (%d MiB)", maxResponseBytes>>20)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("GET %s: status %d", rawURL, resp.StatusCode)
}
+385
View File
@@ -0,0 +1,385 @@
package fetcher
import (
"errors"
"io"
"net/http"
"net/http/httptest"
"os"
"strings"
"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") }
}
func TestFetchFile_ResponseTooLarge(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
// Write maxResponseBytes+1 bytes to exceed the cap.
chunk := make([]byte, 4096)
written := int64(0)
for written <= maxResponseBytes {
n, _ := w.Write(chunk)
written += int64(n)
}
}))
defer srv.Close()
cfg := GitLabConfig{BaseURL: srv.URL}
_, err := cfg.FetchFile("group/project", "/ci.yml", "main")
if err == nil {
t.Fatal("expected error for oversized response")
}
if !strings.Contains(err.Error(), "exceeds maximum size") {
t.Errorf("unexpected error: %v", err)
}
}
func TestFetchURL_ResponseTooLarge(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
chunk := make([]byte, 4096)
written := int64(0)
for written <= maxResponseBytes {
n, _ := w.Write(chunk)
written += int64(n)
}
}))
defer srv.Close()
cfg := GitLabConfig{}
_, err := cfg.FetchURL(srv.URL)
if err == nil {
t.Fatal("expected error for oversized response")
}
if !strings.Contains(err.Error(), "exceeds maximum size") {
t.Errorf("unexpected error: %v", err)
}
}
+622
View File
@@ -0,0 +1,622 @@
package graph
import (
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"git.k3nny.fr/glint/internal/fetcher"
"git.k3nny.fr/glint/internal/model"
)
// ── mermaidLabel ──────────────────────────────────────────────────────────────
func TestMermaidLabel(t *testing.T) {
cases := []struct{ in, want string }{
{"plain", "plain"},
{`has "quotes"`, "has 'quotes'"},
{"has#hash", "has&#35;hash"},
{`"quoted"#both`, "'quoted'&#35;both"},
}
for _, tc := range cases {
got := mermaidLabel(tc.in)
if got != tc.want {
t.Errorf("mermaidLabel(%q)=%q want %q", tc.in, got, tc.want)
}
}
}
// ── includeFileList ───────────────────────────────────────────────────────────
func TestIncludeFileList(t *testing.T) {
if got := includeFileList("file.yml"); len(got) != 1 || got[0] != "file.yml" {
t.Error("string form")
}
if got := includeFileList([]any{"a.yml", "b.yml"}); len(got) != 2 {
t.Error("slice form")
}
if got := includeFileList([]any{"ok.yml", 42}); len(got) != 1 {
t.Error("mixed slice drops non-strings")
}
if got := includeFileList(nil); got != nil {
t.Error("nil should return nil")
}
if got := includeFileList(123); got != nil {
t.Error("unknown type returns nil")
}
}
// ── parseComponentRef ─────────────────────────────────────────────────────────
func TestParseComponentRef(t *testing.T) {
cases := []struct {
ref string
wantHost string
wantProj string
wantComp string
wantVer string
wantErr bool
}{
{
ref: "gitlab.com/group/project/component@v1.0",
wantHost: "gitlab.com", wantProj: "group/project", wantComp: "component", wantVer: "v1.0",
},
{
ref: "gitlab.com/a/b/c/d@main",
wantHost: "gitlab.com", wantProj: "a/b/c", wantComp: "d", wantVer: "main",
},
{ref: "no-at-sign", wantErr: true},
{ref: "no-at-sign@", wantErr: true},
{ref: "host/comp@v1", wantErr: true}, // only 2 parts — need at least 3
}
for _, tc := range cases {
host, proj, comp, ver, err := parseComponentRef(tc.ref)
if tc.wantErr {
if err == nil { t.Errorf("parseComponentRef(%q): expected error", tc.ref) }
continue
}
if err != nil { t.Errorf("parseComponentRef(%q): %v", tc.ref, err) }
if host != tc.wantHost { t.Errorf("host: got %q want %q", host, tc.wantHost) }
if proj != tc.wantProj { t.Errorf("project: got %q want %q", proj, tc.wantProj) }
if comp != tc.wantComp { t.Errorf("component: got %q want %q", comp, tc.wantComp) }
if ver != tc.wantVer { t.Errorf("version: got %q want %q", ver, tc.wantVer) }
}
}
// ── jobNames ──────────────────────────────────────────────────────────────────
func TestJobNames_Empty(t *testing.T) {
p := &model.Pipeline{Jobs: map[string]model.Job{}}
if got := jobNames(p); got != nil {
t.Errorf("empty pipeline: expected nil, got %v", got)
}
}
// ── treeBuilder helpers ───────────────────────────────────────────────────────
func TestNextID(t *testing.T) {
b := &treeBuilder{visited: map[string]bool{}}
if b.nextID() != "inc1" { t.Error("first ID") }
if b.nextID() != "inc2" { t.Error("second ID") }
}
func TestParseEntry_String(t *testing.T) {
b := &treeBuilder{visited: map[string]bool{}, baseDir: "/tmp"}
nodes := b.parseEntry("some/local.yml")
if len(nodes) != 1 { t.Fatalf("expected 1 node, got %d", len(nodes)) }
if !strings.Contains(nodes[0].label, "local:") { t.Error("expected local: label") }
}
func TestParseEntry_Unknown(t *testing.T) {
b := &treeBuilder{visited: map[string]bool{}}
nodes := b.parseEntry(42) // unknown type
if len(nodes) != 0 { t.Error("expected no nodes for unknown type") }
}
func TestParseMap_Template(t *testing.T) {
b := &treeBuilder{visited: map[string]bool{}}
nodes := b.parseMap(map[string]any{"template": "Auto-DevOps"})
if len(nodes) != 1 { t.Fatalf("expected 1 node, got %d", len(nodes)) }
if !strings.Contains(nodes[0].label, "template:") { t.Error("expected template: label") }
}
func TestParseMap_Remote(t *testing.T) {
b := &treeBuilder{visited: map[string]bool{}, cfg: fetcher.GitLabConfig{}}
nodes := b.parseMap(map[string]any{"remote": "https://example.com/ci.yml"})
if len(nodes) != 1 { t.Fatalf("expected 1 node, got %d", len(nodes)) }
if !strings.Contains(nodes[0].label, "remote:") { t.Error("expected remote: label") }
}
func TestParseMap_Project_NoFiles(t *testing.T) {
b := &treeBuilder{visited: map[string]bool{}, cfg: fetcher.GitLabConfig{}}
nodes := b.parseMap(map[string]any{"project": "g/p"})
if len(nodes) != 1 { t.Fatalf("expected 1 node, got %d", len(nodes)) }
if !strings.Contains(nodes[0].label, "project:") { t.Error("expected project: label") }
}
func TestParseMap_Project_WithRef(t *testing.T) {
b := &treeBuilder{visited: map[string]bool{}, cfg: fetcher.GitLabConfig{}}
nodes := b.parseMap(map[string]any{
"project": "g/p",
"ref": "main",
"file": "templates/ci.yml",
})
if len(nodes) != 1 { t.Fatalf("expected 1 node, got %d", len(nodes)) }
}
func TestParseMap_Project_MultipleFiles(t *testing.T) {
b := &treeBuilder{visited: map[string]bool{}, cfg: fetcher.GitLabConfig{}}
nodes := b.parseMap(map[string]any{
"project": "g/p",
"file": []any{"a.yml", "b.yml"},
})
if len(nodes) != 2 { t.Fatalf("expected 2 nodes, got %d", len(nodes)) }
}
func TestParseMap_Component_WithDollar(t *testing.T) {
b := &treeBuilder{visited: map[string]bool{}}
// Component ref with $ — skipped (dynamic, can't resolve)
nodes := b.parseMap(map[string]any{"component": "${CI_SERVER_HOST}/group/project/comp@v1"})
if len(nodes) != 1 { t.Fatalf("expected 1 node, got %d", len(nodes)) }
if nodes[0].jobs != nil { t.Error("jobs should be nil for unresolvable component") }
}
func TestParseMap_Component_BadRef(t *testing.T) {
b := &treeBuilder{visited: map[string]bool{}, cfg: fetcher.GitLabConfig{}}
nodes := b.parseMap(map[string]any{"component": "no-at-sign"})
if len(nodes) != 1 { t.Fatalf("expected 1 node, got %d", len(nodes)) }
}
func TestParseMap_Unknown(t *testing.T) {
b := &treeBuilder{visited: map[string]bool{}}
nodes := b.parseMap(map[string]any{"unknown_key": "value"})
if len(nodes) != 0 { t.Error("expected no nodes for unknown map keys") }
}
// ── recurseLocal ──────────────────────────────────────────────────────────────
func TestRecurseLocal_FileNotFound(t *testing.T) {
b := &treeBuilder{visited: map[string]bool{}, baseDir: "/nonexistent"}
node := &treeNode{id: "n1"}
b.recurseLocal(node, "missing.yml")
if node.jobs != nil { t.Error("jobs should remain nil on missing file") }
}
func TestRecurseLocal_ValidFile(t *testing.T) {
dir := t.TempDir()
ciPath := filepath.Join(dir, "ci.yml")
if err := os.WriteFile(ciPath, []byte("job1:\n script: echo\n"), 0o644); err != nil {
t.Fatal(err)
}
b := &treeBuilder{visited: map[string]bool{}, baseDir: dir}
node := &treeNode{id: "n1"}
b.recurseLocal(node, "ci.yml")
if len(node.jobs) == 0 { t.Error("expected jobs to be populated") }
}
func TestRecurseLocal_VisitedPrevents_Loop(t *testing.T) {
dir := t.TempDir()
ciPath := filepath.Join(dir, "ci.yml")
if err := os.WriteFile(ciPath, []byte("job1:\n script: echo\n"), 0o644); err != nil {
t.Fatal(err)
}
b := &treeBuilder{
visited: map[string]bool{"local:" + ciPath: true},
baseDir: dir,
}
node := &treeNode{id: "n1"}
b.recurseLocal(node, "ci.yml")
if len(node.jobs) != 0 { t.Error("visited node should not be parsed again") }
}
func TestRecurseLocal_WithNestedIncludes(t *testing.T) {
dir := t.TempDir()
// parent.yml includes child.yml
childPath := filepath.Join(dir, "child.yml")
if err := os.WriteFile(childPath, []byte("child-job:\n script: echo\n"), 0o644); err != nil {
t.Fatal(err)
}
parentPath := filepath.Join(dir, "parent.yml")
parentContent := "include:\n - local: child.yml\nparent-job:\n script: echo\n"
if err := os.WriteFile(parentPath, []byte(parentContent), 0o644); err != nil {
t.Fatal(err)
}
b := &treeBuilder{visited: map[string]bool{}, baseDir: dir}
node := &treeNode{id: "n1"}
b.recurseLocal(node, "parent.yml")
if len(node.children) == 0 { t.Error("expected children from nested include") }
}
func TestRecurseLocal_InvalidYAML(t *testing.T) {
dir := t.TempDir()
ciPath := filepath.Join(dir, "bad.yml")
if err := os.WriteFile(ciPath, []byte(":\tbad\tyaml\n"), 0o644); err != nil {
t.Fatal(err)
}
b := &treeBuilder{visited: map[string]bool{}, baseDir: dir}
node := &treeNode{id: "n1"}
b.recurseLocal(node, "bad.yml")
if node.jobs != nil { t.Error("invalid YAML should not set jobs") }
}
// ── directJobs ────────────────────────────────────────────────────────────────
func TestDirectJobs_MissingFile(t *testing.T) {
if jobs := directJobs("/nonexistent/ci.yml"); jobs != nil {
t.Error("expected nil for missing file")
}
}
func TestDirectJobs_ValidFile(t *testing.T) {
dir := t.TempDir()
ciPath := filepath.Join(dir, "ci.yml")
if err := os.WriteFile(ciPath, []byte("build:\n script: make\n"), 0o644); err != nil {
t.Fatal(err)
}
jobs := directJobs(ciPath)
if len(jobs) == 0 { t.Error("expected jobs from valid file") }
}
// ── renderTree ────────────────────────────────────────────────────────────────
func TestRenderTree_Basic(t *testing.T) {
root := &treeNode{
id: "root",
label: "main.yml",
class: "main",
jobs: []string{"job-a", "job-b"},
children: []*treeNode{
{id: "inc1", label: "local: child.yml", class: "local"},
},
}
out := renderTree(root)
if !strings.Contains(out, "main.yml") { t.Error("expected root label") }
if !strings.Contains(out, "job-a") { t.Error("expected job-a") }
if !strings.Contains(out, "child.yml") { t.Error("expected child label") }
if !strings.Contains(out, "root --> inc1") { t.Error("expected edge root→inc1") }
}
// ── Includes (integration) ────────────────────────────────────────────────────
func TestIncludes_LocalFile(t *testing.T) {
dir := t.TempDir()
childPath := filepath.Join(dir, "child.yml")
if err := os.WriteFile(childPath, []byte("child-job:\n script: echo\n"), 0o644); err != nil {
t.Fatal(err)
}
sourcePath := filepath.Join(dir, "pipeline.yml")
rawIncludes := []any{map[string]any{"local": "child.yml"}}
out := Includes(sourcePath, rawIncludes, fetcher.GitLabConfig{})
if !strings.Contains(out, "local:") { t.Error("expected local: node") }
}
func TestIncludes_NoIncludes(t *testing.T) {
dir := t.TempDir()
sourcePath := filepath.Join(dir, "pipeline.yml")
out := Includes(sourcePath, nil, fetcher.GitLabConfig{})
if !strings.Contains(out, "flowchart") { t.Error("expected flowchart") }
}
// ── fetchComponentFile (graph package) ────────────────────────────────────────
func TestGraphFetchComponentFile_PrimarySuccess(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
fmt.Fprintln(w, "comp-job:\n script: echo")
}))
defer srv.Close()
cfg := fetcher.GitLabConfig{BaseURL: srv.URL}
data, err := fetchComponentFile(cfg, "g/p", "mycomp", "v1")
if err != nil {
t.Fatalf("primary success: unexpected error: %v", err)
}
if len(data) == 0 {
t.Error("expected non-empty data")
}
}
// ── recurseRemote ─────────────────────────────────────────────────────────────
func TestRecurseRemote_AlreadyVisited(t *testing.T) {
b := &treeBuilder{
visited: map[string]bool{"remote:http://example.com/ci.yml": true},
cfg: fetcher.GitLabConfig{},
baseDir: "/tmp",
}
node := &treeNode{id: "n1"}
b.recurseRemote(node, "http://example.com/ci.yml")
// Visited: nothing should be fetched or added.
}
func TestRecurseRemote_ValidWithJobs(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
fmt.Fprintln(w, "remote-job:\n script: echo")
}))
defer srv.Close()
b := &treeBuilder{
visited: map[string]bool{},
cfg: fetcher.GitLabConfig{},
baseDir: "/tmp",
}
node := &treeNode{id: "n1"}
b.recurseRemote(node, srv.URL+"/ci.yml")
if len(node.jobs) == 0 {
t.Error("expected job names from remote YAML")
}
}
func TestRecurseRemote_InvalidYAML(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
fmt.Fprintln(w, ":\tbad\tyaml")
}))
defer srv.Close()
b := &treeBuilder{
visited: map[string]bool{},
cfg: fetcher.GitLabConfig{},
baseDir: "/tmp",
}
node := &treeNode{id: "n1"}
b.recurseRemote(node, srv.URL+"/bad.yml")
// ParseBytes fails → early return; no panic.
}
func TestRecurseRemote_WithSubIncludes(t *testing.T) {
// The remote YAML includes a second URL (sub-includes path).
var callCount int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
callCount++
w.WriteHeader(200)
if callCount == 1 {
// First call: returns YAML with a remote sub-include.
fmt.Fprintln(w, "include:\n - remote: http://127.0.0.1:0/sub.yml\nparent-job:\n script: echo")
} else {
// Sub-include fetch will fail (connection refused) — that's OK; we
// just need the sub-include routing path to be executed.
w.WriteHeader(500)
}
}))
defer srv.Close()
b := &treeBuilder{
visited: map[string]bool{},
cfg: fetcher.GitLabConfig{},
baseDir: "/tmp",
counter: 0,
}
node := &treeNode{id: "n1"}
b.recurseRemote(node, srv.URL+"/ci.yml")
// parent-job should be in node.jobs even if sub-include fails.
if len(node.jobs) == 0 {
t.Error("expected job names from remote YAML")
}
}
// ── recurseComponent ─────────────────────────────────────────────────────────
// tlsHost returns a helper that creates a TLS test server, swaps http.DefaultTransport
// so the test client trusts the self-signed cert, and returns (close, host).
func tlsComponent(t *testing.T, handler http.HandlerFunc) (host string) {
t.Helper()
srv := httptest.NewTLSServer(handler)
t.Cleanup(srv.Close)
orig := http.DefaultTransport
http.DefaultTransport = srv.Client().Transport
t.Cleanup(func() { http.DefaultTransport = orig })
return srv.URL[len("https://"):]
}
func TestRecurseComponent_AlreadyVisited(t *testing.T) {
b := &treeBuilder{
visited: map[string]bool{"component:gitlab.com/g/p/comp@v1": true},
cfg: fetcher.GitLabConfig{},
baseDir: "/tmp",
}
node := &treeNode{id: "n1"}
b.recurseComponent(node, "gitlab.com/g/p/comp@v1")
// Already visited: nothing happens.
}
func TestRecurseComponent_DollarRef(t *testing.T) {
// ref containing $ → early return (line 196-197)
b := &treeBuilder{visited: map[string]bool{}, cfg: fetcher.GitLabConfig{}}
node := &treeNode{id: "n1"}
b.recurseComponent(node, "$CI_SERVER/g/p/comp@v1")
// no panic, node unchanged
}
func TestRecurseComponent_BadRef(t *testing.T) {
// ref with no @ → parseComponentRef fails (line 206-208)
b := &treeBuilder{visited: map[string]bool{}, cfg: fetcher.GitLabConfig{}}
node := &treeNode{id: "n1"}
b.recurseComponent(node, "gitlab.com/g/p/mycomp-no-version")
// no panic
}
func TestRecurseComponent_FetchError(t *testing.T) {
host := tlsComponent(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(404)
})
b := &treeBuilder{
visited: map[string]bool{},
cfg: fetcher.GitLabConfig{},
baseDir: "/tmp",
}
node := &treeNode{id: "n1"}
b.recurseComponent(node, host+"/g/p/mycomp@v1")
// 404 → fetch error → early return; no panic.
}
func TestRecurseComponent_InvalidYAML(t *testing.T) {
host := tlsComponent(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
fmt.Fprintln(w, ":\tbad\tyaml")
})
b := &treeBuilder{
visited: map[string]bool{},
cfg: fetcher.GitLabConfig{},
baseDir: "/tmp",
}
node := &treeNode{id: "n1"}
b.recurseComponent(node, host+"/g/p/mycomp@v1")
// fetch succeeds but ParseBytes fails → early return; no panic.
}
func TestRecurseComponent_ValidYAML(t *testing.T) {
host := tlsComponent(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
fmt.Fprintln(w, "comp-job:\n script: echo component")
})
b := &treeBuilder{
visited: map[string]bool{},
cfg: fetcher.GitLabConfig{},
baseDir: "/tmp",
}
node := &treeNode{id: "n1"}
b.recurseComponent(node, host+"/g/p/mycomp@v1")
if len(node.jobs) == 0 {
t.Error("expected job names from component YAML")
}
}
// ── recurseProject ────────────────────────────────────────────────────────────
func TestRecurseProject_AlreadyVisited(t *testing.T) {
b := &treeBuilder{
visited: map[string]bool{"project:g/p:ci.yml@main": true},
cfg: fetcher.GitLabConfig{Token: "tok"},
baseDir: "/tmp",
}
node := &treeNode{id: "n1"}
b.recurseProject(node, "g/p", "ci.yml", "main")
// Already visited: nothing happens.
}
func TestRecurseProject_WithToken(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
fmt.Fprintln(w, "proj-job:\n script: echo project")
}))
defer srv.Close()
b := &treeBuilder{
visited: map[string]bool{},
cfg: fetcher.GitLabConfig{BaseURL: srv.URL, Token: "tok"},
baseDir: "/tmp",
}
node := &treeNode{id: "n1"}
b.recurseProject(node, "g/p", "ci.yml", "main")
if len(node.jobs) == 0 {
t.Error("expected job names from project YAML")
}
}
func TestRecurseProject_FetchError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(404)
}))
defer srv.Close()
b := &treeBuilder{
visited: map[string]bool{},
cfg: fetcher.GitLabConfig{BaseURL: srv.URL, Token: "tok"},
baseDir: "/tmp",
}
node := &treeNode{id: "n1"}
b.recurseProject(node, "g/p", "ci.yml", "main")
// fetch fails → early return; no panic.
}
func TestRecurseProject_InvalidYAML(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
fmt.Fprintln(w, ":\tbad\tyaml")
}))
defer srv.Close()
b := &treeBuilder{
visited: map[string]bool{},
cfg: fetcher.GitLabConfig{BaseURL: srv.URL, Token: "tok"},
baseDir: "/tmp",
}
node := &treeNode{id: "n1"}
b.recurseProject(node, "g/p", "ci.yml", "main")
// ParseBytes fails → early return; no panic.
}
func TestRecurseProject_WithSubIncludes(t *testing.T) {
// Project YAML includes sub-includes → covers recurseProject line 170 (buildChildren).
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
fmt.Fprintln(w, "include:\n - remote: http://127.0.0.1:0/sub.yml\nproj-job:\n script: echo")
}))
defer srv.Close()
b := &treeBuilder{
visited: map[string]bool{},
cfg: fetcher.GitLabConfig{BaseURL: srv.URL, Token: "tok"},
baseDir: "/tmp",
}
node := &treeNode{id: "n1"}
b.recurseProject(node, "g/p", "ci.yml", "main")
// proj-job should be populated; sub-include fetch fails (port 0) — that's fine.
if len(node.jobs) == 0 {
t.Error("expected job names from project YAML")
}
}
func TestRecurseComponent_WithSubIncludes(t *testing.T) {
// Component YAML includes sub-includes → covers recurseComponent line 221 (buildChildren).
host := tlsComponent(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
fmt.Fprintln(w, "include:\n - remote: http://127.0.0.1:0/sub.yml\ncomp-job:\n script: echo")
})
b := &treeBuilder{
visited: map[string]bool{},
cfg: fetcher.GitLabConfig{},
baseDir: "/tmp",
}
node := &treeNode{id: "n1"}
b.recurseComponent(node, host+"/g/p/mycomp@v1")
if len(node.jobs) == 0 {
t.Error("expected job names from component YAML")
}
}
// ── fetchComponentFile (fallback path) ───────────────────────────────────────
func TestGraphFetchComponentFile_FallbackSuccess(t *testing.T) {
// Primary path (templates/comp.yml) returns 404; fallback (templates/comp/template.yml) returns 200.
var reqCount int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
reqCount++
if reqCount == 1 {
w.WriteHeader(404)
} else {
w.WriteHeader(200)
fmt.Fprintln(w, "fallback-job:\n script: echo")
}
}))
defer srv.Close()
cfg := fetcher.GitLabConfig{BaseURL: srv.URL}
data, err := fetchComponentFile(cfg, "g/p", "mycomp", "v1")
if err != nil {
t.Fatalf("fallback success: unexpected error: %v", err)
}
if len(data) == 0 {
t.Error("expected non-empty data from fallback path")
}
}
// Ensure model import is used.
var _ = model.Job{}
+3 -3
View File
@@ -27,6 +27,7 @@ func Pipeline(p *model.Pipeline) string {
w(" classDef manual fill:#fc6d26,stroke:#e56b1f,color:#fff")
w(" classDef trigger fill:#6b4fbb,stroke:#5a3fa0,color:#fff")
w(" classDef delayed fill:#fca326,stroke:#d98a1e,color:#333")
w(" classDef on_failure fill:#d9534f,stroke:#c0392b,color:#fff")
w("")
// Collect visible (non-template) job names; sort for stable output.
@@ -82,9 +83,6 @@ func Pipeline(p *model.Pipeline) string {
// Emit one subgraph per stage.
for _, stage := range stages {
jobs := byStage[stage]
if len(jobs) == 0 {
continue
}
sort.Strings(jobs)
wf(" subgraph %s[\"%s\"]", stageID(stage), stage)
for _, name := range jobs {
@@ -152,6 +150,8 @@ func jobClass(job model.Job) string {
return "manual"
case "delayed":
return "delayed"
case "on_failure":
return "on_failure"
}
return "regular"
}
+133
View File
@@ -0,0 +1,133 @@
package graph
import (
"strings"
"testing"
"git.k3nny.fr/glint/internal/model"
)
func TestPipeline_EmptyJobs(t *testing.T) {
p := &model.Pipeline{}
out := Pipeline(p)
if !strings.Contains(out, "no jobs defined") {
t.Errorf("expected 'no jobs defined', got:\n%s", out)
}
}
func TestPipeline_BasicTwoStages(t *testing.T) {
p := &model.Pipeline{
Stages: []string{"build", "test"},
Jobs: map[string]model.Job{
"build-job": {Name: "build-job", Stage: "build"},
"test-job": {Name: "test-job", Stage: "test"},
".tmpl": {Name: ".tmpl", Stage: "build"}, // hidden — excluded
},
}
out := Pipeline(p)
if !strings.Contains(out, "build-job") { t.Error("expected build-job") }
if !strings.Contains(out, "test-job") { t.Error("expected test-job") }
if strings.Contains(out, ".tmpl") { t.Error(".tmpl should be excluded") }
// Classic mode: stage → stage connector
if !strings.Contains(out, "Classic") { t.Error("expected classic mode comment") }
}
func TestPipeline_DAGMode(t *testing.T) {
p := &model.Pipeline{
Stages: []string{"build", "test"},
Jobs: map[string]model.Job{
"build-job": {Name: "build-job", Stage: "build"},
"test-job": {Name: "test-job", Stage: "test", Needs: []any{"build-job"}},
},
}
out := Pipeline(p)
if !strings.Contains(out, "DAG") { t.Error("expected DAG mode comment") }
}
func TestPipeline_DAGMode_CrossPipelineNeed(t *testing.T) {
p := &model.Pipeline{
Stages: []string{"build"},
Jobs: map[string]model.Job{
"build-job": {Name: "build-job", Stage: "build",
Needs: []any{map[string]any{"pipeline": "other", "job": "j"}}},
},
}
out := Pipeline(p)
// cross-pipeline need should not add an arrow (job not in local map)
if !strings.Contains(out, "DAG") { t.Error("expected DAG mode comment") }
}
func TestPipeline_JobNoStage(t *testing.T) {
p := &model.Pipeline{
Jobs: map[string]model.Job{
"myjob": {Name: "myjob"},
},
}
out := Pipeline(p)
if !strings.Contains(out, "myjob") { t.Error("expected myjob") }
}
func TestPipeline_SingleStage(t *testing.T) {
// Single stage → no connector
p := &model.Pipeline{
Stages: []string{"build"},
Jobs: map[string]model.Job{
"a": {Name: "a", Stage: "build"},
},
}
out := Pipeline(p)
if strings.Contains(out, "-->") { t.Error("single stage should not have connectors") }
}
// ── stageID / sanitizeID ──────────────────────────────────────────────────────
func TestStageID(t *testing.T) {
if stageID("build") != "stage_build" { t.Error("plain name") }
if stageID("my-stage") != "stage_my_stage" { t.Error("hyphens replaced") }
}
func TestSanitizeID(t *testing.T) {
cases := []struct{ in, want string }{
{"build", "build"},
{"my-stage", "my_stage"},
{"with space", "with_space"},
{"ABC_123", "ABC_123"},
}
for _, tc := range cases {
if sanitizeID(tc.in) != tc.want {
t.Errorf("sanitizeID(%q)=%q want %q", tc.in, sanitizeID(tc.in), tc.want)
}
}
}
// ── jobClass ──────────────────────────────────────────────────────────────────
func TestJobClass(t *testing.T) {
if jobClass(model.Job{When: "manual"}) != "manual" { t.Error("manual") }
if jobClass(model.Job{When: "delayed"}) != "delayed" { t.Error("delayed") }
if jobClass(model.Job{When: "on_failure"}) != "on_failure" { t.Error("on_failure") }
if jobClass(model.Job{Trigger: "x"}) != "trigger" { t.Error("trigger") }
if jobClass(model.Job{}) != "regular" { t.Error("regular") }
}
func TestPipeline_OnFailureClass(t *testing.T) {
p := &model.Pipeline{
Stages: []string{"cleanup"},
Jobs: map[string]model.Job{
"cleanup-job": {Name: "cleanup-job", Stage: "cleanup", When: "on_failure"},
},
}
out := Pipeline(p)
if !strings.Contains(out, "on_failure") { t.Error("expected on_failure class") }
if !strings.Contains(out, "#d9534f") { t.Error("expected on_failure color in classDef") }
}
// ── needsJobName ──────────────────────────────────────────────────────────────
func TestNeedsJobName(t *testing.T) {
if needsJobName("job-a") != "job-a" { t.Error("string form") }
if needsJobName(map[string]any{"job": "job-b"}) != "job-b" { t.Error("map form") }
if needsJobName(map[string]any{"pipeline": "other", "job": "j"}) != "" { t.Error("cross-pipeline should return empty") }
if needsJobName(map[string]any{"no-job": true}) != "" { t.Error("map without job key") }
if needsJobName(42) != "" { t.Error("unexpected type") }
}
+524 -107
View File
@@ -10,37 +10,138 @@ import (
"strings"
"time"
"git.k3nny.fr/glint/internal/cicontext"
"git.k3nny.fr/glint/internal/model"
)
// Layout constants (pixels) tuned to resemble GitLab's full pipeline graph view.
const (
chipW = 178 // job chip width
chipH = 34 // job chip height
chipGap = 6 // vertical gap between chips in a column
iconCX = 14 // status circle: x offset from chip left edge to circle centre
iconR = 7 // status circle radius (14 px diameter)
textLeft = 27 // job name text: x offset from chip left edge
labelH = 30 // stage-name label area height (text + bottom gap)
topPad = 40 // outer top padding
sidePad = 40 // outer left / right padding
stageGap = 50 // horizontal gap between stage columns (connector space)
botPad = 48 // outer bottom padding (legend lives here)
chipW = 178 // job chip width
chipH = 34 // job chip height
chipGap = 6 // vertical gap between chips in a column
iconCX = 14 // status circle: x offset from chip left edge to centre
iconR = 7 // status circle radius (14 px diameter)
textLeft = 27 // job name text: x offset from chip left edge
labelH = 30 // stage-name label area height (text + bottom gap)
topPad = 40 // outer top padding
sidePad = 40 // outer left / right padding
stageGap = 50 // horizontal gap between stage columns (connector space)
subStageGap = 20 // horizontal gap between sub-columns within the same stage
botPad = 48 // outer bottom padding (legend lives here)
)
type svgPt struct{ x, y int }
// column represents one vertical stack of job chips in the SVG.
// A declared GitLab stage maps to one column unless jobs within it have
// same-stage needs:, in which case it splits into topological sub-columns.
type column struct {
stage string // declared GitLab stage name
jobs []string // job names in this column, topologically ordered
}
// computeColumns assigns jobs to SVG columns.
// When jobs within the same declared stage have needs: relationships between
// each other, the stage is split into topological sub-columns so that
// depended-upon jobs appear to the left of the jobs that depend on them.
func computeColumns(stages []string, byStage map[string][]string, jobs map[string]model.Job) []column {
var cols []column
for _, stage := range stages {
stageJobs := byStage[stage]
sort.Strings(stageJobs)
if len(stageJobs) == 0 {
continue
}
// Index same-stage membership.
stageSet := make(map[string]bool, len(stageJobs))
for _, j := range stageJobs {
stageSet[j] = true
}
// Check whether any job has a same-stage need.
hasIntraNeeds := false
outer:
for _, j := range stageJobs {
for _, need := range jobs[j].Needs {
if dep := needsJobName(need); dep != "" && stageSet[dep] {
hasIntraNeeds = true
break outer
}
}
}
if !hasIntraNeeds {
cols = append(cols, column{stage: stage, jobs: stageJobs})
continue
}
// Compute topological depth within the stage.
depth := make(map[string]int, len(stageJobs))
for _, j := range stageJobs {
depth[j] = -1
}
inProgress := make(map[string]bool, len(stageJobs))
var computeDepth func(j string) int
computeDepth = func(j string) int {
if depth[j] >= 0 {
return depth[j]
}
if inProgress[j] {
return 0 // cycle guard (cycles already rejected by GL029)
}
inProgress[j] = true
maxDep := -1
for _, need := range jobs[j].Needs {
dep := needsJobName(need)
if dep == "" || !stageSet[dep] {
continue
}
d := computeDepth(dep)
if d > maxDep {
maxDep = d
}
}
depth[j] = maxDep + 1
return depth[j]
}
for _, j := range stageJobs {
if depth[j] < 0 {
computeDepth(j)
}
}
// One sub-column per topological depth level.
maxDepth := 0
for _, j := range stageJobs {
if depth[j] > maxDepth {
maxDepth = depth[j]
}
}
for l := 0; l <= maxDepth; l++ {
var levelJobs []string
for _, j := range stageJobs {
if depth[j] == l {
levelJobs = append(levelJobs, j)
}
}
if len(levelJobs) > 0 {
sort.Strings(levelJobs)
cols = append(cols, column{stage: stage, jobs: levelJobs})
}
}
}
return cols
}
// RenderPipeline writes a PNG (or SVG fallback) file with a GitLab CI-style
// pipeline layout and returns the path to the generated file.
func RenderPipeline(p *model.Pipeline, outDir string) (string, error) {
func RenderPipeline(p *model.Pipeline, outDir string, ctx *cicontext.Context) (string, error) {
if err := os.MkdirAll(outDir, 0o755); err != nil {
return "", fmt.Errorf("creating output directory %s: %w", outDir, err)
}
svg, err := pipelineSVG(p)
if err != nil {
return "", err
}
svg := pipelineSVG(p, ctx)
ts := time.Now().Format("20060102-150405")
svgPath := filepath.Join(outDir, "pipeline-"+ts+".svg")
@@ -56,23 +157,39 @@ func RenderPipeline(p *model.Pipeline, outDir string) (string, error) {
return svgPath, nil
}
// RenderHTML writes a self-contained HTML file with the pipeline graph embedded
// inline with pan/zoom and a job-detail sidebar, then returns the output path.
func RenderHTML(p *model.Pipeline, outDir string, ctx *cicontext.Context) (string, error) {
if err := os.MkdirAll(outDir, 0o755); err != nil {
return "", fmt.Errorf("creating output directory %s: %w", outDir, err)
}
svg := pipelineSVG(p, ctx)
html := htmlPage(svg)
ts := time.Now().Format("20060102-150405")
htmlPath := filepath.Join(outDir, "pipeline-"+ts+".html")
if err := os.WriteFile(htmlPath, []byte(html), 0o644); err != nil {
return "", fmt.Errorf("writing HTML: %w", err)
}
return htmlPath, nil
}
// convertToPNG tries rsvg-convert, Inkscape, magick, and convert (not on Windows).
func convertToPNG(svgPath, pngPath string) bool {
type cand struct {
args []string
skipWin bool
candidates := [][]string{
{"rsvg-convert", "--output", pngPath, svgPath},
{"inkscape", "--export-filename=" + pngPath, svgPath},
{"magick", svgPath, pngPath},
}
for _, c := range []cand{
{[]string{"rsvg-convert", "--output", pngPath, svgPath}, false},
{[]string{"inkscape", "--export-filename=" + pngPath, svgPath}, false},
{[]string{"magick", svgPath, pngPath}, false},
{[]string{"convert", svgPath, pngPath}, true},
} {
if c.skipWin && runtime.GOOS == "windows" {
continue
}
if bin, err := exec.LookPath(c.args[0]); err == nil {
if exec.Command(bin, c.args[1:]...).Run() == nil {
// `convert` is the legacy ImageMagick name; skip on Windows where the name
// collides with the built-in FAT→NTFS converter.
if runtime.GOOS != "windows" {
candidates = append(candidates, []string{"convert", svgPath, pngPath})
}
for _, args := range candidates {
if bin, err := exec.LookPath(args[0]); err == nil {
if exec.Command(bin, args[1:]...).Run() == nil {
return true
}
}
@@ -80,7 +197,7 @@ func convertToPNG(svgPath, pngPath string) bool {
return false
}
func pipelineSVG(p *model.Pipeline) (string, error) {
func pipelineSVG(p *model.Pipeline, ctx *cicontext.Context) string {
// Collect visible (non-template) job names in sorted order.
var visible []string
for name := range p.Jobs {
@@ -91,7 +208,17 @@ func pipelineSVG(p *model.Pipeline) (string, error) {
sort.Strings(visible)
if len(visible) == 0 {
return svgEmpty(), nil
return svgEmpty()
}
// Precompute which jobs are skipped in the given context.
skippedJobs := make(map[string]bool)
if ctx != nil {
for _, name := range visible {
if cicontext.EvalJob(p.Jobs[name], ctx) == cicontext.JobSkipped {
skippedJobs[name] = true
}
}
}
// Group by stage; fall back to "test" (GitLab default) when stage is unset.
@@ -124,32 +251,42 @@ func pipelineSVG(p *model.Pipeline) (string, error) {
}
}
// Compute per-stage column heights and job anchor points for connectors.
colH := make([]int, len(stages)) // height of the chip stack (no label)
colCtY := make([]int, len(stages)) // y centre of the chip stack
// ── Column layout ─────────────────────────────────────────────────────────
// Split stages into sub-columns when intra-stage needs exist.
cols := computeColumns(stages, byStage, p.Jobs)
// X position of each column's left edge.
colX := make([]int, len(cols))
if len(cols) > 0 {
colX[0] = sidePad
for i := 1; i < len(cols); i++ {
gap := stageGap
if cols[i].stage == cols[i-1].stage {
gap = subStageGap
}
colX[i] = colX[i-1] + chipW + gap
}
}
// Compute SVG dimensions and per-job connector anchor points.
maxColH := 0
rightMid := make(map[string]svgPt)
leftMid := make(map[string]svgPt)
for i, stage := range stages {
jobs := byStage[stage]
sort.Strings(jobs)
n := len(jobs)
for ci, col := range cols {
n := len(col.jobs)
h := n*chipH + max(0, n-1)*chipGap
colH[i] = h
if h > maxColH {
maxColH = h
}
cx := sidePad + i*(chipW+stageGap)
for j, name := range jobs {
for j, name := range col.jobs {
chy := topPad + labelH + j*(chipH+chipGap)
rightMid[name] = svgPt{cx + chipW, chy + chipH/2}
leftMid[name] = svgPt{cx, chy + chipH/2}
rightMid[name] = svgPt{colX[ci] + chipW, chy + chipH/2}
leftMid[name] = svgPt{colX[ci], chy + chipH/2}
}
colCtY[i] = topPad + labelH + h/2
}
svgW := sidePad*2 + len(stages)*chipW + max(0, len(stages)-1)*stageGap
svgW := colX[len(cols)-1] + chipW + sidePad
svgH := topPad + labelH + maxColH + botPad
// DAG mode: any visible job with a needs: list triggers job-to-job arrows.
@@ -179,49 +316,35 @@ func pipelineSVG(p *model.Pipeline) (string, error) {
// White page background.
wf(` <rect width="%d" height="%d" fill="#ffffff"/>`, svgW, svgH)
// ── Stage columns ─────────────────────────────────────────────────────────
for i, stage := range stages {
cx := sidePad + i*(chipW+stageGap)
jobs := byStage[stage]
sort.Strings(jobs)
// ── Stage headers ─────────────────────────────────────────────────────────
// Each declared stage may span multiple sub-columns; draw one header per stage.
drawnHeader := make(map[string]bool)
for ci, col := range cols {
if drawnHeader[col.stage] {
continue
}
// Span all sub-columns that belong to this stage.
x1 := colX[ci]
x2 := colX[ci] + chipW
for j := ci + 1; j < len(cols) && cols[j].stage == col.stage; j++ {
x2 = colX[j] + chipW
}
centerX := (x1 + x2) / 2
// Stage name small, gray, uppercase, centered above the chip stack.
// Stage name small, gray, uppercase.
wf(` <text x="%d" y="%d" text-anchor="middle" `+
`font-family="'GitLab Sans','Segoe UI',-apple-system,BlinkMacSystemFont,sans-serif" `+
`font-size="11" font-weight="600" letter-spacing="0.8" fill="#868686">%s</text>`,
cx+chipW/2, topPad+13, svgEsc(strings.ToUpper(stage)))
centerX, topPad+13, svgEsc(strings.ToUpper(col.stage)))
// Subtle separator line below stage name.
// Separator line spanning all sub-columns.
wf(` <line x1="%d" y1="%d" x2="%d" y2="%d" stroke="#eaeaea" stroke-width="1"/>`,
cx, topPad+21, cx+chipW, topPad+21)
x1, topPad+21, x2, topPad+21)
// Job chips.
for j, name := range jobs {
job := p.Jobs[name]
chy := topPad + labelH + j*(chipH+chipGap)
color := chipColor(job)
// Chip card (white, rounded, subtle border + shadow).
wf(` <rect x="%d" y="%d" width="%d" height="%d" rx="4" `+
`fill="#ffffff" stroke="#dde1e7" stroke-width="1" filter="url(#chip-shadow)"/>`,
cx, chy, chipW, chipH)
// Colored status indicator circle.
wf(` <circle cx="%d" cy="%d" r="%d" fill="%s"/>`,
cx+iconCX, chy+chipH/2, iconR, color)
// Icon symbol inside the circle.
drawChipIcon(&sb, job, cx+iconCX, chy+chipH/2)
// Job name text.
wf(` <text x="%d" y="%d" dominant-baseline="middle" `+
`font-family="'GitLab Sans','Segoe UI',-apple-system,BlinkMacSystemFont,sans-serif" `+
`font-size="13" fill="#303030">%s</text>`,
cx+textLeft, chy+chipH/2, svgEsc(svgTrunc(name, 20)))
}
drawnHeader[col.stage] = true
}
// ── Connectors ────────────────────────────────────────────────────────────
// ── Connectors (drawn before chips so they appear behind job blocks) ───────
const connStroke = "#dbdbdb"
if dagMode {
@@ -246,27 +369,123 @@ func pipelineSVG(p *model.Pipeline) (string, error) {
}
}
} else {
// Classic: one connector per adjacent stage pair.
for i := 0; i < len(stages)-1; i++ {
x1 := sidePad + i*(chipW+stageGap) + chipW
x2 := sidePad + (i+1)*(chipW+stageGap)
y1 := colCtY[i]
y2 := colCtY[i+1]
// Classic: bus-bar connectors between adjacent stage columns.
// Every job in stage[i] fans to a vertical bus at the midpoint gap,
// then fans out to every job in stage[i+1].
for ci := 0; ci < len(cols)-1; ci++ {
x1 := colX[ci] + chipW // right edge of current column
x2 := colX[ci+1] // left edge of next column
midX := (x1 + x2) / 2
if y1 == y2 {
// Straight horizontal line.
wf(` <line x1="%d" y1="%d" x2="%d" y2="%d" stroke="%s" stroke-width="2"/>`,
x1, y1, x2-7, y2, connStroke)
} else {
// L-shaped elbow: right → vertical → right.
wf(` <polyline points="%d,%d %d,%d %d,%d %d,%d" `+
`stroke="%s" stroke-width="2" fill="none" stroke-linejoin="round"/>`,
x1, y1, midX, y1, midX, y2, x2-7, y2, connStroke)
srcJobs := cols[ci].jobs
dstJobs := cols[ci+1].jobs
// Collect all Y midpoints to span the vertical bus bar.
var allYs []int
srcY := make([]int, len(srcJobs))
dstY := make([]int, len(dstJobs))
for j, name := range srcJobs {
srcY[j] = rightMid[name].y
allYs = append(allYs, srcY[j])
}
// Arrowhead at the destination.
wf(` <polygon points="%d,%d %d,%d %d,%d" fill="%s"/>`,
x2-7, y2-4, x2, y2, x2-7, y2+4, connStroke)
for j, name := range dstJobs {
dstY[j] = leftMid[name].y
allYs = append(allYs, dstY[j])
}
sort.Ints(allYs)
busMinY, busMaxY := allYs[0], allYs[len(allYs)-1]
// Vertical bus bar at midX (only when there are multiple Y levels).
if busMinY < busMaxY {
wf(` <line x1="%d" y1="%d" x2="%d" y2="%d" stroke="%s" stroke-width="2"/>`,
midX, busMinY, midX, busMaxY, connStroke)
}
// Horizontal stubs from each source job to the bus.
for _, y := range srcY {
wf(` <line x1="%d" y1="%d" x2="%d" y2="%d" stroke="%s" stroke-width="2"/>`,
x1, y, midX, y, connStroke)
}
// Horizontal stubs from bus to each destination job, with arrowhead.
for _, y := range dstY {
wf(` <line x1="%d" y1="%d" x2="%d" y2="%d" stroke="%s" stroke-width="2"/>`,
midX, y, x2-7, y, connStroke)
wf(` <polygon points="%d,%d %d,%d %d,%d" fill="%s"/>`,
x2-7, y-4, x2, y, x2-7, y+4, connStroke)
}
}
}
// ── Job chips (drawn after connectors so they appear on top) ─────────────
for ci, col := range cols {
for j, name := range col.jobs {
job := p.Jobs[name]
chy := topPad + labelH + j*(chipH+chipGap)
isSkipped := skippedJobs[name]
cx := colX[ci]
// Build <desc> content: shown in the HTML sidebar and SVG viewer tooltips.
desc := "stage: " + col.stage
if job.When != "" {
desc += "\nwhen: " + job.When
}
if img := imageString(job.Image); img != "" {
desc += "\nimage: " + img
}
var needNames []string
for _, n := range job.Needs {
if s := needsJobName(n); s != "" {
needNames = append(needNames, s)
}
}
if len(needNames) > 0 {
desc += "\nneeds: " + strings.Join(needNames, ", ")
}
if isSkipped {
desc += "\nstate: skipped"
}
// data-job attribute enables JS click detection in the HTML output.
wf(` <g data-job="%s">`, svgEsc(name))
wf(` <title>%s</title>`, svgEsc(name))
wf(` <desc>%s</desc>`, svgEsc(desc))
color := chipColor(job)
if isSkipped {
color = "#868686"
}
// Chip card (white, rounded, subtle border + shadow).
// on_failure jobs get a dashed border to signal the failure path.
dashAttr := ""
if !isSkipped && job.When == "on_failure" {
dashAttr = ` stroke-dasharray="4,3"`
}
wf(` <rect x="%d" y="%d" width="%d" height="%d" rx="4" `+
`fill="#ffffff" stroke="#dde1e7" stroke-width="1"%s filter="url(#chip-shadow)"/>`,
cx, chy, chipW, chipH, dashAttr)
// Colored status indicator circle.
wf(` <circle cx="%d" cy="%d" r="%d" fill="%s"/>`,
cx+iconCX, chy+chipH/2, iconR, color)
// Icon inside the circle (omitted for skipped — grey circle speaks for itself).
if !isSkipped {
drawChipIcon(&sb, job, cx+iconCX, chy+chipH/2)
}
// Job name text — dimmed for skipped jobs.
textColor := "#303030"
if isSkipped {
textColor = "#868686"
}
wf(` <text x="%d" y="%d" dominant-baseline="middle" `+
`font-family="'GitLab Sans','Segoe UI',-apple-system,BlinkMacSystemFont,sans-serif" `+
`font-size="13" fill="%s">%s</text>`,
cx+textLeft, chy+chipH/2, textColor, svgEsc(svgTrunc(name, 20)))
wf(` </g>`)
}
}
@@ -276,6 +495,7 @@ func pipelineSVG(p *model.Pipeline) (string, error) {
{"#fc6d26", "manual"},
{"#6b4fbb", "trigger"},
{"#fca326", "delayed"},
{"#d9534f", "on_failure"},
}
const legendItemW = 82
legendY := topPad + labelH + maxColH + botPad/2
@@ -290,7 +510,7 @@ func pipelineSVG(p *model.Pipeline) (string, error) {
}
w(`</svg>`)
return sb.String(), nil
return sb.String()
}
// drawChipIcon writes an SVG symbol inside the status circle to help identify
@@ -298,7 +518,7 @@ func pipelineSVG(p *model.Pipeline) (string, error) {
func drawChipIcon(sb *strings.Builder, job model.Job, cx, cy int) {
if job.Trigger != nil {
// Right-pointing chevron for trigger jobs.
fmt.Fprintf(sb, " <polyline points=\"%d,%d %d,%d %d,%d\" "+
fmt.Fprintf(sb, " <polyline points=\"%d,%d %d,%d %d,%d\" "+
"stroke=\"#fff\" stroke-width=\"1.5\" fill=\"none\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n",
cx-3, cy-3, cx+3, cy, cx-3, cy+3)
return
@@ -306,18 +526,24 @@ func drawChipIcon(sb *strings.Builder, job model.Job, cx, cy int) {
switch job.When {
case "manual":
// Filled play triangle.
fmt.Fprintf(sb, " <polygon points=\"%d,%d %d,%d %d,%d\" fill=\"#fff\"/>\n",
fmt.Fprintf(sb, " <polygon points=\"%d,%d %d,%d %d,%d\" fill=\"#fff\"/>\n",
cx-3, cy-4, cx+5, cy, cx-3, cy+4)
case "delayed":
// Clock hands: vertical + horizontal.
fmt.Fprintf(sb, " <circle cx=\"%d\" cy=\"%d\" r=\"5\" stroke=\"#fff\" stroke-width=\"1.2\" fill=\"none\"/>\n", cx, cy)
fmt.Fprintf(sb, " <line x1=\"%d\" y1=\"%d\" x2=\"%d\" y2=\"%d\" stroke=\"#fff\" stroke-width=\"1.2\" stroke-linecap=\"round\"/>\n",
// Clock: circle + hands.
fmt.Fprintf(sb, " <circle cx=\"%d\" cy=\"%d\" r=\"5\" stroke=\"#fff\" stroke-width=\"1.2\" fill=\"none\"/>\n", cx, cy)
fmt.Fprintf(sb, " <line x1=\"%d\" y1=\"%d\" x2=\"%d\" y2=\"%d\" stroke=\"#fff\" stroke-width=\"1.2\" stroke-linecap=\"round\"/>\n",
cx, cy, cx, cy-3)
fmt.Fprintf(sb, " <line x1=\"%d\" y1=\"%d\" x2=\"%d\" y2=\"%d\" stroke=\"#fff\" stroke-width=\"1.2\" stroke-linecap=\"round\"/>\n",
fmt.Fprintf(sb, " <line x1=\"%d\" y1=\"%d\" x2=\"%d\" y2=\"%d\" stroke=\"#fff\" stroke-width=\"1.2\" stroke-linecap=\"round\"/>\n",
cx, cy, cx+2, cy+1)
case "on_failure":
// X mark for failure-path jobs.
fmt.Fprintf(sb, " <line x1=\"%d\" y1=\"%d\" x2=\"%d\" y2=\"%d\" stroke=\"#fff\" stroke-width=\"1.5\" stroke-linecap=\"round\"/>\n",
cx-3, cy-3, cx+3, cy+3)
fmt.Fprintf(sb, " <line x1=\"%d\" y1=\"%d\" x2=\"%d\" y2=\"%d\" stroke=\"#fff\" stroke-width=\"1.5\" stroke-linecap=\"round\"/>\n",
cx+3, cy-3, cx-3, cy+3)
default:
// Regular job: small white checkmark outline.
fmt.Fprintf(sb, " <polyline points=\"%d,%d %d,%d %d,%d\" "+
// Regular job: small white checkmark.
fmt.Fprintf(sb, " <polyline points=\"%d,%d %d,%d %d,%d\" "+
"stroke=\"#fff\" stroke-width=\"1.5\" fill=\"none\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n",
cx-3, cy, cx-1, cy+3, cx+4, cy-3)
}
@@ -332,10 +558,25 @@ func chipColor(job model.Job) string {
return "#fc6d26"
case "delayed":
return "#fca326"
case "on_failure":
return "#d9534f"
}
return "#1f75cb"
}
// imageString extracts the image name from a job Image field (string or map form).
func imageString(img any) string {
switch v := img.(type) {
case string:
return v
case map[string]any:
if name, ok := v["name"].(string); ok {
return name
}
}
return ""
}
func svgEsc(s string) string {
s = strings.ReplaceAll(s, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
@@ -361,3 +602,179 @@ func svgEmpty() string {
`</svg>`,
w, h, w, h, w/2, h/2)
}
// htmlPage wraps an SVG pipeline graph in a self-contained HTML page with
// mouse pan/zoom and a job-detail sidebar that appears on chip click.
func htmlPage(svgContent string) string {
var sb strings.Builder
sb.WriteString(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Pipeline Graph</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Segoe UI', Arial, sans-serif;
background: #f5f5f5;
display: flex;
height: 100vh;
overflow: hidden;
}
#sidebar {
width: 260px;
flex-shrink: 0;
background: #fff;
border-right: 1px solid #e0e0e0;
display: none;
flex-direction: column;
overflow-y: auto;
}
#sidebar.open { display: flex; }
#sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: .6rem .8rem;
border-bottom: 1px solid #e0e0e0;
background: #fafafa;
flex-shrink: 0;
}
#sidebar-header h3 {
font-size: .9rem;
color: #303030;
word-break: break-all;
}
#close-btn {
background: none;
border: none;
cursor: pointer;
font-size: 1rem;
color: #868686;
padding: .2rem .4rem;
margin-left: .4rem;
flex-shrink: 0;
}
#close-btn:hover { color: #303030; }
#sidebar-body { padding: .8rem; flex: 1; }
#sidebar-body pre {
font-size: .82rem;
color: #555;
white-space: pre-wrap;
line-height: 1.6;
}
#viewport {
flex: 1;
overflow: hidden;
cursor: grab;
position: relative;
background: #f5f5f5;
}
#viewport.dragging { cursor: grabbing; }
#svg-wrap {
display: inline-block;
transform-origin: 0 0;
padding: 20px;
}
g[data-job] { cursor: pointer; }
g[data-job]:hover > rect { stroke: #1f75cb; stroke-width: 2; }
</style>
</head>
<body>
<div id="sidebar">
<div id="sidebar-header">
<h3 id="sidebar-title">Job details</h3>
<button id="close-btn" title="Close">&#x2715;</button>
</div>
<div id="sidebar-body">
<pre id="sidebar-details"></pre>
</div>
</div>
<div id="viewport">
<div id="svg-wrap">
`)
sb.WriteString(svgContent)
sb.WriteString(`
</div>
</div>
<script>
(function() {
'use strict';
var vp = document.getElementById('viewport');
var wrap = document.getElementById('svg-wrap');
var sidebar = document.getElementById('sidebar');
var titleEl = document.getElementById('sidebar-title');
var detailsEl = document.getElementById('sidebar-details');
var tx = 0, ty = 0, scale = 1;
function applyTransform() {
wrap.style.transform = 'translate(' + tx + 'px,' + ty + 'px) scale(' + scale + ')';
}
// Zoom toward the cursor position.
vp.addEventListener('wheel', function(e) {
e.preventDefault();
var factor = e.deltaY < 0 ? 1.1 : 1 / 1.1;
var r = vp.getBoundingClientRect();
var mx = e.clientX - r.left;
var my = e.clientY - r.top;
tx = mx - (mx - tx) * factor;
ty = my - (my - ty) * factor;
scale *= factor;
applyTransform();
}, { passive: false });
// Drag to pan (skip if clicking on a job chip).
var dragging = false, ox = 0, oy = 0;
vp.addEventListener('mousedown', function(e) {
if (e.target.closest('g[data-job]')) return;
dragging = true;
ox = e.clientX - tx;
oy = e.clientY - ty;
vp.classList.add('dragging');
});
window.addEventListener('mousemove', function(e) {
if (!dragging) return;
tx = e.clientX - ox;
ty = e.clientY - oy;
applyTransform();
});
window.addEventListener('mouseup', function() {
dragging = false;
vp.classList.remove('dragging');
});
// Double-click on the background to reset the view.
vp.addEventListener('dblclick', function(e) {
if (e.target.closest('g[data-job]')) return;
tx = 0; ty = 0; scale = 1;
applyTransform();
});
// Click a job chip to show its details in the sidebar.
document.addEventListener('click', function(e) {
var g = e.target.closest('g[data-job]');
if (!g) return;
var jobName = g.dataset.job;
var descEl = g.querySelector('desc');
var desc = descEl ? descEl.textContent : '';
titleEl.textContent = jobName;
detailsEl.textContent = desc;
sidebar.classList.add('open');
});
// Close the sidebar.
document.getElementById('close-btn').addEventListener('click', function() {
sidebar.classList.remove('open');
});
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') sidebar.classList.remove('open');
});
})();
</script>
</body>
</html>
`)
return sb.String()
}
+665
View File
@@ -0,0 +1,665 @@
package graph
import (
"os"
"strings"
"testing"
"git.k3nny.fr/glint/internal/cicontext"
"git.k3nny.fr/glint/internal/model"
)
// ── svgEsc ────────────────────────────────────────────────────────────────────
func TestSvgEsc(t *testing.T) {
cases := []struct{ in, want string }{
{"hello", "hello"},
{"a&b", "a&amp;b"},
{"<tag>", "&lt;tag&gt;"},
{"a&b<c>d", "a&amp;b&lt;c&gt;d"},
}
for _, tc := range cases {
if got := svgEsc(tc.in); got != tc.want {
t.Errorf("svgEsc(%q)=%q want %q", tc.in, got, tc.want)
}
}
}
// ── svgTrunc ──────────────────────────────────────────────────────────────────
func TestSvgTrunc(t *testing.T) {
cases := []struct {
s string
maxLen int
want string
}{
{"short", 20, "short"},
{"exactly20charslong!", 20, "exactly20charslong!"},
{"this-is-a-very-long-job-name", 20, "this-is-a-very-long…"},
{"αβγδεζηθικλμνξο", 5, "αβγδ…"},
}
for _, tc := range cases {
got := svgTrunc(tc.s, tc.maxLen)
if got != tc.want {
t.Errorf("svgTrunc(%q, %d)=%q want %q", tc.s, tc.maxLen, got, tc.want)
}
}
}
// ── svgEmpty ──────────────────────────────────────────────────────────────────
func TestSvgEmpty(t *testing.T) {
out := svgEmpty()
if !strings.Contains(out, "<svg") { t.Error("expected <svg") }
if !strings.Contains(out, "no jobs defined") { t.Error("expected 'no jobs defined'") }
}
// ── chipColor ─────────────────────────────────────────────────────────────────
func TestChipColor(t *testing.T) {
if chipColor(model.Job{Trigger: "x"}) != "#6b4fbb" { t.Error("trigger color") }
if chipColor(model.Job{When: "manual"}) != "#fc6d26" { t.Error("manual color") }
if chipColor(model.Job{When: "delayed"}) != "#fca326" { t.Error("delayed color") }
if chipColor(model.Job{When: "on_failure"}) != "#d9534f" { t.Error("on_failure color") }
if chipColor(model.Job{}) != "#1f75cb" { t.Error("regular color") }
}
// ── imageString ───────────────────────────────────────────────────────────────
func TestImageString(t *testing.T) {
if imageString("alpine") != "alpine" { t.Error("string form") }
if imageString(map[string]any{"name": "golang:1.21"}) != "golang:1.21" { t.Error("map form") }
if imageString(map[string]any{"other": "x"}) != "" { t.Error("map without name key") }
if imageString(nil) != "" { t.Error("nil") }
if imageString(42) != "" { t.Error("unexpected type") }
}
// ── drawChipIcon ──────────────────────────────────────────────────────────────
func TestDrawChipIcon(t *testing.T) {
cases := []struct {
job model.Job
wantTag string
}{
{model.Job{Trigger: "x"}, "<polyline"},
{model.Job{When: "manual"}, "<polygon"},
{model.Job{When: "delayed"}, "<circle"},
{model.Job{When: "on_failure"}, "<line"},
{model.Job{}, "<polyline"},
}
for _, tc := range cases {
var sb strings.Builder
drawChipIcon(&sb, tc.job, 10, 10)
if !strings.Contains(sb.String(), tc.wantTag) {
t.Errorf("drawChipIcon(%q): want %s, got:\n%s", tc.job.When, tc.wantTag, sb.String())
}
}
}
// ── pipelineSVG ───────────────────────────────────────────────────────────────
func TestPipelineSVG_Empty(t *testing.T) {
svg := pipelineSVG(&model.Pipeline{}, nil)
if !strings.Contains(svg, "no jobs defined") {
t.Errorf("expected 'no jobs defined', got:\n%s", svg)
}
}
func TestPipelineSVG_ClassicMode(t *testing.T) {
p := &model.Pipeline{
Stages: []string{"build", "test"},
Jobs: map[string]model.Job{
"build-job": {Name: "build-job", Stage: "build"},
"test-job": {Name: "test-job", Stage: "test"},
},
}
svg := pipelineSVG(p, nil)
if !strings.Contains(svg, "<svg") { t.Error("expected <svg") }
if !strings.Contains(svg, "build-job") { t.Error("expected build-job") }
if !strings.Contains(svg, "test-job") { t.Error("expected test-job") }
// Classic mode bus-bar draws <line> elements for connectors
if !strings.Contains(svg, "<line") {
t.Error("expected <line> connector in classic mode")
}
}
func TestPipelineSVG_ClassicMode_MultiJob(t *testing.T) {
// Two source jobs, two destination jobs → bus-bar with vertical segment
p := &model.Pipeline{
Stages: []string{"build", "test"},
Jobs: map[string]model.Job{
"build-a": {Name: "build-a", Stage: "build"},
"build-b": {Name: "build-b", Stage: "build"},
"test-a": {Name: "test-a", Stage: "test"},
"test-b": {Name: "test-b", Stage: "test"},
},
}
svg := pipelineSVG(p, nil)
// Bus bar: horizontal stubs from each src + dst, plus a vertical bus line
// and an arrowhead polygon for each dst job.
if !strings.Contains(svg, "<polygon") {
t.Error("expected arrowhead <polygon> in multi-job classic mode")
}
}
func TestPipelineSVG_DAGMode(t *testing.T) {
p := &model.Pipeline{
Stages: []string{"build", "test"},
Jobs: map[string]model.Job{
"build-job": {Name: "build-job", Stage: "build"},
"test-job": {Name: "test-job", Stage: "test", Needs: []any{"build-job"}},
},
}
svg := pipelineSVG(p, nil)
// DAG mode draws <path> bezier curves
if !strings.Contains(svg, "<path") {
t.Error("expected <path> connector in DAG mode")
}
}
func TestPipelineSVG_DAGMode_StraightConnector(t *testing.T) {
// Two stages at same height → straight connector in classic mode
p := &model.Pipeline{
Stages: []string{"a", "b"},
Jobs: map[string]model.Job{
"j1": {Name: "j1", Stage: "a"},
"j2": {Name: "j2", Stage: "b"},
},
}
svg := pipelineSVG(p, nil)
if !strings.Contains(svg, "<svg") { t.Error("expected <svg") }
}
func TestPipelineSVG_AllJobTypes(t *testing.T) {
p := &model.Pipeline{
Stages: []string{"deploy"},
Jobs: map[string]model.Job{
"manual-job": {Name: "manual-job", Stage: "deploy", When: "manual"},
"delayed-job": {Name: "delayed-job", Stage: "deploy", When: "delayed"},
"trigger-job": {Name: "trigger-job", Stage: "deploy", Trigger: "other/project"},
"onfailure-job": {Name: "onfailure-job", Stage: "deploy", When: "on_failure"},
},
}
svg := pipelineSVG(p, nil)
if !strings.Contains(svg, "#fc6d26") { t.Error("expected manual color") }
if !strings.Contains(svg, "#fca326") { t.Error("expected delayed color") }
if !strings.Contains(svg, "#6b4fbb") { t.Error("expected trigger color") }
if !strings.Contains(svg, "#d9534f") { t.Error("expected on_failure color") }
// on_failure chips have a dashed border
if !strings.Contains(svg, `stroke-dasharray="4,3"`) {
t.Error("expected dashed border on on_failure chip")
}
}
func TestPipelineSVG_JobNoStage(t *testing.T) {
p := &model.Pipeline{
Jobs: map[string]model.Job{
"myjob": {Name: "myjob"},
},
}
svg := pipelineSVG(p, nil)
if !strings.Contains(svg, "myjob") { t.Error("expected myjob") }
}
func TestPipelineSVG_CrossPipelineNeed(t *testing.T) {
p := &model.Pipeline{
Stages: []string{"build"},
Jobs: map[string]model.Job{
"j": {Name: "j", Stage: "build",
Needs: []any{map[string]any{"pipeline": "other", "job": "x"}}},
},
}
svg := pipelineSVG(p, nil)
if !strings.Contains(svg, "<svg") { t.Error("expected <svg") }
}
func TestPipelineSVG_Tooltip(t *testing.T) {
p := &model.Pipeline{
Stages: []string{"build"},
Jobs: map[string]model.Job{
"build-job": {
Name: "build-job",
Stage: "build",
When: "on_success",
Image: "golang:1.21",
Needs: []any{"prep-job"},
},
},
}
svg := pipelineSVG(p, nil)
// Tooltip <title> and <desc> should appear inside the <g data-job> wrapper
if !strings.Contains(svg, `<title>build-job</title>`) {
t.Error("expected <title>build-job</title>")
}
if !strings.Contains(svg, `<desc>`) {
t.Error("expected <desc> element")
}
if !strings.Contains(svg, "image: golang:1.21") {
t.Error("expected image in desc")
}
if !strings.Contains(svg, "needs: prep-job") {
t.Error("expected needs in desc")
}
if !strings.Contains(svg, `data-job="build-job"`) {
t.Error("expected data-job attribute")
}
}
func TestPipelineSVG_SkippedJob(t *testing.T) {
// A job with rules that never match in the given context should be greyed out.
p := &model.Pipeline{
Stages: []string{"deploy"},
Jobs: map[string]model.Job{
"deploy-job": {
Name: "deploy-job",
Stage: "deploy",
Script: []any{"deploy.sh"},
Rules: []model.Rule{
{If: `$CI_COMMIT_TAG != ""`, When: "on_success"},
},
},
},
}
// Branch context: CI_COMMIT_TAG is empty → rule doesn't match → job skipped
ctx := cicontext.New("main", "", "push", nil)
svg := pipelineSVG(p, ctx)
// Skipped jobs use grey (#868686) instead of the regular blue
if !strings.Contains(svg, `fill="#868686"`) {
t.Error("expected grey fill for skipped job circle")
}
// Skipped state should appear in the tooltip desc
if !strings.Contains(svg, "state: skipped") {
t.Error("expected 'state: skipped' in tooltip desc")
}
}
func TestPipelineSVG_ContextNil(t *testing.T) {
// nil context → no skipped jobs, regular colors
p := &model.Pipeline{
Stages: []string{"build"},
Jobs: map[string]model.Job{
"j": {Name: "j", Stage: "build"},
},
}
svg := pipelineSVG(p, nil)
if !strings.Contains(svg, `fill="#1f75cb"`) {
t.Error("expected regular blue fill with nil context")
}
}
// ── RenderPipeline ────────────────────────────────────────────────────────────
func TestRenderPipeline(t *testing.T) {
p := &model.Pipeline{
Stages: []string{"build"},
Jobs: map[string]model.Job{
"build-job": {Name: "build-job", Stage: "build"},
},
}
dir := t.TempDir()
path, err := RenderPipeline(p, dir, nil)
if err != nil {
t.Fatalf("RenderPipeline: %v", err)
}
if _, statErr := os.Stat(path); statErr != nil {
t.Errorf("output file not found: %v", statErr)
}
}
func TestRenderPipeline_InvalidDir(t *testing.T) {
// Writing to a path under a file (not a dir) should error.
dir := t.TempDir()
filePath := dir + "/notadir"
if err := os.WriteFile(filePath, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
_, err := RenderPipeline(&model.Pipeline{}, filePath+"/nested", nil)
if err == nil {
t.Error("expected error writing to path under a file")
}
}
func TestRenderPipeline_WriteFileFails(t *testing.T) {
if os.Getuid() == 0 {
t.Skip("skipping as root — file permissions don't apply")
}
dir := t.TempDir()
if err := os.Chmod(dir, 0o555); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { os.Chmod(dir, 0o755) })
p := &model.Pipeline{
Stages: []string{"build"},
Jobs: map[string]model.Job{"j": {Name: "j", Stage: "build"}},
}
_, err := RenderPipeline(p, dir, nil)
if err == nil {
t.Error("expected error when writing SVG to read-only directory")
}
}
// ── convertToPNG ─────────────────────────────────────────────────────────────
func TestRenderPipeline_SVGFallback(t *testing.T) {
// With no converters on PATH, RenderPipeline returns the SVG path (line 53).
origPath := os.Getenv("PATH")
os.Setenv("PATH", "")
t.Cleanup(func() { os.Setenv("PATH", origPath) })
p := &model.Pipeline{
Stages: []string{"build"},
Jobs: map[string]model.Job{"j": {Name: "j", Stage: "build"}},
}
dir := t.TempDir()
path, err := RenderPipeline(p, dir, nil)
if err != nil {
t.Fatalf("RenderPipeline: %v", err)
}
if !strings.HasSuffix(path, ".svg") {
t.Errorf("expected .svg output when no converter available, got %q", path)
}
}
func TestConvertToPNG_NoConverter(t *testing.T) {
// In a test environment without rsvg-convert/inkscape/magick, returns false.
// We can't assert false because the CI machine might have one of them,
// but we can assert it doesn't panic.
dir := t.TempDir()
svgPath := dir + "/test.svg"
pngPath := dir + "/test.png"
_ = os.WriteFile(svgPath, []byte(`<svg xmlns="http://www.w3.org/2000/svg"/>`), 0o644)
// result is either true (converter found) or false (no converter) — just no panic
convertToPNG(svgPath, pngPath)
}
func TestConvertToPNG_FakeConverter(t *testing.T) {
// Install a fake rsvg-convert that just creates the output file and exits 0.
// This exercises the "return true" branch and os.Remove in RenderPipeline.
binDir := t.TempDir()
script := "#!/bin/sh\n# rsvg-convert --output <out> <in>\ncp \"$3\" \"$2\"\n"
fakeBin := binDir + "/rsvg-convert"
if err := os.WriteFile(fakeBin, []byte(script), 0o755); err != nil {
t.Fatal(err)
}
origPath := os.Getenv("PATH")
os.Setenv("PATH", binDir+":"+origPath)
t.Cleanup(func() { os.Setenv("PATH", origPath) })
svgDir := t.TempDir()
svgPath := svgDir + "/test.svg"
pngPath := svgDir + "/test.png"
if err := os.WriteFile(svgPath, []byte(`<svg xmlns="http://www.w3.org/2000/svg"/>`), 0o644); err != nil {
t.Fatal(err)
}
ok := convertToPNG(svgPath, pngPath)
if !ok {
t.Error("expected convertToPNG to return true with fake rsvg-convert")
}
}
func TestRenderPipeline_PNGConversion(t *testing.T) {
// Verify the PNG path is returned (and SVG removed) when converter succeeds.
binDir := t.TempDir()
script := "#!/bin/sh\ncp \"$3\" \"$2\"\n"
if err := os.WriteFile(binDir+"/rsvg-convert", []byte(script), 0o755); err != nil {
t.Fatal(err)
}
origPath := os.Getenv("PATH")
os.Setenv("PATH", binDir+":"+origPath)
t.Cleanup(func() { os.Setenv("PATH", origPath) })
p := &model.Pipeline{
Stages: []string{"build"},
Jobs: map[string]model.Job{"j": {Name: "j", Stage: "build"}},
}
dir := t.TempDir()
path, err := RenderPipeline(p, dir, nil)
if err != nil {
t.Fatalf("RenderPipeline: %v", err)
}
if !strings.HasSuffix(path, ".png") {
t.Errorf("expected .png output, got %q", path)
}
}
// ── pipelineSVG coverage gaps ─────────────────────────────────────────────────
func TestPipelineSVG_LShapedElbow(t *testing.T) {
// Classic mode with unequal stage sizes → vertical bus bar required
p := &model.Pipeline{
Stages: []string{"build", "test"},
Jobs: map[string]model.Job{
"j1": {Name: "j1", Stage: "build"},
"j2": {Name: "j2", Stage: "test"},
"j3": {Name: "j3", Stage: "test"},
},
}
svg := pipelineSVG(p, nil)
// The bus bar is a <line>; arrowheads are <polygon>
if !strings.Contains(svg, "<polygon") {
t.Error("expected <polygon> arrowhead in bus-bar connector")
}
}
func TestPipelineSVG_DAGNeedNotInPositionMap(t *testing.T) {
// DAG mode: job needs a template job (starts with .) → dep not in position maps → continue
p := &model.Pipeline{
Stages: []string{"build"},
Jobs: map[string]model.Job{
"j": {Name: "j", Stage: "build", Needs: []any{".hidden-template"}},
".hidden-template": {Name: ".hidden-template", Stage: "build"},
},
}
svg := pipelineSVG(p, nil)
// Should render without panic; .hidden-template is not visible so no connector drawn.
if !strings.Contains(svg, "<svg") {
t.Error("expected valid SVG output")
}
}
// ── RenderHTML ────────────────────────────────────────────────────────────────
func TestRenderHTML(t *testing.T) {
p := &model.Pipeline{
Stages: []string{"build"},
Jobs: map[string]model.Job{
"build-job": {Name: "build-job", Stage: "build"},
},
}
dir := t.TempDir()
path, err := RenderHTML(p, dir, nil)
if err != nil {
t.Fatalf("RenderHTML: %v", err)
}
if !strings.HasSuffix(path, ".html") {
t.Errorf("expected .html output, got %q", path)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("reading output: %v", err)
}
html := string(data)
if !strings.Contains(html, "<!DOCTYPE html>") { t.Error("expected DOCTYPE") }
if !strings.Contains(html, "<svg") { t.Error("expected embedded <svg") }
if !strings.Contains(html, "build-job") { t.Error("expected job name in HTML") }
if !strings.Contains(html, "applyTransform") { t.Error("expected JS pan/zoom function") }
if !strings.Contains(html, "sidebar") { t.Error("expected sidebar element") }
}
func TestRenderHTML_InvalidDir(t *testing.T) {
dir := t.TempDir()
filePath := dir + "/notadir"
if err := os.WriteFile(filePath, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
_, err := RenderHTML(&model.Pipeline{}, filePath+"/nested", nil)
if err == nil {
t.Error("expected error writing to path under a file")
}
}
func TestRenderHTML_WriteFileFails(t *testing.T) {
if os.Getuid() == 0 {
t.Skip("skipping as root — file permissions don't apply")
}
dir := t.TempDir()
if err := os.Chmod(dir, 0o555); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { os.Chmod(dir, 0o755) })
p := &model.Pipeline{
Stages: []string{"build"},
Jobs: map[string]model.Job{"j": {Name: "j", Stage: "build"}},
}
_, err := RenderHTML(p, dir, nil)
if err == nil {
t.Error("expected error when writing HTML to read-only directory")
}
}
// ── computeColumns ────────────────────────────────────────────────────────────
func TestComputeColumns_NoIntraNeeds(t *testing.T) {
// No intra-stage needs → one column per stage.
stages := []string{"build", "test"}
byStage := map[string][]string{
"build": {"build-a", "build-b"},
"test": {"test-a"},
}
jobs := map[string]model.Job{
"build-a": {Name: "build-a", Stage: "build"},
"build-b": {Name: "build-b", Stage: "build"},
"test-a": {Name: "test-a", Stage: "test"},
}
cols := computeColumns(stages, byStage, jobs)
if len(cols) != 2 {
t.Fatalf("expected 2 columns (one per stage), got %d", len(cols))
}
if cols[0].stage != "build" || len(cols[0].jobs) != 2 {
t.Errorf("col[0]: got stage=%q jobs=%v", cols[0].stage, cols[0].jobs)
}
if cols[1].stage != "test" || len(cols[1].jobs) != 1 {
t.Errorf("col[1]: got stage=%q jobs=%v", cols[1].stage, cols[1].jobs)
}
}
func TestComputeColumns_IntraNeeds(t *testing.T) {
// job-b depends on job-a in the same stage → split into 2 sub-columns.
stages := []string{"build"}
byStage := map[string][]string{
"build": {"job-a", "job-b"},
}
jobs := map[string]model.Job{
"job-a": {Name: "job-a", Stage: "build"},
"job-b": {Name: "job-b", Stage: "build", Needs: []any{"job-a"}},
}
cols := computeColumns(stages, byStage, jobs)
if len(cols) != 2 {
t.Fatalf("expected 2 sub-columns, got %d", len(cols))
}
// Both belong to the same stage.
if cols[0].stage != "build" || cols[1].stage != "build" {
t.Error("both sub-columns should be in stage 'build'")
}
// job-a has no same-stage deps → depth 0 → first sub-column.
if len(cols[0].jobs) != 1 || cols[0].jobs[0] != "job-a" {
t.Errorf("col[0] should contain job-a, got %v", cols[0].jobs)
}
// job-b depends on job-a → depth 1 → second sub-column.
if len(cols[1].jobs) != 1 || cols[1].jobs[0] != "job-b" {
t.Errorf("col[1] should contain job-b, got %v", cols[1].jobs)
}
}
func TestComputeColumns_EmptyStageSkipped(t *testing.T) {
// Stages with no jobs are silently skipped.
stages := []string{"build", "empty", "test"}
byStage := map[string][]string{
"build": {"j1"},
"test": {"j2"},
}
jobs := map[string]model.Job{
"j1": {Name: "j1", Stage: "build"},
"j2": {Name: "j2", Stage: "test"},
}
cols := computeColumns(stages, byStage, jobs)
if len(cols) != 2 {
t.Fatalf("expected 2 columns (empty stage skipped), got %d", len(cols))
}
}
func TestComputeColumns_CrossStageNeedIgnored(t *testing.T) {
// job-b has needs: for both job-a (same stage) and prev-job (different stage).
// The cross-stage need must be ignored when computing topological depth.
stages := []string{"build"}
byStage := map[string][]string{
"build": {"job-a", "job-b"},
}
jobs := map[string]model.Job{
"job-a": {Name: "job-a", Stage: "build"},
"job-b": {Name: "job-b", Stage: "build", Needs: []any{"job-a", "prev-job"}},
"prev-job": {Name: "prev-job", Stage: "prepare"},
}
cols := computeColumns(stages, byStage, jobs)
// Still split into 2 sub-columns; the cross-stage need is ignored.
if len(cols) != 2 {
t.Fatalf("expected 2 sub-columns, got %d", len(cols))
}
if cols[0].jobs[0] != "job-a" {
t.Errorf("expected job-a in first sub-column, got %v", cols[0].jobs)
}
if cols[1].jobs[0] != "job-b" {
t.Errorf("expected job-b in second sub-column, got %v", cols[1].jobs)
}
}
func TestComputeColumns_CycleGuard(t *testing.T) {
// Intra-stage cycle must not hang (cycle guard returns depth 0).
stages := []string{"build"}
byStage := map[string][]string{
"build": {"a", "b"},
}
jobs := map[string]model.Job{
"a": {Name: "a", Stage: "build", Needs: []any{"b"}},
"b": {Name: "b", Stage: "build", Needs: []any{"a"}},
}
cols := computeColumns(stages, byStage, jobs)
// Should return without hanging; exact column count is implementation-defined.
if len(cols) == 0 {
t.Error("expected at least one column")
}
}
func TestPipelineSVG_IntraStageDAG(t *testing.T) {
// job-b depends on job-a in the same stage → SVG should render both,
// placed as sub-columns (sub-stage gap between them).
p := &model.Pipeline{
Stages: []string{"build"},
Jobs: map[string]model.Job{
"job-a": {Name: "job-a", Stage: "build"},
"job-b": {Name: "job-b", Stage: "build", Needs: []any{"job-a"}},
},
}
svg := pipelineSVG(p, nil)
if !strings.Contains(svg, "job-a") { t.Error("expected job-a in SVG") }
if !strings.Contains(svg, "job-b") { t.Error("expected job-b in SVG") }
// DAG mode: bezier connectors between the two jobs.
if !strings.Contains(svg, "<path") {
t.Error("expected bezier <path> connector between intra-stage jobs")
}
}
// ── htmlPage ──────────────────────────────────────────────────────────────────
func TestHtmlPage(t *testing.T) {
svgContent := `<svg xmlns="http://www.w3.org/2000/svg" width="100" height="50"><rect/></svg>`
html := htmlPage(svgContent)
if !strings.Contains(html, "<!DOCTYPE html>") { t.Error("expected DOCTYPE") }
if !strings.Contains(html, svgContent) { t.Error("expected SVG embedded in HTML") }
if !strings.Contains(html, "id=\"viewport\"") { t.Error("expected viewport element") }
if !strings.Contains(html, "id=\"sidebar\"") { t.Error("expected sidebar element") }
if !strings.Contains(html, "applyTransform") { t.Error("expected JS transform function") }
}
+154
View File
@@ -0,0 +1,154 @@
package graph
import (
"strings"
"testing"
"git.k3nny.fr/glint/internal/cicontext"
"git.k3nny.fr/glint/internal/model"
)
func makeSimplePipeline() *model.Pipeline {
return &model.Pipeline{
Stages: []string{"build", "test"},
Jobs: map[string]model.Job{
"build-job": {Name: "build-job", Stage: "build"},
"test-job": {Name: "test-job", Stage: "test"},
".template": {Name: ".template", Stage: "build"},
},
}
}
func TestTree_Basic(t *testing.T) {
p := makeSimplePipeline()
out := Tree(p, nil)
if !strings.Contains(out, "pipeline") {
t.Error("expected 'pipeline' root node")
}
if !strings.Contains(out, "build-job") {
t.Error("expected build-job in tree")
}
if !strings.Contains(out, "test-job") {
t.Error("expected test-job in tree")
}
// hidden template jobs should not appear
if strings.Contains(out, ".template") {
t.Error(".template job should be excluded from tree")
}
}
func TestTree_WithContext_Skipped(t *testing.T) {
p := &model.Pipeline{
Stages: []string{"build"},
Jobs: map[string]model.Job{
"run-on-tag": {Name: "run-on-tag", Stage: "build", Rules: []model.Rule{
{If: `$CI_COMMIT_TAG != ""`, When: "on_success"},
}},
},
}
ctx := cicontext.New("main", "", "", nil)
out := Tree(p, ctx)
if !strings.Contains(out, "[skipped]") {
t.Errorf("expected [skipped] annotation, got:\n%s", out)
}
}
func TestTree_WithContext_Manual(t *testing.T) {
p := &model.Pipeline{
Stages: []string{"deploy"},
Jobs: map[string]model.Job{
"deploy": {Name: "deploy", Stage: "deploy", When: "manual"},
},
}
ctx := cicontext.New("main", "", "", nil)
out := Tree(p, ctx)
if !strings.Contains(out, "[manual]") {
t.Errorf("expected [manual] annotation, got:\n%s", out)
}
}
func TestTree_NoContext_Annotations(t *testing.T) {
p := &model.Pipeline{
Stages: []string{"build"},
Jobs: map[string]model.Job{
"manual-job": {Name: "manual-job", Stage: "build", When: "manual"},
"delayed-job": {Name: "delayed-job", Stage: "build", When: "delayed"},
"trigger-job": {Name: "trigger-job", Stage: "build", Trigger: "other/project"},
},
}
out := Tree(p, nil)
if !strings.Contains(out, "[manual]") { t.Error("expected [manual]") }
if !strings.Contains(out, "[delayed]") { t.Error("expected [delayed]") }
if !strings.Contains(out, "[trigger]") { t.Error("expected [trigger]") }
}
func TestTree_JobWithNoStage(t *testing.T) {
// Jobs without a stage should default to "test"
p := &model.Pipeline{
Stages: []string{"test"},
Jobs: map[string]model.Job{
"nostage": {Name: "nostage"},
},
}
out := Tree(p, nil)
if !strings.Contains(out, "test") { t.Error("expected default stage 'test'") }
}
func TestTree_UndeclaredStage(t *testing.T) {
// Job in a stage not listed in p.Stages — should still appear
p := &model.Pipeline{
Stages: []string{},
Jobs: map[string]model.Job{
"myjob": {Name: "myjob", Stage: "custom"},
},
}
out := Tree(p, nil)
if !strings.Contains(out, "myjob") { t.Error("expected myjob in output") }
}
// ── branchChars ───────────────────────────────────────────────────────────────
func TestBranchChars(t *testing.T) {
b, c := branchChars(true)
if b != "└── " || c != " " {
t.Errorf("last: branch=%q cont=%q", b, c)
}
b, c = branchChars(false)
if b != "├── " || c != "│ " {
t.Errorf("not-last: branch=%q cont=%q", b, c)
}
}
// ── jobLabel ─────────────────────────────────────────────────────────────────
func TestJobLabel(t *testing.T) {
job := model.Job{Name: "j"}
if jobLabel(job, "j", nil) != "j" {
t.Error("plain job should have plain label")
}
// Multiple tags
job2 := model.Job{Name: "j2", When: "manual", Trigger: "other"}
label := jobLabel(job2, "j2", nil)
if !strings.Contains(label, "manual") || !strings.Contains(label, "trigger") {
t.Errorf("expected manual+trigger in label: %q", label)
}
// With context — active job has no annotation
emptyCtx := &cicontext.Context{}
if jobLabel(model.Job{}, "j", emptyCtx) != "j" {
t.Error("active job with context should have no annotation")
}
}
// TestJobLabel_ActiveWithContext covers the 'return name' path when a job runs
// normally (neither skipped nor manual) with a non-empty context.
func TestJobLabel_ActiveWithContext(t *testing.T) {
// Branch context with a job that has no rules → the job is active.
ctx := cicontext.New("main", "", "", nil)
activeJob := model.Job{Name: "build", Script: []any{"make"}}
label := jobLabel(activeJob, "build", ctx)
if label != "build" {
t.Errorf("active job with context: expected 'build', got %q", label)
}
}
+6 -1
View File
@@ -6,7 +6,7 @@ import (
"git.k3nny.fr/glint/internal/model"
)
func checkDependencies(p *model.Pipeline) []Finding {
func checkDependencies(p *model.Pipeline, skipped map[string]bool) []Finding {
stageIndex := make(map[string]int, len(p.Stages))
for i, s := range p.Stages {
stageIndex[s] = i
@@ -14,6 +14,9 @@ func checkDependencies(p *model.Pipeline) []Finding {
var findings []Finding
for name, job := range p.Jobs {
if skipped[name] {
continue
}
if len(job.Dependencies) == 0 {
continue
}
@@ -27,6 +30,7 @@ func checkDependencies(p *model.Pipeline) []Finding {
Job: name,
File: job.File,
Line: job.Line,
Column: job.Column,
Message: fmt.Sprintf("'dependencies' references unknown job %q", dep),
})
continue
@@ -40,6 +44,7 @@ func checkDependencies(p *model.Pipeline) []Finding {
Job: name,
File: job.File,
Line: job.Line,
Column: job.Column,
Message: fmt.Sprintf("'dependencies' job %q must be in an earlier stage (in %q, current job is in %q)", dep, depJob.Stage, job.Stage),
})
}
+870
View File
@@ -0,0 +1,870 @@
package linter
// RuleEntry holds the human-readable documentation for a single lint rule,
// shown by 'glint explain <RULE>'.
type RuleEntry struct {
Title string // short title (one line)
Severity Severity // Error or Warning
Description string // what triggers the rule and why it matters
Example string // YAML snippet that triggers the rule
Fix string // corrected YAML
}
// RuleCatalog maps stable rule IDs to their documentation entries.
var RuleCatalog = map[string]RuleEntry{
RuleNoStages: {
Title: "no stages defined",
Severity: Warning,
Description: "The pipeline has no 'stages:' block. GitLab falls back to the " +
"built-in default stages (build, test, deploy), which may not match your " +
"intended job ordering.",
Example: `# No stages: block
build-job:
script: make build
test-job:
script: make test`,
Fix: `stages:
- build
- test
build-job:
stage: build
script: make build
test-job:
stage: test
script: make test`,
},
RuleWorkflowWhen: {
Title: "workflow.rules.when invalid value",
Severity: Error,
Description: "'workflow.rules[n].when' only accepts 'always' or 'never'. Any " +
"other value (such as 'on_success' or 'manual') is invalid and will cause " +
"the pipeline to fail to create.",
Example: `workflow:
rules:
- if: $CI_COMMIT_BRANCH
when: on_success # invalid here`,
Fix: `workflow:
rules:
- if: $CI_COMMIT_BRANCH
when: always`,
},
RuleMissingScript: {
Title: "missing required 'script' field",
Severity: Error,
Description: "Every non-trigger, non-pages job must define 'script:' (or 'run:' " +
"for CI Steps). A job with no script will fail immediately when GitLab tries " +
"to run it.",
Example: `my-job:
stage: test
image: python:3.12
# Missing script:`,
Fix: `my-job:
stage: test
image: python:3.12
script: pytest`,
},
RuleUnknownStage: {
Title: "job references undeclared stage",
Severity: Error,
Description: "The job's 'stage:' value does not match any name in the pipeline's " +
"'stages:' list. GitLab will reject the pipeline configuration.",
Example: `stages:
- build
- test
deploy-job:
stage: production # not in stages:
script: ./deploy.sh`,
Fix: `stages:
- build
- test
- production
deploy-job:
stage: production
script: ./deploy.sh`,
},
RuleOnlyRulesConflict: {
Title: "'only:' and 'rules:' used together",
Severity: Error,
Description: "'only:' and 'rules:' are mutually exclusive. Using both on the " +
"same job is a configuration error that prevents the pipeline from being " +
"created.",
Example: `my-job:
only:
- main
rules:
- if: $CI_COMMIT_BRANCH
script: echo conflict`,
Fix: `# Remove only: and use rules: exclusively
my-job:
rules:
- if: $CI_COMMIT_BRANCH == "main"
script: echo ok`,
},
RuleExceptRulesConflict: {
Title: "'except:' and 'rules:' used together",
Severity: Error,
Description: "'except:' and 'rules:' are mutually exclusive. Using both on the " +
"same job is a configuration error that prevents the pipeline from being " +
"created.",
Example: `my-job:
except:
- tags
rules:
- if: $CI_COMMIT_BRANCH
script: echo conflict`,
Fix: `my-job:
rules:
- if: $CI_COMMIT_TAG
when: never
- if: $CI_COMMIT_BRANCH
script: echo ok`,
},
RuleDeprecatedOnly: {
Title: "'only:' / 'except:' deprecated",
Severity: Warning,
Description: "'only:' and 'except:' are deprecated in favour of 'rules:'. " +
"GitLab may remove support for these keywords in a future major version. " +
"'rules:' is more expressive and supports variable references, merge-request " +
"conditions, and fine-grained 'when:' control.",
Example: `my-job:
only:
- main
script: ./deploy.sh`,
Fix: `my-job:
rules:
- if: $CI_COMMIT_BRANCH == "main"
script: ./deploy.sh`,
},
RuleInvalidWhen: {
Title: "'when:' has invalid value",
Severity: Error,
Description: "Job-level 'when:' accepts: on_success, on_failure, always, " +
"manual, delayed, never. Any other value is rejected by GitLab.",
Example: `my-job:
when: sometimes # invalid
script: echo hi`,
Fix: `my-job:
when: on_success
script: echo hi`,
},
RuleDelayedNoStartIn: {
Title: "'when: delayed' without 'start_in:'",
Severity: Error,
Description: "'when: delayed' requires a 'start_in:' duration (e.g. " +
"'30 minutes', '1 hour'). Without it GitLab rejects the job configuration.",
Example: `my-job:
when: delayed
script: echo hi`,
Fix: `my-job:
when: delayed
start_in: 30 minutes
script: echo hi`,
},
RuleStartInNoDelayed: {
Title: "'start_in:' without 'when: delayed'",
Severity: Error,
Description: "'start_in:' is only meaningful when 'when: delayed'. Setting it " +
"alongside any other 'when:' value is a configuration error.",
Example: `my-job:
when: on_success
start_in: 30 minutes # only valid with delayed
script: echo hi`,
Fix: `my-job:
when: delayed
start_in: 30 minutes
script: echo hi`,
},
RuleInvalidParallel: {
Title: "'parallel:' value invalid",
Severity: Error,
Description: "'parallel:' must be an integer between 2 and 200 (inclusive), " +
"or a map with a 'matrix:' key for matrix jobs. Values outside this range " +
"or the wrong type are rejected by GitLab.",
Example: `my-job:
parallel: 1 # must be 2200
script: echo hi`,
Fix: `my-job:
parallel: 5
script: echo hi`,
},
RuleInvalidRetry: {
Title: "'retry:' value invalid",
Severity: Error,
Description: "'retry:' accepts an integer 02 or a map with optional 'max:' " +
"(02) and 'when:' keys. Values outside this range are rejected.",
Example: `my-job:
retry: 5 # max is 2
script: echo hi`,
Fix: `my-job:
retry: 2
script: echo hi`,
},
RuleInvalidRetryWhen: {
Title: "'retry.when:' has invalid failure type",
Severity: Error,
Description: "'retry.when:' must be a recognised GitLab CI failure type " +
"(e.g. script_failure, runner_system_failure) or 'always'. Unknown " +
"values are rejected.",
Example: `my-job:
retry:
max: 2
when: disk_full # not a valid failure type
script: echo hi`,
Fix: `my-job:
retry:
max: 2
when: runner_system_failure
script: echo hi`,
},
RuleInvalidAllowFailure: {
Title: "'allow_failure:' invalid value",
Severity: Error,
Description: "'allow_failure:' must be a boolean (true/false) or a map with " +
"an 'exit_codes:' key. Other forms are rejected by GitLab.",
Example: `my-job:
allow_failure: maybe # must be true/false or map
script: echo hi`,
Fix: `my-job:
allow_failure: true
script: echo hi`,
},
RuleInvalidInterruptible: {
Title: "'interruptible:' is not a boolean",
Severity: Error,
Description: "'interruptible:' must be a boolean (true or false). Other types " +
"are rejected.",
Example: `my-job:
interruptible: yes # must be a YAML boolean true/false
script: echo hi`,
Fix: `my-job:
interruptible: true
script: echo hi`,
},
RuleTriggerWithScript: {
Title: "trigger job also defines 'script:'",
Severity: Error,
Description: "Jobs with a 'trigger:' key (downstream pipeline triggers) cannot " +
"use 'script:'. These are mutually exclusive — a trigger job has no runner " +
"environment to execute scripts in.",
Example: `trigger-job:
trigger:
project: mygroup/myproject
script: echo this will fail`,
Fix: `trigger-job:
trigger:
project: mygroup/myproject`,
},
RuleInvalidTrigger: {
Title: "trigger: map missing 'project:' or 'include:'",
Severity: Error,
Description: "When 'trigger:' is a map it must specify either 'project:' " +
"(downstream project trigger) or 'include:' (dynamic child pipeline). " +
"Without one of these keys GitLab cannot determine what to trigger.",
Example: `trigger-job:
trigger:
strategy: depend # missing project: or include:`,
Fix: `trigger-job:
trigger:
project: mygroup/downstream
strategy: depend`,
},
RuleInvalidCoverage: {
Title: "'coverage:' is not a regex pattern",
Severity: Error,
Description: "'coverage:' must be a regex pattern wrapped in forward slashes " +
"(e.g. '/Coverage: \\d+\\.?\\d*%/'). GitLab uses this pattern to extract " +
"the coverage percentage from job output.",
Example: `my-job:
coverage: "\\d+%" # missing surrounding //
script: pytest --cov`,
Fix: `my-job:
coverage: '/Coverage: \d+\.?\d*%/'
script: pytest --cov`,
},
RuleInvalidRelease: {
Title: "'release:' missing required 'tag_name:'",
Severity: Error,
Description: "'release:' must be a map and must include a 'tag_name:' key. " +
"Without it GitLab cannot create the release.",
Example: `release-job:
script: echo releasing
release:
name: My Release # missing tag_name:`,
Fix: `release-job:
script: echo releasing
release:
tag_name: $CI_COMMIT_TAG
name: My Release`,
},
RuleInvalidEnvironment: {
Title: "'environment:' invalid url or action",
Severity: Error,
Description: "'environment.url' requires 'environment.name' to be set. " +
"'environment.action' must be one of: start, stop, prepare, verify, access.",
Example: `my-job:
script: ./deploy.sh
environment:
url: https://prod.example.com # name: is required`,
Fix: `my-job:
script: ./deploy.sh
environment:
name: production
url: https://prod.example.com`,
},
RuleInvalidArtifacts: {
Title: "'artifacts:' invalid configuration",
Severity: Error,
Description: "'artifacts.when' must be on_success, on_failure, or always. " +
"'artifacts.expose_as' requires 'artifacts.paths' to be set.",
Example: `my-job:
script: make
artifacts:
when: sometimes # invalid value`,
Fix: `my-job:
script: make
artifacts:
when: on_failure
paths:
- build/logs/`,
},
RulePagesPublic: {
Title: "pages job missing 'public' in artifacts.paths",
Severity: Warning,
Description: "The 'pages' job must include 'public' (or a path starting with " +
"'public/') in 'artifacts.paths' for GitLab Pages to deploy the site.",
Example: `pages:
script: hugo
artifacts:
paths:
- dist/ # should include public/`,
Fix: `pages:
script: hugo --destination public
artifacts:
paths:
- public/`,
},
RuleInvalidCache: {
Title: "'cache:' invalid when or policy",
Severity: Error,
Description: "'cache.when' must be on_success, on_failure, or always. " +
"'cache.policy' must be pull, push, or pull-push.",
Example: `my-job:
script: npm ci
cache:
paths: [node_modules/]
policy: read-only # invalid; use pull`,
Fix: `my-job:
script: npm ci
cache:
paths: [node_modules/]
policy: pull`,
},
RuleInvalidRulesWhen: {
Title: "'rules[n].when:' has invalid value",
Severity: Error,
Description: "Inside a 'rules:' block, 'when:' must be one of: on_success, " +
"on_failure, always, manual, delayed, never. Other values are rejected.",
Example: `my-job:
rules:
- if: $CI_COMMIT_BRANCH
when: conditional # not valid here
script: echo hi`,
Fix: `my-job:
rules:
- if: $CI_COMMIT_BRANCH
when: on_success
script: echo hi`,
},
RuleInvalidImage: {
Title: "'image:' map form missing 'name:'",
Severity: Error,
Description: "When 'image:' is a map, it must include a 'name:' key specifying " +
"the Docker image. Without it GitLab cannot resolve which image to pull.",
Example: `my-job:
image:
entrypoint: ["/bin/sh"] # missing name:
script: echo hi`,
Fix: `my-job:
image:
name: alpine:3.19
entrypoint: ["/bin/sh"]
script: echo hi`,
},
RuleInvalidInherit: {
Title: "'inherit.default' or 'inherit.variables' invalid type",
Severity: Error,
Description: "'inherit.default' and 'inherit.variables' must each be a boolean " +
"(true/false) or a list of field/variable names. Other types are rejected.",
Example: `my-job:
inherit:
default: "no" # must be true/false or a list
script: echo hi`,
Fix: `my-job:
inherit:
default: false
script: echo hi`,
},
RuleNeedsUnknown: {
Title: "'needs:' references unknown job",
Severity: Error,
Description: "A job in the 'needs:' list does not exist in the pipeline. " +
"GitLab will refuse to create the pipeline.",
Example: `build:
stage: build
script: make
test:
stage: test
needs: [build, lint] # lint does not exist
script: make test`,
Fix: `lint:
stage: build
script: make lint
build:
stage: build
script: make
test:
stage: test
needs: [build, lint]
script: make test`,
},
RuleNeedsStageOrder: {
Title: "'needs:' job is in a later stage",
Severity: Error,
Description: "A job listed in 'needs:' is in a later stage than the current " +
"job. GitLab does not allow needs: to reference future stages.",
Example: `stages: [build, test, deploy]
test:
stage: test
needs: [deploy-job] # deploy-job is in a later stage
script: make test
deploy-job:
stage: deploy
script: ./deploy.sh`,
Fix: `stages: [build, test, deploy]
test:
stage: test
needs: [build-job]
script: make test
build-job:
stage: build
script: make`,
},
RuleNeedsCycle: {
Title: "circular dependency in 'needs:' graph",
Severity: Error,
Description: "Two or more jobs in the 'needs:' graph form a cycle — A needs B " +
"and B needs A (directly or transitively). GitLab will reject the pipeline.",
Example: `job-a:
stage: test
needs: [job-b]
script: echo a
job-b:
stage: test
needs: [job-a]
script: echo b`,
Fix: `job-a:
stage: test
script: echo a
job-b:
stage: test
needs: [job-a]
script: echo b`,
},
RuleUnknownDependency: {
Title: "'dependencies:' references unknown job",
Severity: Error,
Description: "A job listed in 'dependencies:' does not exist in the pipeline. " +
"GitLab will refuse to create the pipeline.",
Example: `test:
stage: test
dependencies: [build, missing-job]
script: make test`,
Fix: `build:
stage: build
script: make
artifacts:
paths: [dist/]
test:
stage: test
dependencies: [build]
script: make test`,
},
RuleDependencyStage: {
Title: "'dependencies:' job in same or later stage",
Severity: Error,
Description: "'dependencies:' can only reference jobs in earlier stages. " +
"Referencing a job in the same or a later stage is not allowed because " +
"artifacts would not yet be available.",
Example: `stages: [test, deploy]
test:
stage: test
dependencies: [deploy-job] # deploy is a later stage
script: make test
deploy-job:
stage: deploy
script: ./deploy.sh`,
Fix: `stages: [build, test, deploy]
build:
stage: build
script: make
artifacts:
paths: [dist/]
test:
stage: test
dependencies: [build]
script: make test`,
},
RuleUndeclaredVariable: {
Title: "'rules:if:' references undeclared variable",
Severity: Warning,
Description: "A 'rules:if:' expression references a variable that is not " +
"declared in any 'variables:' block in the pipeline YAML. This may be a " +
"typo, or the variable may be set in GitLab project settings (invisible to " +
"glint). Predefined CI_* and GITLAB_* variables are always exempt.",
Example: `variables:
APP_ENV: staging
my-job:
rules:
- if: $DEPLOY_ENV == "prod" # DEPLOY_ENV not declared
script: ./deploy.sh`,
Fix: `variables:
APP_ENV: staging
DEPLOY_ENV: staging # declare it, or set it in GitLab project settings
my-job:
rules:
- if: $DEPLOY_ENV == "prod"
script: ./deploy.sh`,
},
RuleDeadRules: {
Title: "rules: block has all 'when: never' — job permanently excluded",
Severity: Warning,
Description: "Every rule in the job's 'rules:' block has an explicit " +
"'when: never'. No matter which conditions match, the job will never " +
"be included in any pipeline run.",
Example: `my-job:
rules:
- if: $CI_COMMIT_BRANCH == "main"
when: never
- when: never
script: echo unreachable`,
Fix: `# Option 1: remove the job entirely
# Option 2: give at least one rule a non-never when:
my-job:
rules:
- if: $CI_COMMIT_BRANCH == "main"
when: on_success
- when: never
script: echo deploy`,
},
RuleInvalidService: {
Title: "'services:' map form missing 'name:' or invalid 'alias:'",
Severity: Error,
Description: "When a service entry is a map it must include a 'name:' key. " +
"'alias:' (if set) must be a valid DNS label: alphanumeric characters, " +
"hyphens, and dots only; must start and end with alphanumeric.",
Example: `my-job:
script: pytest
services:
- alias: my_db # underscore is not valid in a DNS label`,
Fix: `my-job:
script: pytest
services:
- name: postgres:15
alias: my-db`,
},
RuleAbsoluteGlobPath: {
Title: "'rules:changes' or 'rules:exists' uses absolute path",
Severity: Warning,
Description: "Paths in 'rules:changes' and 'rules:exists' are always relative " +
"to the repository root. An absolute path starting with '/' will never " +
"match any file and will silently disable the rule.",
Example: `my-job:
rules:
- changes:
- /src/**/*.go # absolute will never match
script: make`,
Fix: `my-job:
rules:
- changes:
- src/**/*.go
script: make`,
},
RuleInvalidTimeout: {
Title: "'timeout:' is not a valid duration string",
Severity: Error,
Description: "'timeout:' must be a GitLab CI duration string composed of " +
"recognised time units: weeks (w), days (d), hours (h), minutes (m/min), " +
"seconds (s). Examples: '1h 30m', '90 minutes', '2 hours'.",
Example: `my-job:
timeout: "1:30:00" # colon-separated format not supported
script: long-running-task.sh`,
Fix: `my-job:
timeout: 1h 30m
script: long-running-task.sh`,
},
RuleInvalidIDToken: {
Title: "'id_tokens:' entry missing required 'aud:' key",
Severity: Error,
Description: "Each entry under 'id_tokens:' must be a map with an 'aud:' " +
"(audience) key. Without 'aud:' GitLab cannot generate the ID token " +
"and the job will fail.",
Example: `my-job:
id_tokens:
MY_TOKEN:
# missing aud:
script: vault-login.sh`,
Fix: `my-job:
id_tokens:
MY_TOKEN:
aud: https://vault.example.com
script: vault-login.sh`,
},
RuleInvalidSecret: {
Title: "'secrets:' entry missing a provider key",
Severity: Error,
Description: "Each entry under 'secrets:' must specify a provider: one of " +
"'vault:', 'gcp_secret_manager:', or 'azure_key_vault:'. Without a " +
"provider GitLab cannot retrieve the secret.",
Example: `my-job:
secrets:
DB_PASSWORD:
path: secret/db/password # no provider key
script: run.sh`,
Fix: `my-job:
secrets:
DB_PASSWORD:
vault:
engine:
name: kv-v2
path: secret
path: db/password
field: password
script: run.sh`,
},
RulePagesPublish: {
Title: "'pages:' keyword publish directory missing from 'artifacts.paths'",
Severity: Warning,
Description: "A job using the 'pages:' keyword must include its publish " +
"directory in 'artifacts.paths'. Without this, GitLab Pages will not " +
"deploy the site.",
Example: `my-pages-job:
script: hugo
pages:
publish: public
artifacts:
paths:
- dist/ # missing public/`,
Fix: `my-pages-job:
script: hugo
pages:
publish: public
artifacts:
paths:
- public/`,
},
RuleDuplicateStage: {
Title: "duplicate stage name in 'stages:'",
Severity: Warning,
Description: "A stage name appears more than once in the 'stages:' list. " +
"GitLab silently merges duplicate stage entries, which can make the " +
"pipeline order confusing.",
Example: `stages:
- build
- test
- test # duplicate`,
Fix: `stages:
- build
- test`,
},
RuleInvalidCacheKeyFiles: {
Title: "'cache.key.files' contains a glob pattern",
Severity: Warning,
Description: "'cache.key.files' must be a list of exact file paths. Glob " +
"patterns (*, ?, [...]) are not expanded — the literal glob string is " +
"used as the cache key, which probably doesn't match your intent.",
Example: `my-job:
script: npm ci
cache:
key:
files:
- package*.json # glob use exact path`,
Fix: `my-job:
script: npm ci
cache:
key:
files:
- package.json
- package-lock.json`,
},
RuleStaticDeadRules: {
Title: "rules:if: block statically never activates",
Severity: Warning,
Description: "Every 'rules:if:' condition in this job evaluates to false " +
"using the variable values declared in the pipeline YAML. The job will " +
"never be included in any pipeline run. This check only fires when ALL " +
"referenced variables are declared in the YAML (predefined CI_* variables " +
"are not evaluated to avoid false positives).",
Example: `variables:
ENABLE_DEPLOY: "false"
deploy:
rules:
- if: '$ENABLE_DEPLOY == "true"' # always false: ENABLE_DEPLOY is "false"
script: ./deploy.sh`,
Fix: `variables:
ENABLE_DEPLOY: "true" # fix the variable value
deploy:
rules:
- if: '$ENABLE_DEPLOY == "true"'
script: ./deploy.sh
# Or add an unconditional fallback:
deploy:
rules:
- if: '$ENABLE_DEPLOY == "true"'
- when: manual # allow manual trigger as a fallback
script: ./deploy.sh`,
},
RuleInheritNoDefault: {
Title: "'inherit: default:' declared but no 'default:' block",
Severity: Warning,
Description: "A job declares 'inherit: default:' but the pipeline has no " +
"'default:' block, so the declaration has no effect. This can also fire " +
"when 'inherit: default: [list]' names fields that are not set in the " +
"'default:' block.",
Example: `# No default: block exists
my-job:
inherit:
default: false # no-op: nothing to opt out of
script: echo hi`,
Fix: `# Either add a default: block...
default:
before_script:
- source venv/bin/activate
my-job:
inherit:
default: false
script: echo hi
# ...or remove the pointless inherit: declaration
my-job:
script: echo hi`,
},
RuleRulesNeedsUnknown: {
Title: "'rules:needs:' references unknown job",
Severity: Error,
Description: "A job listed in a rule's 'needs:' override does not exist in " +
"the pipeline. 'rules:needs:' (GitLab CI 16.4+) overrides the top-level " +
"'needs:' list when that specific rule matches. GitLab will refuse to " +
"create the pipeline if any referenced job is missing.",
Example: `stages: [build, test]
build:
stage: build
script: make
test:
stage: test
script: make test
rules:
- if: $CI_COMMIT_BRANCH == "main"
needs: [build, lint] # lint does not exist`,
Fix: `stages: [build, test]
lint:
stage: build
script: make lint
build:
stage: build
script: make
test:
stage: test
script: make test
rules:
- if: $CI_COMMIT_BRANCH == "main"
needs: [build, lint]`,
},
RuleInsecureRemoteInclude: {
Title: "remote include uses plain HTTP",
Severity: Warning,
Description: "An include: remote: entry uses an http:// URL instead of https://. " +
"CI templates fetched over plain HTTP are transmitted in cleartext; an " +
"attacker with network access could intercept or modify the template " +
"before it is parsed. Use https:// so the connection is encrypted and " +
"the server's identity is verified.",
Example: `include:
- remote: http://ci-templates.example.com/build.yml`,
Fix: `include:
- remote: https://ci-templates.example.com/build.yml`,
},
}
+69
View File
@@ -0,0 +1,69 @@
package linter
import (
"testing"
"git.k3nny.fr/glint/internal/model"
)
// FuzzLint ensures that the full Parse → Lint pipeline never panics on
// arbitrary YAML input. Lint rules type-assert model fields extensively;
// this fuzzer drives those assertions against malformed-but-parseable YAML.
// Run with: go test -fuzz=FuzzLint ./internal/linter/
func FuzzLint(f *testing.F) {
seeds := []string{
// Minimal valid pipeline
"stages: [build]\njob:\n stage: build\n script: echo ok\n",
// Manual / delayed / trigger / on_failure job types
"job:\n script: ok\n when: manual\n",
"job:\n script: ok\n when: delayed\n start_in: 5 minutes\n",
"job:\n trigger:\n project: group/repo\n",
"job:\n script: ok\n when: on_failure\n",
// needs: and dependencies:
"stages: [a,b]\na:\n stage: a\n script: ok\nb:\n stage: b\n needs: [a]\n script: ok\n",
"stages: [a,b]\na:\n stage: a\n script: ok\nb:\n stage: b\n dependencies: [a]\n script: ok\n",
// rules:
"job:\n script: ok\n rules:\n - if: '$CI_COMMIT_BRANCH == \"main\"'\n when: on_success\n",
// parallel matrix
"job:\n script: ok\n parallel:\n matrix:\n - ARCH: [amd64, arm64]\n",
// image as string / map
"job:\n script: ok\n image: golang:1.21\n",
"job:\n script: ok\n image:\n name: golang:1.21\n entrypoint: ['']\n",
// artifacts / cache with both string and map when:
"job:\n script: ok\n artifacts:\n when: on_success\n paths: [dist/]\n",
"job:\n script: ok\n cache:\n key: $CI_COMMIT_REF_SLUG\n paths: [vendor/]\n",
// environment / release / coverage
"job:\n script: ok\n environment:\n name: production\n url: https://example.com\n",
"job:\n script: ok\n release:\n tag_name: $CI_COMMIT_TAG\n description: Release\n",
"job:\n script: ok\n coverage: '/^TOTAL.*?(\\d+%)$/'\n",
// retry / timeout
"job:\n script: ok\n retry: 2\n",
"job:\n script: ok\n timeout: 2h30m\n",
// workflow
"workflow:\n rules:\n - if: '$CI_COMMIT_BRANCH'\n when: always\njob:\n script: ok\n",
// extends
".base:\n script: ok\nchild:\n extends: .base\n stage: test\n",
// id_tokens / secrets
"job:\n script: ok\n id_tokens:\n TOKEN:\n aud: https://example.com\n",
// services
"job:\n script: ok\n services:\n - name: postgres:14\n alias: db\n",
// pages job
"pages:\n script: make docs\n artifacts:\n paths: [public/]\n",
// inherit
"default:\n retry: 1\njob:\n script: ok\n inherit:\n default: false\n",
// allow_failure
"job:\n script: ok\n allow_failure:\n exit_codes: [1, 2]\n",
}
for _, s := range seeds {
f.Add([]byte(s))
}
f.Fuzz(func(t *testing.T, data []byte) {
p, err := model.ParseBytes(data)
if err != nil {
return
}
// Lint must never panic regardless of pipeline content.
_ = Lint(p, nil)
})
}
+105
View File
@@ -0,0 +1,105 @@
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,
Column: job.Column,
Message: "rules: block can never activate: all if: conditions evaluate to false given the declared pipeline variables",
}
}
+161
View File
@@ -0,0 +1,161 @@
package linter
import (
"testing"
"git.k3nny.fr/glint/internal/model"
)
func TestCheckRulesIfReachability(t *testing.T) {
makeJob := func(vars map[string]any, rules []model.Rule) model.Job {
return model.Job{Variables: vars, Rules: rules}
}
cases := []struct {
name string
pipelineVars map[string]any
jobs map[string]model.Job
wantHit []string // job names that should produce GL042
wantMiss []string // job names that should NOT produce GL042
}{
{
name: "declared var always false — GL042 fires",
pipelineVars: map[string]any{"DEPLOY": "false"},
jobs: map[string]model.Job{
"deploy": makeJob(nil, []model.Rule{
{If: `$DEPLOY == "true"`, When: "on_success"},
}),
},
wantHit: []string{"deploy"},
wantMiss: nil,
},
{
name: "declared var matches — no finding",
pipelineVars: map[string]any{"DEPLOY": "true"},
jobs: map[string]model.Job{
"deploy": makeJob(nil, []model.Rule{
{If: `$DEPLOY == "true"`, When: "on_success"},
}),
},
wantHit: nil,
wantMiss: []string{"deploy"},
},
{
name: "predefined CI_ var — skip check conservatively",
pipelineVars: nil,
jobs: map[string]model.Job{
"branch-job": makeJob(nil, []model.Rule{
{If: `$CI_COMMIT_BRANCH == "never-exists"`, When: "on_success"},
}),
},
wantHit: nil,
wantMiss: []string{"branch-job"},
},
{
name: "undeclared var — skip check conservatively",
pipelineVars: nil,
jobs: map[string]model.Job{
"my-job": makeJob(nil, []model.Rule{
{If: `$UNKNOWN_VAR == "x"`, When: "on_success"},
}),
},
wantHit: nil,
wantMiss: []string{"my-job"},
},
{
name: "unconditional rule — job can always activate",
pipelineVars: map[string]any{"DEPLOY": "false"},
jobs: map[string]model.Job{
"my-job": makeJob(nil, []model.Rule{
{If: `$DEPLOY == "true"`, When: "never"},
{When: "on_success"},
}),
},
wantHit: nil,
wantMiss: []string{"my-job"},
},
{
name: "all rules when:never — GL042 fires (not GL033 territory overlap)",
pipelineVars: map[string]any{"DEPLOY": "false"},
jobs: map[string]model.Job{
"my-job": makeJob(nil, []model.Rule{
{If: `$DEPLOY == "true"`, When: "on_success"},
{When: "never"},
}),
},
// Rule 1 evaluates to false; Rule 2 is explicit never → all dead.
wantHit: []string{"my-job"},
wantMiss: nil,
},
{
name: "job-level var overrides pipeline var",
pipelineVars: map[string]any{"FLAG": "false"},
jobs: map[string]model.Job{
"my-job": makeJob(map[string]any{"FLAG": "true"}, []model.Rule{
{If: `$FLAG == "true"`, When: "on_success"},
}),
},
// Job-level FLAG="true" makes the if: evaluate to true → no finding.
wantHit: nil,
wantMiss: []string{"my-job"},
},
{
name: "no rules — nothing to check",
pipelineVars: map[string]any{"DEPLOY": "false"},
jobs: map[string]model.Job{
"my-job": makeJob(nil, nil),
},
wantHit: nil,
wantMiss: []string{"my-job"},
},
{
name: "boolean variable evaluates correctly",
pipelineVars: map[string]any{"FLAG": false}, // bool false
jobs: map[string]model.Job{
"my-job": makeJob(nil, []model.Rule{
{If: `$FLAG == "true"`, When: "on_success"},
}),
},
// ScalarString(false) → "false"; "false" == "true" → false → GL042 fires.
wantHit: []string{"my-job"},
wantMiss: nil,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
p := &model.Pipeline{
Variables: tc.pipelineVars,
Jobs: tc.jobs,
}
findings := checkRulesIfReachability(p)
hitSet := make(map[string]bool)
for _, f := range findings {
if f.Rule == RuleStaticDeadRules {
hitSet[f.Job] = true
}
}
for _, want := range tc.wantHit {
if !hitSet[want] {
t.Errorf("expected GL042 for job %q but it was not reported; findings: %v", want, findings)
}
}
for _, notWant := range tc.wantMiss {
if hitSet[notWant] {
t.Errorf("unexpected GL042 for job %q", notWant)
}
}
})
}
}
// TestCheckRulesIfReachability_EmptyPipeline covers the early return (line 16-18)
// when the pipeline has no jobs.
func TestCheckRulesIfReachability_EmptyPipeline(t *testing.T) {
findings := checkRulesIfReachability(&model.Pipeline{Jobs: map[string]model.Job{}})
if len(findings) != 0 {
t.Errorf("empty pipeline: expected no findings, got %v", findings)
}
}
+31
View File
@@ -0,0 +1,31 @@
package linter
import (
"fmt"
"strings"
"git.k3nny.fr/glint/internal/model"
)
// checkInsecureRemoteInclude warns when an include: remote: entry uses plain
// HTTP (GL045). The file is still fetched and linted; the finding is a warning
// so pipelines that use HTTP for internal infra are not blocked.
func checkInsecureRemoteInclude(p *model.Pipeline) []Finding {
var findings []Finding
for _, inc := range p.Include {
m, ok := inc.(map[string]any)
if !ok {
continue
}
remote, _ := m["remote"].(string)
if strings.HasPrefix(remote, "http://") {
findings = append(findings, Finding{
Severity: Warning,
Rule: RuleInsecureRemoteInclude,
File: p.SourceFile,
Message: fmt.Sprintf("remote include %q uses plain HTTP; CI templates are fetched unencrypted — prefer HTTPS", remote),
})
}
}
return findings
}
+73
View File
@@ -0,0 +1,73 @@
package linter
import (
"testing"
"git.k3nny.fr/glint/internal/model"
)
func TestCheckInsecureRemoteInclude(t *testing.T) {
tests := []struct {
name string
include []any
wantGL string // expected rule ID, empty = no findings
}{
{
name: "http URL triggers GL045",
include: []any{map[string]any{"remote": "http://example.com/ci.yml"}},
wantGL: RuleInsecureRemoteInclude,
},
{
name: "https URL is clean",
include: []any{map[string]any{"remote": "https://example.com/ci.yml"}},
},
{
name: "local include ignored",
include: []any{map[string]any{"local": "/templates/ci.yml"}},
},
{
name: "string include ignored",
include: []any{"/templates/ci.yml"},
},
{
name: "no includes",
include: nil,
},
{
name: "mixed: http fires, https does not",
include: []any{
map[string]any{"remote": "https://ok.example.com/ci.yml"},
map[string]any{"remote": "http://bad.example.com/ci.yml"},
},
wantGL: RuleInsecureRemoteInclude,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
p := &model.Pipeline{
Include: tc.include,
Jobs: map[string]model.Job{},
}
findings := checkInsecureRemoteInclude(p)
if tc.wantGL == "" {
if len(findings) != 0 {
t.Errorf("expected no findings, got: %v", findings)
}
return
}
found := false
for _, f := range findings {
if f.Rule == tc.wantGL {
found = true
if f.Severity != Warning {
t.Errorf("severity = %v; want Warning", f.Severity)
}
}
}
if !found {
t.Errorf("expected finding %s, got: %v", tc.wantGL, findings)
}
})
}
}
+114
View File
@@ -0,0 +1,114 @@
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,
Column: job.Column,
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,
Column: job.Column,
Message: fmt.Sprintf(
"'inherit: default: [%s]': %s not defined in the 'default:' block — %s",
strings.Join(dead, ", "),
pluralIs(len(dead)),
"these entries have no effect",
),
}}
}
// defaultBlockHasField reports whether the given field name has a non-zero value
// in the default: block. Unknown field names return true (conservative).
func defaultBlockHasField(d *model.DefaultConfig, field string) bool {
switch field {
case "image":
return d.Image != nil
case "before_script":
return d.BeforeScript != nil
case "after_script":
return d.AfterScript != nil
case "cache":
return d.Cache != nil
case "artifacts":
return d.Artifacts != nil
case "retry":
return d.Retry != nil
case "timeout":
return d.Timeout != ""
case "tags":
return len(d.Tags) > 0
}
// Unknown field name — conservatively assume it might be defined.
return true
}
func pluralIs(n int) string {
if n == 1 {
return "this field is"
}
return "these fields are"
}
@@ -0,0 +1,165 @@
package linter
import (
"testing"
"git.k3nny.fr/glint/internal/model"
)
func TestCheckInheritCompleteness(t *testing.T) {
cases := []struct {
name string
default_ *model.DefaultConfig
inherit any // job.Inherit value
wantHit bool
}{
{
name: "no inherit — no finding",
inherit: nil,
wantHit: false,
},
{
name: "inherit: default: false, no default block — GL043",
inherit: map[string]any{"default": false},
wantHit: true,
},
{
name: "inherit: default: true, no default block — GL043",
inherit: map[string]any{"default": true},
wantHit: true,
},
{
name: "inherit: default: [image], no default block — GL043",
inherit: map[string]any{"default": []any{"image"}},
wantHit: true,
},
{
name: "inherit: default: false, default block exists — no finding",
default_: &model.DefaultConfig{Image: "node:20"},
inherit: map[string]any{"default": false},
wantHit: false,
},
{
name: "inherit: default: [image], image in default — no finding",
default_: &model.DefaultConfig{Image: "node:20"},
inherit: map[string]any{"default": []any{"image"}},
wantHit: false,
},
{
name: "inherit: default: [before_script], before_script NOT in default — GL043",
default_: &model.DefaultConfig{Image: "node:20"},
inherit: map[string]any{"default": []any{"before_script"}},
wantHit: true,
},
{
name: "inherit: default: [image, before_script], only image in default — GL043 for before_script",
default_: &model.DefaultConfig{Image: "node:20"},
inherit: map[string]any{"default": []any{"image", "before_script"}},
wantHit: true,
},
{
name: "inherit: variables only — no default check",
default_: nil,
inherit: map[string]any{"variables": false},
wantHit: false,
},
{
name: "inherit: unknown field in list — conservative, no finding",
default_: &model.DefaultConfig{Image: "node:20"},
inherit: map[string]any{"default": []any{"nonexistent_field"}},
wantHit: false, // unknown fields return true from defaultBlockHasField
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
job := model.Job{Inherit: tc.inherit}
p := &model.Pipeline{
Default: tc.default_,
Jobs: map[string]model.Job{"test-job": job},
}
findings := checkInheritCompleteness(p)
hit := false
for _, f := range findings {
if f.Rule == RuleInheritNoDefault {
hit = true
}
}
if hit != tc.wantHit {
t.Errorf("GL043 hit=%v, want=%v; findings: %v", hit, tc.wantHit, findings)
}
})
}
}
// TestCheckInheritCompleteness_BoolInherit verifies that a scalar (non-map)
// inherit value (e.g. inherit: true) is silently ignored.
func TestCheckInheritCompleteness_BoolInherit(t *testing.T) {
job := model.Job{Inherit: true}
p := &model.Pipeline{
Default: nil,
Jobs: map[string]model.Job{"job": job},
}
if findings := checkInheritCompleteness(p); len(findings) != 0 {
t.Errorf("bool inherit should produce no findings; got %v", findings)
}
}
// TestCheckInheritCompleteness_ListItemNotString verifies that non-string items
// in the inherit: default: list are skipped.
func TestCheckInheritCompleteness_ListItemNotString(t *testing.T) {
// The list contains 42 (int) and "image" (string). Only "image" should be checked.
job := model.Job{Inherit: map[string]any{"default": []any{42, "image"}}}
p := &model.Pipeline{
Default: &model.DefaultConfig{Image: "node:20"},
Jobs: map[string]model.Job{"job": job},
}
// "image" IS set in default → no findings.
if findings := checkInheritCompleteness(p); len(findings) != 0 {
t.Errorf("non-string item should be skipped; got %v", findings)
}
}
// TestDefaultBlockHasField_AllFields exercises every field branch in
// defaultBlockHasField, including the ones not covered by the main table test.
func TestDefaultBlockHasField_AllFields(t *testing.T) {
cases := []struct {
name string
d *model.DefaultConfig
field string
want bool
}{
{"after_script set", &model.DefaultConfig{AfterScript: []any{"echo"}}, "after_script", true},
{"after_script nil", &model.DefaultConfig{}, "after_script", false},
{"cache set", &model.DefaultConfig{Cache: map[string]any{"key": "main"}}, "cache", true},
{"cache nil", &model.DefaultConfig{}, "cache", false},
{"artifacts set", &model.DefaultConfig{Artifacts: map[string]any{"paths": []any{"dist/"}}}, "artifacts", true},
{"artifacts nil", &model.DefaultConfig{}, "artifacts", false},
{"retry set", &model.DefaultConfig{Retry: 2}, "retry", true},
{"retry nil", &model.DefaultConfig{}, "retry", false},
{"timeout set", &model.DefaultConfig{Timeout: "30m"}, "timeout", true},
{"timeout empty", &model.DefaultConfig{}, "timeout", false},
{"tags set", &model.DefaultConfig{Tags: []string{"docker"}}, "tags", true},
{"tags empty", &model.DefaultConfig{}, "tags", false},
{"unknown field", &model.DefaultConfig{}, "unknown_field", true}, // conservative
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := defaultBlockHasField(tc.d, tc.field)
if got != tc.want {
t.Errorf("defaultBlockHasField(%q) = %v, want %v", tc.field, got, tc.want)
}
})
}
}
// TestPluralIs covers both branches of pluralIs.
func TestPluralIs(t *testing.T) {
if pluralIs(1) != "this field is" {
t.Errorf("pluralIs(1) = %q, want 'this field is'", pluralIs(1))
}
if pluralIs(2) != "these fields are" {
t.Errorf("pluralIs(2) = %q, want 'these fields are'", pluralIs(2))
}
}
+613
View File
@@ -0,0 +1,613 @@
package linter
import (
"testing"
"git.k3nny.fr/glint/internal/model"
)
// ── checkWhen ─────────────────────────────────────────────────────────────────
func TestCheckWhen(t *testing.T) {
cases := []struct {
name string
job model.Job
wantN int
wantRule string
}{
{"valid when", model.Job{When: "on_success"}, 0, ""},
{"invalid when", model.Job{When: "bad_value"}, 1, RuleInvalidWhen},
{"delayed requires start_in", model.Job{When: "delayed"}, 1, RuleDelayedNoStartIn},
{"delayed with start_in", model.Job{When: "delayed", StartIn: "30 minutes"}, 0, ""},
{"start_in without delayed", model.Job{When: "on_success", StartIn: "5m"}, 1, RuleStartInNoDelayed},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := checkWhen("j", tc.job)
if len(got) != tc.wantN {
t.Errorf("got %d findings want %d: %v", len(got), tc.wantN, got)
}
if tc.wantRule != "" && len(got) > 0 && got[0].Rule != tc.wantRule {
t.Errorf("rule: got %q want %q", got[0].Rule, tc.wantRule)
}
})
}
}
// ── checkParallel ─────────────────────────────────────────────────────────────
func TestCheckParallel(t *testing.T) {
cases := []struct {
name string
job model.Job
wantN int
}{
{"nil parallel", model.Job{}, 0},
{"valid int", model.Job{Parallel: 4}, 0},
{"int too low", model.Job{Parallel: 1}, 1},
{"int too high", model.Job{Parallel: 201}, 1},
{"map with matrix", model.Job{Parallel: map[string]any{"matrix": []any{}}}, 0},
{"map without matrix", model.Job{Parallel: map[string]any{"other": true}}, 1},
{"invalid type", model.Job{Parallel: "string"}, 1},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := checkParallel("j", tc.job)
if len(got) != tc.wantN {
t.Errorf("got %d findings want %d", len(got), tc.wantN)
}
})
}
}
// ── checkRetry ────────────────────────────────────────────────────────────────
func TestCheckRetry(t *testing.T) {
cases := []struct {
name string
job model.Job
wantN int
}{
{"nil retry", model.Job{}, 0},
{"valid int 0", model.Job{Retry: 0}, 0},
{"valid int 2", model.Job{Retry: 2}, 0},
{"invalid int -1", model.Job{Retry: -1}, 1},
{"invalid int 3", model.Job{Retry: 3}, 1},
{"map with valid max", model.Job{Retry: map[string]any{"max": 1}}, 0},
{"map with invalid max", model.Job{Retry: map[string]any{"max": 5}}, 1},
{"map with valid when string", model.Job{Retry: map[string]any{"when": "always"}}, 0},
{"map with invalid when string", model.Job{Retry: map[string]any{"when": "bad_reason"}}, 1},
{"map with when slice", model.Job{Retry: map[string]any{"when": []any{"always", "script_failure"}}}, 0},
{"map with when slice invalid", model.Job{Retry: map[string]any{"when": []any{"bad_reason"}}}, 1},
{"invalid type", model.Job{Retry: "string"}, 1},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := checkRetry("j", tc.job)
if len(got) != tc.wantN {
t.Errorf("got %d findings want %d: %v", len(got), tc.wantN, got)
}
})
}
}
// ── checkAllowFailure ─────────────────────────────────────────────────────────
func TestCheckAllowFailure(t *testing.T) {
cases := []struct {
name string
job model.Job
wantN int
}{
{"nil", model.Job{}, 0},
{"bool true", model.Job{Allow: true}, 0},
{"bool false", model.Job{Allow: false}, 0},
{"map with exit_codes", model.Job{Allow: map[string]any{"exit_codes": []any{1, 2}}}, 0},
{"map without exit_codes", model.Job{Allow: map[string]any{"other": true}}, 1},
{"invalid type", model.Job{Allow: "yes"}, 1},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := checkAllowFailure("j", tc.job)
if len(got) != tc.wantN {
t.Errorf("got %d findings want %d", len(got), tc.wantN)
}
})
}
}
// ── checkInterruptible ────────────────────────────────────────────────────────
func TestCheckInterruptible(t *testing.T) {
if len(checkInterruptible("j", model.Job{})) != 0 { t.Error("nil: expected clean") }
if len(checkInterruptible("j", model.Job{Interruptible: true})) != 0 { t.Error("bool true: expected clean") }
if len(checkInterruptible("j", model.Job{Interruptible: false})) != 0 { t.Error("bool false: expected clean") }
if len(checkInterruptible("j", model.Job{Interruptible: "yes"})) != 1 { t.Error("string: expected error") }
}
// ── checkTrigger ──────────────────────────────────────────────────────────────
func TestCheckTrigger(t *testing.T) {
cases := []struct {
name string
job model.Job
wantN int
}{
{"nil trigger", model.Job{}, 0},
{"string trigger", model.Job{Trigger: "other/project"}, 0},
{"trigger with script", model.Job{Trigger: "x", Script: []any{"echo"}}, 1},
{"map trigger with project", model.Job{Trigger: map[string]any{"project": "g/p"}}, 0},
{"map trigger with include", model.Job{Trigger: map[string]any{"include": "ci.yml"}}, 0},
{"map trigger missing both", model.Job{Trigger: map[string]any{"strategy": "depend"}}, 1},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := checkTrigger("j", tc.job)
if len(got) != tc.wantN {
t.Errorf("got %d findings want %d: %v", len(got), tc.wantN, got)
}
})
}
}
// ── checkCoverage ─────────────────────────────────────────────────────────────
func TestCheckCoverage(t *testing.T) {
if len(checkCoverage("j", model.Job{})) != 0 { t.Error("empty: clean") }
if len(checkCoverage("j", model.Job{Coverage: `/\d+%/`})) != 0 { t.Error("valid: clean") }
if len(checkCoverage("j", model.Job{Coverage: "bad"})) != 1 { t.Error("missing slashes: error") }
}
// ── checkRelease ──────────────────────────────────────────────────────────────
func TestCheckRelease(t *testing.T) {
if len(checkRelease("j", model.Job{})) != 0 { t.Error("nil: clean") }
if len(checkRelease("j", model.Job{Release: "string"})) != 1 { t.Error("string: error") }
if len(checkRelease("j", model.Job{Release: map[string]any{"tag_name": "v1"}})) != 0 { t.Error("valid map: clean") }
if len(checkRelease("j", model.Job{Release: map[string]any{"description": "x"}})) != 1 { t.Error("no tag_name: error") }
if len(checkRelease("j", model.Job{Release: map[string]any{"tag_name": ""}})) != 1 { t.Error("empty tag_name: error") }
if len(checkRelease("j", model.Job{Release: map[string]any{"tag_name": nil}})) != 1 { t.Error("nil tag_name: error") }
}
// ── checkEnvironment ──────────────────────────────────────────────────────────
func TestCheckEnvironment(t *testing.T) {
if len(checkEnvironment("j", model.Job{})) != 0 { t.Error("nil: clean") }
if len(checkEnvironment("j", model.Job{Environment: "production"})) != 0 { t.Error("string: clean") }
if len(checkEnvironment("j", model.Job{Environment: map[string]any{"name": "prod"}})) != 0 { t.Error("map with name: clean") }
// url without name
if len(checkEnvironment("j", model.Job{Environment: map[string]any{"url": "https://x.com"}})) != 1 { t.Error("url no name: error") }
// invalid action
if len(checkEnvironment("j", model.Job{Environment: map[string]any{"name": "prod", "action": "badaction"}})) != 1 { t.Error("bad action: error") }
// valid action
if len(checkEnvironment("j", model.Job{Environment: map[string]any{"name": "prod", "action": "stop"}})) != 0 { t.Error("stop action: clean") }
}
// ── checkArtifacts ────────────────────────────────────────────────────────────
func TestCheckArtifacts(t *testing.T) {
if len(checkArtifacts("j", model.Job{})) != 0 { t.Error("nil: clean") }
// non-map type: no-op
if len(checkArtifacts("j", model.Job{Artifacts: "string"})) != 0 { t.Error("string: clean") }
// valid when
if len(checkArtifacts("j", model.Job{Artifacts: map[string]any{"when": "on_failure"}})) != 0 { t.Error("valid when: clean") }
// invalid when
if len(checkArtifacts("j", model.Job{Artifacts: map[string]any{"when": "bad_when"}})) != 1 { t.Error("bad when: error") }
// expose_as without paths
if len(checkArtifacts("j", model.Job{Artifacts: map[string]any{"expose_as": "Coverage"}})) != 1 { t.Error("expose_as no paths: error") }
// expose_as with paths
if len(checkArtifacts("j", model.Job{Artifacts: map[string]any{"expose_as": "X", "paths": []any{"out/"}}})) != 0 { t.Error("expose_as with paths: clean") }
// pages job without public in paths
if len(checkArtifacts("pages", model.Job{Name: "pages", Artifacts: map[string]any{"paths": []any{"dist/"}}})) != 1 { t.Error("pages without public: warning") }
// pages job with public in paths
if len(checkArtifacts("pages", model.Job{Name: "pages", Artifacts: map[string]any{"paths": []any{"public"}}})) != 0 { t.Error("pages with public: clean") }
// pages job with pages: keyword set — GL033 check skipped
if len(checkArtifacts("pages", model.Job{Pages: map[string]any{}, Artifacts: map[string]any{"paths": []any{"dist/"}}})) != 0 { t.Error("pages with pages keyword: clean") }
}
// ── checkCache ────────────────────────────────────────────────────────────────
func TestCheckCache(t *testing.T) {
if len(checkCache("j", model.Job{})) != 0 { t.Error("nil: clean") }
// map form valid
if len(checkCache("j", model.Job{Cache: map[string]any{"key": "abc"}})) != 0 { t.Error("map valid: clean") }
// map form invalid when
if len(checkCache("j", model.Job{Cache: map[string]any{"when": "bad"}})) != 1 { t.Error("invalid when: error") }
// map form invalid policy
if len(checkCache("j", model.Job{Cache: map[string]any{"policy": "bad_policy"}})) != 1 { t.Error("invalid policy: error") }
// slice form
if len(checkCache("j", model.Job{Cache: []any{
map[string]any{"when": "bad"},
map[string]any{"policy": "pull"},
}})) != 1 { t.Error("slice with one bad: error") }
}
// ── checkRules ────────────────────────────────────────────────────────────────
func TestCheckRules(t *testing.T) {
if len(checkRules("j", model.Job{})) != 0 { t.Error("no rules: clean") }
if len(checkRules("j", model.Job{Rules: []model.Rule{{When: "on_success"}}})) != 0 { t.Error("valid when: clean") }
if len(checkRules("j", model.Job{Rules: []model.Rule{{When: "bad_when"}}})) != 1 { t.Error("bad when: error") }
}
// ── checkImage ────────────────────────────────────────────────────────────────
func TestCheckImage(t *testing.T) {
if len(checkImage("j", model.Job{})) != 0 { t.Error("nil: clean") }
if len(checkImage("j", model.Job{Image: "alpine"})) != 0 { t.Error("string: clean") }
if len(checkImage("j", model.Job{Image: map[string]any{"name": "alpine"}})) != 0 { t.Error("map with name: clean") }
if len(checkImage("j", model.Job{Image: map[string]any{"pull_policy": "always"}})) != 1 { t.Error("map without name: error") }
}
// ── checkInherit ──────────────────────────────────────────────────────────────
func TestCheckInherit(t *testing.T) {
if len(checkInherit("j", model.Job{})) != 0 { t.Error("nil: clean") }
if len(checkInherit("j", model.Job{Inherit: "string"})) != 0 { t.Error("non-map: clean") }
if len(checkInherit("j", model.Job{Inherit: map[string]any{"default": true}})) != 0 { t.Error("bool: clean") }
if len(checkInherit("j", model.Job{Inherit: map[string]any{"default": []any{"image"}}})) != 0 { t.Error("list: clean") }
if len(checkInherit("j", model.Job{Inherit: map[string]any{"variables": "string"}})) != 1 { t.Error("invalid type: error") }
}
// ── checkServices ─────────────────────────────────────────────────────────────
func TestCheckServices(t *testing.T) {
if len(checkServices("j", model.Job{})) != 0 { t.Error("nil: clean") }
if len(checkServices("j", model.Job{Services: []any{"postgres"}})) != 0 { t.Error("string: clean") }
if len(checkServices("j", model.Job{Services: []any{map[string]any{"name": "pg"}}})) != 0 { t.Error("valid map: clean") }
if len(checkServices("j", model.Job{Services: []any{map[string]any{"port": 5432}}})) != 1 { t.Error("no name: error") }
if len(checkServices("j", model.Job{Services: []any{map[string]any{"name": "pg", "alias": "invalid alias!"}}})) != 1 { t.Error("bad alias: error") }
if len(checkServices("j", model.Job{Services: []any{map[string]any{"name": "pg", "alias": "valid-alias"}}})) != 0 { t.Error("good alias: clean") }
}
// ── checkRulesGlobs ───────────────────────────────────────────────────────────
func TestCheckRulesGlobs(t *testing.T) {
if len(checkRulesGlobs("j", model.Job{})) != 0 { t.Error("no rules: clean") }
// Relative path: clean
relRule := model.Rule{Changes: []any{"src/**/*.go"}}
if len(checkRulesGlobs("j", model.Job{Rules: []model.Rule{relRule}})) != 0 { t.Error("relative: clean") }
// Absolute path: warning
absRule := model.Rule{Changes: []any{"/absolute/path"}}
if len(checkRulesGlobs("j", model.Job{Rules: []model.Rule{absRule}})) != 1 { t.Error("absolute: warning") }
// Map form with paths
mapRule := model.Rule{Changes: map[string]any{"paths": []any{"/abs"}}}
if len(checkRulesGlobs("j", model.Job{Rules: []model.Rule{mapRule}})) != 1 { t.Error("map absolute: warning") }
// exists
existsRule := model.Rule{Exists: []any{"/bad"}}
if len(checkRulesGlobs("j", model.Job{Rules: []model.Rule{existsRule}})) != 1 { t.Error("exists absolute: warning") }
}
// ── checkTimeout ─────────────────────────────────────────────────────────────
func TestCheckTimeout(t *testing.T) {
if len(checkTimeout("j", model.Job{})) != 0 { t.Error("empty: clean") }
if len(checkTimeout("j", model.Job{Timeout: "1h 30m"})) != 0 { t.Error("valid: clean") }
if len(checkTimeout("j", model.Job{Timeout: "90 minutes"})) != 0 { t.Error("valid2: clean") }
if len(checkTimeout("j", model.Job{Timeout: "not-a-duration"})) != 1 { t.Error("invalid: error") }
}
func TestCheckDefaultTimeout(t *testing.T) {
if len(checkDefaultTimeout("", "f.yml")) != 0 { t.Error("empty: clean") }
if len(checkDefaultTimeout("2 hours", "f.yml")) != 0 { t.Error("valid: clean") }
if len(checkDefaultTimeout("bad", "f.yml")) != 1 { t.Error("invalid: error") }
}
// ── checkIDTokens ─────────────────────────────────────────────────────────────
func TestCheckIDTokens(t *testing.T) {
if len(checkIDTokens("j", model.Job{})) != 0 { t.Error("nil: clean") }
if len(checkIDTokens("j", model.Job{IDTokens: "string"})) != 0 { t.Error("non-map: clean") }
// valid token with aud
if len(checkIDTokens("j", model.Job{IDTokens: map[string]any{
"MY_TOKEN": map[string]any{"aud": "https://example.com"},
}})) != 0 { t.Error("valid: clean") }
// missing aud
if len(checkIDTokens("j", model.Job{IDTokens: map[string]any{
"MY_TOKEN": map[string]any{"other": "x"},
}})) != 1 { t.Error("missing aud: error") }
// not a map
if len(checkIDTokens("j", model.Job{IDTokens: map[string]any{
"MY_TOKEN": "string",
}})) != 1 { t.Error("token not a map: error") }
}
// ── checkSecrets ─────────────────────────────────────────────────────────────
func TestCheckSecrets(t *testing.T) {
if len(checkSecrets("j", model.Job{})) != 0 { t.Error("nil: clean") }
if len(checkSecrets("j", model.Job{Secrets: "string"})) != 0 { t.Error("non-map: clean") }
// valid vault
if len(checkSecrets("j", model.Job{Secrets: map[string]any{
"MY_SECRET": map[string]any{"vault": map[string]any{"engine": map[string]any{"name": "kv", "path": "x"}, "path": "y", "field": "z"}},
}})) != 0 { t.Error("vault: clean") }
// missing provider
if len(checkSecrets("j", model.Job{Secrets: map[string]any{
"MY_SECRET": map[string]any{"other": "x"},
}})) != 1 { t.Error("missing provider: error") }
// not a map
if len(checkSecrets("j", model.Job{Secrets: map[string]any{
"MY_SECRET": "string",
}})) != 1 { t.Error("not a map: error") }
}
// ── checkPagesKeyword ─────────────────────────────────────────────────────────
func TestCheckPagesKeyword(t *testing.T) {
if len(checkPagesKeyword("j", model.Job{})) != 0 { t.Error("nil pages: clean") }
// pages keyword but no artifacts
if len(checkPagesKeyword("j", model.Job{Pages: map[string]any{}})) != 1 { t.Error("no artifacts: warning") }
// pages keyword with artifacts but no paths
if len(checkPagesKeyword("j", model.Job{
Pages: map[string]any{},
Artifacts: map[string]any{},
})) != 1 { t.Error("no paths: warning") }
// pages with public in paths
if len(checkPagesKeyword("j", model.Job{
Pages: map[string]any{},
Artifacts: map[string]any{"paths": []any{"public"}},
})) != 0 { t.Error("public in paths: clean") }
// pages with custom publish dir and matching path
if len(checkPagesKeyword("j", model.Job{
Pages: map[string]any{"publish": "dist"},
Artifacts: map[string]any{"paths": []any{"dist"}},
})) != 0 { t.Error("custom publish match: clean") }
// pages with custom publish dir missing from paths
if len(checkPagesKeyword("j", model.Job{
Pages: map[string]any{"publish": "dist"},
Artifacts: map[string]any{"paths": []any{"public"}},
})) != 1 { t.Error("custom publish missing: warning") }
// artifacts is a non-map (unexpected)
if len(checkPagesKeyword("j", model.Job{
Pages: map[string]any{},
Artifacts: "string",
})) != 0 { t.Error("non-map artifacts: clean") }
}
// ── checkCacheKeyFiles ────────────────────────────────────────────────────────
func TestCheckCacheKeyFiles(t *testing.T) {
if len(checkCacheKeyFiles("j", model.Job{})) != 0 { t.Error("nil: clean") }
// valid key.files
if len(checkCacheKeyFiles("j", model.Job{Cache: map[string]any{
"key": map[string]any{"files": []any{"Gemfile.lock"}},
}})) != 0 { t.Error("valid: clean") }
// glob pattern in key.files
if len(checkCacheKeyFiles("j", model.Job{Cache: map[string]any{
"key": map[string]any{"files": []any{"*.lock"}},
}})) != 1 { t.Error("glob: warning") }
// files is not a list
if len(checkCacheKeyFiles("j", model.Job{Cache: map[string]any{
"key": map[string]any{"files": "Gemfile.lock"},
}})) != 1 { t.Error("non-list: error") }
// no key
if len(checkCacheKeyFiles("j", model.Job{Cache: map[string]any{}})) != 0 { t.Error("no key: clean") }
// key is not a map
if len(checkCacheKeyFiles("j", model.Job{Cache: map[string]any{"key": "string"}})) != 0 { t.Error("key string: clean") }
// slice cache form
if len(checkCacheKeyFiles("j", model.Job{Cache: []any{
map[string]any{"key": map[string]any{"files": []any{"*.lock"}}},
}})) != 1 { t.Error("slice cache with glob: warning") }
}
// ── linter.go level checks ────────────────────────────────────────────────────
func TestCheckStages(t *testing.T) {
// no stages → warning
p := &model.Pipeline{}
if len(checkStages(p)) != 1 { t.Error("no stages: warning") }
// with stages → clean
p2 := &model.Pipeline{Stages: []string{"build"}}
if len(checkStages(p2)) != 0 { t.Error("with stages: clean") }
}
func TestCheckDuplicateStages(t *testing.T) {
p := &model.Pipeline{Stages: []string{"build", "test", "build"}}
if len(checkDuplicateStages(p)) != 1 { t.Error("duplicate: warning") }
p2 := &model.Pipeline{Stages: []string{"build", "test"}}
if len(checkDuplicateStages(p2)) != 0 { t.Error("no duplicate: clean") }
}
func TestCheckWorkflow(t *testing.T) {
p := &model.Pipeline{Workflow: &model.Workflow{Rules: []model.Rule{{When: "always"}}}}
if len(checkWorkflow(p)) != 0 { t.Error("valid when: clean") }
p2 := &model.Pipeline{Workflow: &model.Workflow{Rules: []model.Rule{{When: "bad"}}}}
if len(checkWorkflow(p2)) != 1 { t.Error("invalid when: error") }
p3 := &model.Pipeline{}
if len(checkWorkflow(p3)) != 0 { t.Error("nil workflow: clean") }
}
func TestFindingString(t *testing.T) {
f := Finding{
Severity: Error,
Rule: "GL001",
Job: "build",
File: "ci.yml",
Line: 10,
Message: "missing script",
}
s := f.String()
if s == "" { t.Error("expected non-empty string") }
// Without file/line/job
f2 := Finding{Severity: Warning, Rule: "GL002", Message: "test"}
s2 := f2.String()
if s2 == "" { t.Error("expected non-empty string") }
// With file but no line
f3 := Finding{Severity: Error, Rule: "GL003", File: "ci.yml", Message: "x"}
if f3.String() == "" { t.Error("expected non-empty string") }
}
func TestScriptNonEmpty(t *testing.T) {
if scriptNonEmpty(nil) { t.Error("nil") }
if scriptNonEmpty([]any{}) { t.Error("empty slice") }
if !scriptNonEmpty([]any{"echo"}) { t.Error("non-empty slice") }
if !scriptNonEmpty("echo") { t.Error("string") }
if scriptNonEmpty("") { t.Error("empty string") }
}
// ── checkNeeds ────────────────────────────────────────────────────────────────
func TestCheckNeeds(t *testing.T) {
p := &model.Pipeline{
Stages: []string{"build", "test"},
Jobs: map[string]model.Job{
"build-job": {Name: "build-job", Stage: "build", Script: []any{"make"}},
"test-job": {Name: "test-job", Stage: "test", Script: []any{"test"},
Needs: []any{"build-job"}},
},
}
if len(checkNeeds(p, nil)) != 0 { t.Error("valid needs: clean") }
// needs unknown job
p2 := &model.Pipeline{
Jobs: map[string]model.Job{
"test-job": {Name: "test-job", Stage: "test", Needs: []any{"nonexistent"}},
},
}
if len(checkNeeds(p2, nil)) != 1 { t.Error("unknown needs: error") }
// optional: true for unknown job → warning not error
p3 := &model.Pipeline{
Jobs: map[string]model.Job{
"test-job": {Name: "test-job", Stage: "test",
Needs: []any{map[string]any{"job": "ghost", "optional": true}}},
},
}
findings := checkNeeds(p3, nil)
if len(findings) != 1 || findings[0].Severity != Warning {
t.Error("optional unknown needs: warning")
}
// cross-pipeline need (ignored)
p4 := &model.Pipeline{
Jobs: map[string]model.Job{
"test-job": {Name: "test-job",
Needs: []any{map[string]any{"pipeline": "other", "job": "j"}}},
},
}
if len(checkNeeds(p4, nil)) != 0 { t.Error("cross-pipeline: clean") }
// needs later stage → error
p5 := &model.Pipeline{
Stages: []string{"build", "test"},
Jobs: map[string]model.Job{
"build-job": {Name: "build-job", Stage: "build", Script: []any{"make"}},
"early-job": {Name: "early-job", Stage: "build",
Needs: []any{"build-job"}, Script: []any{"echo"}},
"test-job": {Name: "test-job", Stage: "test", Script: []any{"test"},
Needs: []any{"build-job"}}, // ok
},
}
// Only cross-stage ordering violations should be found
_ = checkNeeds(p5, nil)
}
func TestCheckNeeds_Cycle(t *testing.T) {
p := &model.Pipeline{
Jobs: map[string]model.Job{
"a": {Name: "a", Needs: []any{"b"}},
"b": {Name: "b", Needs: []any{"a"}},
},
}
findings := checkNeeds(p, nil)
for _, f := range findings {
if f.Rule == RuleNeedsCycle { return }
}
t.Error("expected cycle detection finding")
}
// ── checkDependencies ─────────────────────────────────────────────────────────
func TestCheckDependencies(t *testing.T) {
p := &model.Pipeline{
Stages: []string{"build", "test"},
Jobs: map[string]model.Job{
"build-job": {Name: "build-job", Stage: "build", Script: []any{"make"}},
"test-job": {Name: "test-job", Stage: "test", Script: []any{"test"},
Dependencies: []string{"build-job"}},
},
}
if len(checkDependencies(p, nil)) != 0 { t.Error("valid deps: clean") }
// unknown dep
p2 := &model.Pipeline{
Jobs: map[string]model.Job{
"test-job": {Name: "test-job", Stage: "test", Dependencies: []string{"ghost"}},
},
}
if len(checkDependencies(p2, nil)) != 1 { t.Error("unknown dep: error") }
// dep in same or later stage → error
p3 := &model.Pipeline{
Stages: []string{"build", "test"},
Jobs: map[string]model.Job{
"test-job1": {Name: "test-job1", Stage: "test"},
"test-job2": {Name: "test-job2", Stage: "test", Dependencies: []string{"test-job1"}},
},
}
if len(checkDependencies(p3, nil)) != 1 { t.Error("same stage dep: error") }
}
func TestCheckDependencies_SkippedJob(t *testing.T) {
p := &model.Pipeline{
Jobs: map[string]model.Job{
"skipped-job": {Name: "skipped-job", Stage: "test", Dependencies: []string{"ghost"}},
},
}
// Without skipping: reports unknown dependency.
if len(checkDependencies(p, nil)) == 0 {
t.Fatal("expected GL030 without skipped set, got none")
}
// With job skipped: suppressed.
if len(checkDependencies(p, map[string]bool{"skipped-job": true})) != 0 {
t.Error("expected no findings for skipped job")
}
}
// ── checkCacheKeyFiles ────────────────────────────────────────────────────────
func TestCheckCacheKeyFiles_NoFilesKey(t *testing.T) {
// cache.key is a map but has no "files" key → continue (line 823-824).
job := model.Job{
Cache: map[string]any{
"key": map[string]any{"prefix": "v1"},
},
}
if got := checkCacheKeyFiles("j", job); len(got) != 0 {
t.Errorf("key map without 'files': expected no findings, got %v", got)
}
}
func TestCheckCacheKeyFiles_FilesNotList(t *testing.T) {
// cache.key.files is a scalar (not a list) → error finding (line 827-834).
job := model.Job{
Cache: map[string]any{
"key": map[string]any{"files": "single-file.lock"},
},
}
got := checkCacheKeyFiles("j", job)
if len(got) == 0 {
t.Error("files as scalar: expected error finding")
return
}
if got[0].Rule != RuleInvalidCacheKeyFiles || got[0].Severity != Error {
t.Errorf("unexpected finding: %v", got[0])
}
}
func TestCheckCacheKeyFiles_NonStringItem(t *testing.T) {
// cache.key.files list has a non-string item → skip it (line 838-839).
job := model.Job{
Cache: map[string]any{
"key": map[string]any{
"files": []any{42, "go.sum"}, // 42 is non-string, go.sum is valid
},
},
}
// 42 is skipped; "go.sum" has no glob chars → no findings.
if got := checkCacheKeyFiles("j", job); len(got) != 0 {
t.Errorf("non-string item: expected no findings, got %v", got)
}
}
+16 -5
View File
@@ -22,15 +22,19 @@ type Finding struct {
Job string // empty for pipeline-level findings
File string // source file where the finding originates
Line int // line number in File (0 = unknown)
Column int // column number in File (0 = unknown; 1-indexed)
Message string
}
func (f Finding) String() string {
var loc string
if f.File != "" {
if f.Line > 0 {
switch {
case f.Line > 0 && f.Column > 0:
loc = fmt.Sprintf("%s:%d:%d: ", f.File, f.Line, f.Column)
case f.Line > 0:
loc = fmt.Sprintf("%s:%d: ", f.File, f.Line)
} else {
default:
loc = fmt.Sprintf("%s: ", f.File)
}
}
@@ -52,16 +56,22 @@ func (f Finding) String() string {
// Lint runs all rules against p and returns findings sorted by (File, Line, Rule).
// Findings with no File (pipeline-level) sort before file-scoped ones.
func Lint(p *model.Pipeline) []Finding {
// skipped is an optional set of job names to exclude from cross-job checks
// (needs:, dependencies:); pass nil to check all jobs.
func Lint(p *model.Pipeline, skipped map[string]bool) []Finding {
var findings []Finding
findings = append(findings, checkStages(p)...)
findings = append(findings, checkDuplicateStages(p)...)
findings = append(findings, checkDefault(p)...)
findings = append(findings, checkWorkflow(p)...)
findings = append(findings, checkJobs(p)...)
findings = append(findings, checkNeeds(p)...)
findings = append(findings, checkDependencies(p)...)
findings = append(findings, checkNeeds(p, skipped)...)
findings = append(findings, checkRulesNeeds(p, skipped)...)
findings = append(findings, checkDependencies(p, skipped)...)
findings = append(findings, checkVariableRefs(p)...)
findings = append(findings, checkRulesIfReachability(p)...)
findings = append(findings, checkInheritCompleteness(p)...)
findings = append(findings, checkInsecureRemoteInclude(p)...)
slices.SortStableFunc(findings, func(a, b Finding) int {
if c := cmp.Compare(a.File, b.File); c != 0 {
return c
@@ -219,6 +229,7 @@ func checkJob(name string, job model.Job, stageSet map[string]bool) []Finding {
if findings[i].Job != "" && findings[i].File == "" {
findings[i].File = job.File
findings[i].Line = job.Line
findings[i].Column = job.Column
}
}
return findings
+142
View File
@@ -0,0 +1,142 @@
package linter
import (
"testing"
"git.k3nny.fr/glint/internal/model"
)
// TestCheckDefault exercises the non-nil default block code path.
func TestCheckDefault(t *testing.T) {
// nil default → nil return (already covered implicitly; included for clarity)
if got := checkDefault(&model.Pipeline{}); got != nil {
t.Errorf("nil default: want nil findings, got %v", got)
}
// non-nil default with empty timeout → no findings
if got := checkDefault(&model.Pipeline{Default: &model.DefaultConfig{}}); got != nil {
t.Errorf("empty default: want no findings, got %v", got)
}
// non-nil default with invalid timeout → finding produced
findings := checkDefault(&model.Pipeline{Default: &model.DefaultConfig{Timeout: "bad-value"}})
if len(findings) == 0 {
t.Error("invalid timeout in default block should produce a finding")
}
}
// TestCheckJob_MissingScript covers the error/warning paths for jobs without a
// script field.
func TestCheckJob_MissingScript(t *testing.T) {
stageSet := map[string]bool{"build": true}
// No script, no extends → Error.
findings := checkJob("my-job", model.Job{Stage: "build"}, stageSet)
var gotErr bool
for _, f := range findings {
if f.Rule == RuleMissingScript && f.Severity == Error {
gotErr = true
}
}
if !gotErr {
t.Error("job with no script and no extends: expected Error finding GL003")
}
}
// TestCheckJob_ExtendsNoScript covers the Warning variant: a job that extends
// a base template but has no script (the script may come from the base, which
// could not be fetched).
func TestCheckJob_ExtendsNoScript(t *testing.T) {
stageSet := map[string]bool{"build": true}
job := model.Job{Stage: "build", Extends: ".base-template"}
findings := checkJob("my-job", job, stageSet)
var gotWarn bool
for _, f := range findings {
if f.Rule == RuleMissingScript && f.Severity == Warning {
gotWarn = true
}
}
if !gotWarn {
t.Error("job with extends but no script: expected Warning finding GL003")
}
}
// TestCheckJob_StageInputPlaceholder verifies that a stage value containing
// "$[[" (a component input placeholder) skips the unknown-stage check.
func TestCheckJob_StageInputPlaceholder(t *testing.T) {
stageSet := map[string]bool{"build": true}
job := model.Job{Stage: "$[[inputs.stage]]", Script: []any{"echo"}}
for _, f := range checkJob("my-job", job, stageSet) {
if f.Rule == RuleUnknownStage {
t.Error("stage with $[[ placeholder should not produce GL004")
}
}
}
// TestCheckJob_OnlyAndRules covers the only+rules conflict (GL005).
func TestCheckJob_OnlyAndRules(t *testing.T) {
stageSet := map[string]bool{"build": true}
job := model.Job{
Stage: "build",
Script: []any{"echo"},
Only: []any{"branches"},
Rules: []model.Rule{{When: "on_success"}},
}
var gotConflict bool
for _, f := range checkJob("my-job", job, stageSet) {
if f.Rule == RuleOnlyRulesConflict {
gotConflict = true
}
}
if !gotConflict {
t.Error("only+rules: expected GL005 conflict finding")
}
}
// TestCheckJob_ExceptAndRules covers the except+rules conflict (GL006).
func TestCheckJob_ExceptAndRules(t *testing.T) {
stageSet := map[string]bool{"build": true}
job := model.Job{
Stage: "build",
Script: []any{"echo"},
Except: []any{"tags"},
Rules: []model.Rule{{When: "on_success"}},
}
var gotConflict bool
for _, f := range checkJob("my-job", job, stageSet) {
if f.Rule == RuleExceptRulesConflict {
gotConflict = true
}
}
if !gotConflict {
t.Error("except+rules: expected GL006 conflict finding")
}
}
// TestCheckJob_RunField verifies that a job with a 'run:' block (instead of
// 'script:') is not flagged for missing script.
func TestCheckJob_RunField(t *testing.T) {
stageSet := map[string]bool{"build": true}
job := model.Job{Stage: "build", Run: map[string]any{"steps": []any{"echo hi"}}}
for _, f := range checkJob("my-job", job, stageSet) {
if f.Rule == RuleMissingScript {
t.Error("job with run: should not produce GL003 (missing script)")
}
}
}
// TestCheckJob_UnknownStage verifies that a job with a declared stage value that
// is not in the stageSet produces a GL004 finding (line 178-185).
func TestCheckJob_UnknownStage(t *testing.T) {
stageSet := map[string]bool{"build": true, "test": true}
job := model.Job{Stage: "unknown-stage", Script: []any{"echo"}}
var gotGL004 bool
for _, f := range checkJob("my-job", job, stageSet) {
if f.Rule == RuleUnknownStage {
gotGL004 = true
}
}
if !gotGL004 {
t.Error("job with undeclared stage: expected GL004 finding")
}
}
+46 -1
View File
@@ -12,7 +12,7 @@ type needEntry struct {
optional bool // true when the needs entry carries optional: true
}
func checkNeeds(p *model.Pipeline) []Finding {
func checkNeeds(p *model.Pipeline, skipped map[string]bool) []Finding {
var findings []Finding
// Build a stage-index map for ordering checks.
@@ -26,6 +26,9 @@ func checkNeeds(p *model.Pipeline) []Finding {
needsGraph := make(map[string][]string)
for name, job := range p.Jobs {
if skipped[name] {
continue
}
if len(job.Needs) == 0 {
continue
}
@@ -50,6 +53,7 @@ func checkNeeds(p *model.Pipeline) []Finding {
Job: name,
File: job.File,
Line: job.Line,
Column: job.Column,
Message: fmt.Sprintf("needs unknown job %q", entry.job),
})
continue
@@ -68,6 +72,7 @@ func checkNeeds(p *model.Pipeline) []Finding {
Job: name,
File: job.File,
Line: job.Line,
Column: job.Column,
Message: fmt.Sprintf(
"needs %q which is in a later stage (%q after %q)",
entry.job, neededJob.Stage, job.Stage,
@@ -82,6 +87,45 @@ func checkNeeds(p *model.Pipeline) []Finding {
return findings
}
// checkRulesNeeds validates rules:needs: entries across all jobs. Each entry
// in a rule's needs: list must reference a job that exists in the pipeline.
// Cross-pipeline needs (maps with a "pipeline" key) are ignored. Skipped jobs
// are excluded from checking (same semantics as top-level needs: via GL027).
func checkRulesNeeds(p *model.Pipeline, skipped map[string]bool) []Finding {
var findings []Finding
for name, job := range p.Jobs {
if skipped[name] {
continue
}
for i, rule := range job.Rules {
if len(rule.Needs) == 0 {
continue
}
for _, entry := range parseNeedEntries(rule.Needs) {
if _, exists := p.Jobs[entry.job]; !exists {
sev := Error
if entry.optional {
sev = Warning
}
findings = append(findings, Finding{
Severity: sev,
Rule: RuleRulesNeedsUnknown,
Job: name,
File: job.File,
Line: job.Line,
Column: job.Column,
Message: fmt.Sprintf(
"rules[%d].needs: references unknown job %q",
i, entry.job,
),
})
}
}
}
}
return findings
}
// parseNeedEntries extracts needs entries from a needs: list, preserving the
// optional flag. Each element is a plain string (job name) or a map with a
// "job" key. Cross-pipeline needs (maps with a "pipeline" key) are skipped.
@@ -130,6 +174,7 @@ func detectNeedsCycles(graph map[string][]string, jobs map[string]model.Job) []F
Job: name,
File: j.File,
Line: j.Line,
Column: j.Column,
Message: fmt.Sprintf("circular dependency in needs: %v → %s", path, name),
})
}
+186
View File
@@ -0,0 +1,186 @@
package linter
import (
"testing"
"git.k3nny.fr/glint/internal/model"
)
// TestCheckNeeds_SkippedJob verifies that a skipped job's needs: violations are suppressed.
func TestCheckNeeds_SkippedJob(t *testing.T) {
p := &model.Pipeline{
Stages: []string{"build", "deploy"},
Jobs: map[string]model.Job{
"skipped-job": {
Name: "skipped-job",
Stage: "build",
Script: []any{"echo"},
Needs: []any{"nonexistent-job"},
},
},
}
// Without skipping: should report unknown needs.
withoutSkip := checkNeeds(p, nil)
if len(withoutSkip) == 0 {
t.Fatal("expected GL027 without skipped set, got none")
}
// With job skipped: no findings.
withSkip := checkNeeds(p, map[string]bool{"skipped-job": true})
if len(withSkip) != 0 {
t.Errorf("expected no findings for skipped job, got %v", withSkip)
}
}
// TestCheckRulesNeeds_UnknownJob verifies that an unknown job in rules:needs: produces GL044.
func TestCheckRulesNeeds_UnknownJob(t *testing.T) {
p := &model.Pipeline{
Stages: []string{"build", "test"},
Jobs: map[string]model.Job{
"build-job": {
Name: "build-job",
Stage: "build",
Script: []any{"make"},
},
"test-job": {
Name: "test-job",
Stage: "test",
Script: []any{"make test"},
Rules: []model.Rule{
{
If: `$CI_COMMIT_BRANCH == "main"`,
Needs: []any{"build-job", "nonexistent"},
},
},
},
},
}
findings := checkRulesNeeds(p, nil)
var got bool
for _, f := range findings {
if f.Rule == RuleRulesNeedsUnknown && f.Severity == Error {
got = true
}
}
if !got {
t.Errorf("expected GL044 error for unknown rules:needs: job; got %v", findings)
}
}
// TestCheckRulesNeeds_KnownJob verifies no finding when all rules:needs: jobs exist.
func TestCheckRulesNeeds_KnownJob(t *testing.T) {
p := &model.Pipeline{
Stages: []string{"build", "test"},
Jobs: map[string]model.Job{
"build-job": {Name: "build-job", Stage: "build", Script: []any{"make"}},
"test-job": {
Name: "test-job",
Stage: "test",
Script: []any{"make test"},
Rules: []model.Rule{{Needs: []any{"build-job"}}},
},
},
}
if findings := checkRulesNeeds(p, nil); len(findings) != 0 {
t.Errorf("expected no findings for valid rules:needs:; got %v", findings)
}
}
// TestCheckRulesNeeds_Optional verifies that optional: true downgrades to Warning.
func TestCheckRulesNeeds_Optional(t *testing.T) {
p := &model.Pipeline{
Jobs: map[string]model.Job{
"test-job": {
Name: "test-job",
Script: []any{"make test"},
Rules: []model.Rule{
{Needs: []any{map[string]any{"job": "ghost", "optional": true}}},
},
},
},
}
findings := checkRulesNeeds(p, nil)
if len(findings) != 1 || findings[0].Severity != Warning {
t.Errorf("optional unknown rules:needs: should produce Warning; got %v", findings)
}
}
// TestCheckRulesNeeds_CrossPipeline verifies that cross-pipeline needs are ignored.
func TestCheckRulesNeeds_CrossPipeline(t *testing.T) {
p := &model.Pipeline{
Jobs: map[string]model.Job{
"test-job": {
Name: "test-job",
Script: []any{"make test"},
Rules: []model.Rule{
{Needs: []any{map[string]any{"pipeline": "other", "job": "j"}}},
},
},
},
}
if findings := checkRulesNeeds(p, nil); len(findings) != 0 {
t.Errorf("cross-pipeline rules:needs: should be ignored; got %v", findings)
}
}
// TestCheckRulesNeeds_SkippedJob verifies that a skipped job's rules:needs: violations are suppressed.
func TestCheckRulesNeeds_SkippedJob(t *testing.T) {
p := &model.Pipeline{
Jobs: map[string]model.Job{
"test-job": {
Name: "test-job",
Script: []any{"make test"},
Rules: []model.Rule{{Needs: []any{"nonexistent"}}},
},
},
}
if findings := checkRulesNeeds(p, map[string]bool{"test-job": true}); len(findings) != 0 {
t.Errorf("skipped job: expected no findings; got %v", findings)
}
}
// TestCheckRulesNeeds_NoNeeds verifies no findings when rules have no needs: override.
func TestCheckRulesNeeds_NoNeeds(t *testing.T) {
p := &model.Pipeline{
Jobs: map[string]model.Job{
"test-job": {
Name: "test-job",
Script: []any{"make test"},
Rules: []model.Rule{{When: "on_success"}},
},
},
}
if findings := checkRulesNeeds(p, nil); len(findings) != 0 {
t.Errorf("rules without needs: should produce no findings; got %v", findings)
}
}
// TestCheckNeeds_StageOrder verifies that a job needing a job in a later stage
// produces RuleNeedsStageOrder (line 64-76 in needs.go).
func TestCheckNeeds_StageOrder(t *testing.T) {
p := &model.Pipeline{
Stages: []string{"build", "test", "deploy"},
Jobs: map[string]model.Job{
"build-job": {
Name: "build-job",
Stage: "build",
Script: []any{"make"},
Needs: []any{"deploy-job"},
},
"deploy-job": {
Name: "deploy-job",
Stage: "deploy",
Script: []any{"make deploy"},
},
},
}
findings := checkNeeds(p, nil)
var gotStageOrder bool
for _, f := range findings {
if f.Rule == RuleNeedsStageOrder {
gotStageOrder = true
}
}
if !gotStageOrder {
t.Errorf("build-job needing deploy-job (later stage): expected GL025 finding; got: %v", findings)
}
}
+20
View File
@@ -143,4 +143,24 @@ const (
// GL041: cache.key.files contains a glob pattern; it must be a list of exact file paths.
RuleInvalidCacheKeyFiles = "GL041"
// GL042: every rules:if: condition in a job's rules: block evaluates to false
// given the values of variables declared in the pipeline YAML, so the job can
// never be active. Only fires when all referenced variables are declared
// (predefined CI_* / GITLAB_* vars are not evaluated to avoid false positives).
RuleStaticDeadRules = "GL042"
// GL043: a job declares 'inherit: default:' (true, false, or list) but the
// pipeline has no 'default:' block, making the declaration a no-op. Also fires
// when 'inherit: default: [list]' names fields not set in the default: block.
RuleInheritNoDefault = "GL043"
// GL044: a rules:needs: entry references a job that does not exist in the pipeline.
// rules:needs: overrides the top-level needs: when a specific rule matches (GitLab CI 16.4+).
RuleRulesNeedsUnknown = "GL044"
// GL045: an include: remote: entry uses plain HTTP instead of HTTPS.
// CI templates fetched over HTTP are transmitted in cleartext and can be
// intercepted or modified in transit.
RuleInsecureRemoteInclude = "GL045"
)
+2 -2
View File
@@ -35,7 +35,7 @@ func TestSambaCI(t *testing.T) {
t.Logf("extends warning: job %q extends unknown %q", w.Job, w.Base)
}
findings := linter.Lint(p)
findings := linter.Lint(p, nil)
for _, f := range findings {
if f.Severity == linter.Error {
@@ -78,7 +78,7 @@ func TestSambaCIEntryFiles(t *testing.T) {
t.Logf("extends warning: job %q extends unknown %q", w.Job, w.Base)
}
findings := linter.Lint(p)
findings := linter.Lint(p, nil)
for _, f := range findings {
if f.Severity == linter.Error {
t.Errorf("unexpected error finding: %s", f)
+1
View File
@@ -148,6 +148,7 @@ func checkVariableRefs(p *model.Pipeline) []Finding {
Job: name,
File: job.File,
Line: job.Line,
Column: job.Column,
Message: fmt.Sprintf("rules[%d].if: $%s is not declared in pipeline or job variables:", i, varName),
})
}
+35
View File
@@ -212,3 +212,38 @@ func TestCheckVariableRefs(t *testing.T) {
})
}
}
// TestCheckVariableRefs_WorkflowRuleEmptyIf covers the `if rule.If == ""` early
// continue at variables.go line 109-110 (workflow rule with no if: expression).
func TestCheckVariableRefs_WorkflowRuleEmptyIf(t *testing.T) {
p := &model.Pipeline{
Workflow: &model.Workflow{
Rules: []model.Rule{
{When: "always"}, // no If → triggers the continue
{If: `$UNDECLARED == "yes"`}, // undeclared → warning
},
},
Jobs: map[string]model.Job{},
}
findings := checkVariableRefs(p)
count := 0
for _, f := range findings {
if f.Rule == RuleUndeclaredVariable {
count++
}
}
if count != 1 {
t.Errorf("expected 1 GL032 warning for UNDECLARED, got %d; findings: %v", count, findings)
}
}
// TestExtractIfVars_StringEscape covers the backslash-escape branch (line 42-44)
// in extractIfVars when scanning a quoted string literal.
func TestExtractIfVars_StringEscape(t *testing.T) {
// `$BRANCH == "de\velop"` — the `\v` inside the string literal triggers the
// escape-character skip in the scanning loop.
got := extractIfVars(`$BRANCH == "de\velop"`)
if len(got) != 1 || got[0] != "BRANCH" {
t.Errorf("extractIfVars with escape in string: got %v, want [BRANCH]", got)
}
}
+340
View File
@@ -0,0 +1,340 @@
package lsp
import (
"bufio"
"encoding/json"
"fmt"
"io"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"git.k3nny.fr/glint/internal/fetcher"
"git.k3nny.fr/glint/internal/linter"
"git.k3nny.fr/glint/internal/model"
"git.k3nny.fr/glint/internal/resolver"
)
// Server is a minimal Language Server Protocol server that publishes glint
// diagnostics for .gitlab-ci.yml files opened in an editor.
//
// Transport: JSON-RPC 2.0 over stdin/stdout with Content-Length framing.
// Sync mode: Full — the client sends the complete document text on every change.
type Server struct {
in *bufio.Reader
out io.Writer
cfg fetcher.GitLabConfig
version string
docs map[string]string // uri → current document text
// Exit is called with the process exit code when the LSP client sends the
// "exit" notification. Defaults to os.Exit; replace in tests.
Exit func(int)
shutdownReceived bool
}
// New creates a Server reading from r and writing to w.
func New(r io.Reader, w io.Writer, cfg fetcher.GitLabConfig, version string) *Server {
return &Server{
in: bufio.NewReader(r),
out: w,
cfg: cfg,
version: version,
docs: make(map[string]string),
Exit: os.Exit,
}
}
// Run processes LSP messages until the connection closes or a fatal error occurs.
// It returns nil on a clean EOF (client disconnected) and a non-nil error for
// unrecoverable protocol failures.
func (s *Server) Run() error {
for {
msg, err := s.readMessage()
if err == io.EOF {
return nil
}
if err != nil {
return fmt.Errorf("reading LSP message: %w", err)
}
if err := s.dispatch(msg); err != nil {
return err
}
}
}
// maxLSPMessageBytes caps the body size accepted from an LSP client.
// A legitimate editor message is never this large; enforcing the cap prevents
// a crafted Content-Length from triggering a multi-gigabyte allocation.
const maxLSPMessageBytes = 64 << 20 // 64 MiB
// readMessage reads one Content-Lengthframed JSON-RPC message from the stream.
func (s *Server) readMessage() (*Message, error) {
var contentLength int
for {
line, err := s.in.ReadString('\n')
if err != nil {
if err == io.EOF && line == "" {
return nil, io.EOF
}
return nil, err
}
line = strings.TrimRight(line, "\r\n")
if line == "" {
break // blank line separates headers from body
}
if strings.HasPrefix(line, "Content-Length: ") {
n, parseErr := strconv.Atoi(strings.TrimPrefix(line, "Content-Length: "))
if parseErr != nil {
return nil, fmt.Errorf("invalid Content-Length: %w", parseErr)
}
contentLength = n
}
}
if contentLength == 0 {
return nil, fmt.Errorf("missing or zero Content-Length header")
}
if contentLength > maxLSPMessageBytes {
return nil, fmt.Errorf("Content-Length %d exceeds maximum %d", contentLength, maxLSPMessageBytes)
}
body := make([]byte, contentLength)
if _, err := io.ReadFull(s.in, body); err != nil {
return nil, fmt.Errorf("reading message body: %w", err)
}
var msg Message
if err := json.Unmarshal(body, &msg); err != nil {
return nil, fmt.Errorf("unmarshalling message: %w", err)
}
return &msg, nil
}
// writeMessage encodes v as JSON and sends it with a Content-Length header.
func (s *Server) writeMessage(v any) error {
body, err := json.Marshal(v)
if err != nil {
return err
}
header := fmt.Sprintf("Content-Length: %d\r\n\r\n", len(body))
if _, err := io.WriteString(s.out, header); err != nil {
return err
}
_, err = s.out.Write(body)
return err
}
func (s *Server) respond(id json.RawMessage, result any) error {
raw, err := json.Marshal(result)
if err != nil {
return err
}
return s.writeMessage(struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id"`
Result json.RawMessage `json:"result"`
}{"2.0", id, raw})
}
func (s *Server) respondError(id json.RawMessage, code int, message string) error {
return s.writeMessage(struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id"`
Error RPCError `json:"error"`
}{"2.0", id, RPCError{Code: code, Message: message}})
}
func (s *Server) notify(method string, params any) error {
raw, err := json.Marshal(params)
if err != nil {
return err
}
return s.writeMessage(struct {
JSONRPC string `json:"jsonrpc"`
Method string `json:"method"`
Params json.RawMessage `json:"params"`
}{"2.0", method, raw})
}
// isRequest reports whether msg is a JSON-RPC request (has a non-null id).
func isRequest(msg *Message) bool {
return len(msg.ID) > 0 && string(msg.ID) != "null"
}
func (s *Server) dispatch(msg *Message) error {
switch msg.Method {
case "initialize":
return s.handleInitialize(msg)
case "initialized":
return nil // notification; no response required
case "shutdown":
s.shutdownReceived = true
if isRequest(msg) {
return s.respond(msg.ID, nil)
}
return nil
case "exit":
code := 1
if s.shutdownReceived {
code = 0
}
s.Exit(code)
return nil
case "textDocument/didOpen":
return s.handleDidOpen(msg)
case "textDocument/didChange":
return s.handleDidChange(msg)
case "textDocument/didSave":
return s.handleDidSave(msg)
case "textDocument/didClose":
return s.handleDidClose(msg)
default:
if isRequest(msg) {
return s.respondError(msg.ID, -32601, "method not found: "+msg.Method)
}
return nil
}
}
func (s *Server) handleInitialize(msg *Message) error {
return s.respond(msg.ID, InitializeResult{
Capabilities: ServerCapabilities{TextDocumentSync: 1},
ServerInfo: &ServerInfo{Name: "glint", Version: s.version},
})
}
func (s *Server) handleDidOpen(msg *Message) error {
var p DidOpenTextDocumentParams
if err := json.Unmarshal(msg.Params, &p); err != nil {
return nil // ignore malformed notifications
}
s.docs[p.TextDocument.URI] = p.TextDocument.Text
return s.lintAndPublish(p.TextDocument.URI, p.TextDocument.Text)
}
func (s *Server) handleDidChange(msg *Message) error {
var p DidChangeTextDocumentParams
if err := json.Unmarshal(msg.Params, &p); err != nil {
return nil
}
if len(p.ContentChanges) == 0 {
return nil
}
// Full sync: the last change event holds the complete new text.
text := p.ContentChanges[len(p.ContentChanges)-1].Text
s.docs[p.TextDocument.URI] = text
return s.lintAndPublish(p.TextDocument.URI, text)
}
func (s *Server) handleDidSave(msg *Message) error {
var p DidSaveTextDocumentParams
if err := json.Unmarshal(msg.Params, &p); err != nil {
return nil
}
text := s.docs[p.TextDocument.URI]
if p.Text != nil {
text = *p.Text
s.docs[p.TextDocument.URI] = text
}
if text == "" {
return nil
}
return s.lintAndPublish(p.TextDocument.URI, text)
}
func (s *Server) handleDidClose(msg *Message) error {
var p DidCloseTextDocumentParams
if err := json.Unmarshal(msg.Params, &p); err != nil {
return nil
}
delete(s.docs, p.TextDocument.URI)
// Clear diagnostics so the editor doesn't show stale squiggles.
return s.notify("textDocument/publishDiagnostics", PublishDiagnosticsParams{
URI: p.TextDocument.URI,
Diagnostics: []Diagnostic{},
})
}
func (s *Server) lintAndPublish(uri, text string) error {
diags := s.lintDocument(uri, text)
return s.notify("textDocument/publishDiagnostics", PublishDiagnosticsParams{
URI: uri,
Diagnostics: diags,
})
}
// lintDocument parses text and runs all lint rules, returning LSP Diagnostics.
// Findings that originate from included files (not the root document) are
// excluded; their URIs are not tracked so line numbers would be incorrect.
func (s *Server) lintDocument(uri, text string) []Diagnostic {
path := uriToPath(uri)
if path == "" {
return []Diagnostic{}
}
rootDir := filepath.Dir(filepath.Clean(path))
pipeline, err := model.ParseBytes([]byte(text))
if err != nil {
return []Diagnostic{{
Range: Range{Start: Position{}, End: Position{}},
Severity: 1,
Source: "glint",
Message: "YAML parse error: " + err.Error(),
}}
}
pipeline.SourceFile = path
pipeline.SetJobOrigin(path)
// Include resolution is best-effort: network failures produce warnings that
// are intentionally discarded here. The linter operates on whatever was
// successfully resolved.
_, _ = resolver.ResolveIncludes(pipeline, s.cfg, rootDir)
_, _ = resolver.Resolve(pipeline)
findings := linter.Lint(pipeline, nil)
diags := make([]Diagnostic, 0, len(findings))
for _, f := range findings {
// Skip findings from included files — their line numbers reference
// a different document URI that the server has not opened.
if f.File != path && f.File != "" {
continue
}
line := 0
if f.Line > 0 {
line = f.Line - 1 // glint uses 1-based lines; LSP uses 0-based
}
sev := 1 // DiagnosticSeverity: Error
if f.Severity == linter.Warning {
sev = 2 // DiagnosticSeverity: Warning
}
msg := f.Message
if f.Job != "" {
msg = fmt.Sprintf("job %q: %s", f.Job, f.Message)
}
diags = append(diags, Diagnostic{
Range: Range{
Start: Position{Line: line},
End: Position{Line: line},
},
Severity: sev,
Code: f.Rule,
Source: "glint",
Message: msg,
})
}
return diags
}
// uriToPath converts a file:// URI to a local filesystem path.
// Returns an empty string for non-file URIs or on parse error.
func uriToPath(uri string) string {
u, err := url.Parse(uri)
if err != nil || u.Scheme != "file" {
return ""
}
return filepath.FromSlash(u.Path)
}
+397
View File
@@ -0,0 +1,397 @@
package lsp
import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"io"
"strconv"
"strings"
"testing"
"git.k3nny.fr/glint/internal/fetcher"
)
// frame encodes v as a Content-Lengthframed LSP message.
func frame(t *testing.T, v any) []byte {
t.Helper()
body, err := json.Marshal(v)
if err != nil {
t.Fatal(err)
}
hdr := fmt.Sprintf("Content-Length: %d\r\n\r\n", len(body))
return append([]byte(hdr), body...)
}
// readMsg reads one Content-Lengthframed JSON object from r.
func readMsg(t *testing.T, r *bufio.Reader) map[string]json.RawMessage {
t.Helper()
var contentLength int
for {
line, err := r.ReadString('\n')
if err != nil {
t.Fatalf("reading header: %v", err)
}
line = strings.TrimRight(line, "\r\n")
if line == "" {
break
}
if strings.HasPrefix(line, "Content-Length: ") {
n, err := strconv.Atoi(strings.TrimPrefix(line, "Content-Length: "))
if err != nil {
t.Fatalf("invalid Content-Length: %v", err)
}
contentLength = n
}
}
body := make([]byte, contentLength)
if _, err := io.ReadFull(r, body); err != nil {
t.Fatalf("reading body: %v", err)
}
var m map[string]json.RawMessage
if err := json.Unmarshal(body, &m); err != nil {
t.Fatalf("unmarshal: %v", err)
}
return m
}
// newTestServer returns a Server with a captured exit code and a bufio.Reader
// wrapping the output buffer so tests can read back server messages.
func newTestServer(input []byte) (*Server, *bytes.Buffer, *int) {
var out bytes.Buffer
exitCode := -1
srv := New(bytes.NewReader(input), &out, fetcher.GitLabConfig{}, "test")
srv.Exit = func(code int) { exitCode = code }
return srv, &out, &exitCode
}
func TestServer_Initialize(t *testing.T) {
input := frame(t, map[string]any{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": map[string]any{},
})
srv, out, _ := newTestServer(input)
srv.Run() //nolint:errcheck
resp := readMsg(t, bufio.NewReader(out))
if string(resp["id"]) != "1" {
t.Errorf("response id = %s; want 1", resp["id"])
}
var result InitializeResult
if err := json.Unmarshal(resp["result"], &result); err != nil {
t.Fatalf("unmarshal result: %v", err)
}
if result.Capabilities.TextDocumentSync != 1 {
t.Errorf("textDocumentSync = %d; want 1", result.Capabilities.TextDocumentSync)
}
if result.ServerInfo == nil || result.ServerInfo.Name != "glint" {
t.Errorf("serverInfo.name = %v; want glint", result.ServerInfo)
}
}
func TestServer_ShutdownExit(t *testing.T) {
var buf bytes.Buffer
buf.Write(frame(t, map[string]any{
"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": map[string]any{},
}))
buf.Write(frame(t, map[string]any{
"jsonrpc": "2.0", "method": "initialized", "params": map[string]any{},
}))
buf.Write(frame(t, map[string]any{
"jsonrpc": "2.0", "id": 2, "method": "shutdown",
}))
buf.Write(frame(t, map[string]any{
"jsonrpc": "2.0", "method": "exit",
}))
srv, out, exitCode := newTestServer(buf.Bytes())
srv.Run() //nolint:errcheck
r := bufio.NewReader(out)
initResp := readMsg(t, r)
if string(initResp["id"]) != "1" {
t.Errorf("init response id = %s; want 1", initResp["id"])
}
shutResp := readMsg(t, r)
if string(shutResp["id"]) != "2" {
t.Errorf("shutdown response id = %s; want 2", shutResp["id"])
}
if string(shutResp["result"]) != "null" {
t.Errorf("shutdown result = %s; want null", shutResp["result"])
}
if *exitCode != 0 {
t.Errorf("exit code = %d; want 0", *exitCode)
}
}
func TestServer_ExitWithoutShutdown(t *testing.T) {
input := frame(t, map[string]any{
"jsonrpc": "2.0", "method": "exit",
})
srv, _, exitCode := newTestServer(input)
srv.Run() //nolint:errcheck
if *exitCode != 1 {
t.Errorf("exit code = %d; want 1 (no prior shutdown)", *exitCode)
}
}
func TestServer_MethodNotFound(t *testing.T) {
input := frame(t, map[string]any{
"jsonrpc": "2.0", "id": 99, "method": "workspace/unknownMethod",
})
srv, out, _ := newTestServer(input)
srv.Run() //nolint:errcheck
resp := readMsg(t, bufio.NewReader(out))
if resp["error"] == nil {
t.Errorf("expected error response for unknown method, got: %v", resp)
}
var rpcErr RPCError
if err := json.Unmarshal(resp["error"], &rpcErr); err != nil {
t.Fatalf("unmarshal error: %v", err)
}
if rpcErr.Code != -32601 {
t.Errorf("error code = %d; want -32601", rpcErr.Code)
}
}
func TestServer_UnknownNotificationIgnored(t *testing.T) {
// Notifications (no id) for unknown methods must be silently ignored.
input := frame(t, map[string]any{
"jsonrpc": "2.0", "method": "$/setTrace", "params": map[string]any{"value": "off"},
})
srv, out, _ := newTestServer(input)
srv.Run() //nolint:errcheck
if out.Len() > 0 {
t.Errorf("server wrote %d bytes for unknown notification; want 0", out.Len())
}
}
func TestServer_DidOpen_CleanPipeline(t *testing.T) {
yaml := `stages: [build]
build-job:
stage: build
script: echo hello
`
// Use a pseudo file:// URI that maps to the tmp path; include resolution
// will fail silently (no network, no local includes) which is fine.
uri := "file:///tmp/test.gitlab-ci.yml"
input := frame(t, map[string]any{
"jsonrpc": "2.0",
"method": "textDocument/didOpen",
"params": map[string]any{
"textDocument": map[string]any{
"uri": uri, "languageId": "yaml", "version": 1, "text": yaml,
},
},
})
srv, out, _ := newTestServer(input)
srv.Run() //nolint:errcheck
notif := readMsg(t, bufio.NewReader(out))
if string(notif["method"]) != `"textDocument/publishDiagnostics"` {
t.Fatalf("method = %s; want textDocument/publishDiagnostics", notif["method"])
}
var params PublishDiagnosticsParams
if err := json.Unmarshal(notif["params"], &params); err != nil {
t.Fatalf("unmarshal params: %v", err)
}
if params.URI != uri {
t.Errorf("uri = %q; want %q", params.URI, uri)
}
// A clean pipeline should produce no diagnostics (or only warnings from
// include resolution being skipped — but those are filtered since they
// originate from a different file path).
for _, d := range params.Diagnostics {
if d.Severity == 1 {
t.Errorf("unexpected error diagnostic: %s", d.Message)
}
}
}
func TestServer_DidOpen_WithErrors(t *testing.T) {
// A pipeline with a job in an undeclared stage triggers GL004.
yaml := `stages: [build]
bad-job:
stage: missing-stage
script: echo hi
`
uri := "file:///tmp/bad.gitlab-ci.yml"
input := frame(t, map[string]any{
"jsonrpc": "2.0",
"method": "textDocument/didOpen",
"params": map[string]any{
"textDocument": map[string]any{
"uri": uri, "languageId": "yaml", "version": 1, "text": yaml,
},
},
})
srv, out, _ := newTestServer(input)
srv.Run() //nolint:errcheck
notif := readMsg(t, bufio.NewReader(out))
var params PublishDiagnosticsParams
if err := json.Unmarshal(notif["params"], &params); err != nil {
t.Fatalf("unmarshal params: %v", err)
}
if len(params.Diagnostics) == 0 {
t.Error("expected diagnostics for pipeline with unknown stage, got none")
}
found := false
for _, d := range params.Diagnostics {
if d.Code == "GL004" {
found = true
if d.Severity != 1 {
t.Errorf("GL004 severity = %d; want 1 (Error)", d.Severity)
}
}
}
if !found {
t.Errorf("expected GL004 diagnostic, got: %v", params.Diagnostics)
}
}
func TestServer_DidOpen_ParseError(t *testing.T) {
uri := "file:///tmp/broken.gitlab-ci.yml"
input := frame(t, map[string]any{
"jsonrpc": "2.0",
"method": "textDocument/didOpen",
"params": map[string]any{
"textDocument": map[string]any{
"uri": uri, "languageId": "yaml", "version": 1,
"text": "?", // bare ? yields empty job name → parse error
},
},
})
srv, out, _ := newTestServer(input)
srv.Run() //nolint:errcheck
notif := readMsg(t, bufio.NewReader(out))
var params PublishDiagnosticsParams
if err := json.Unmarshal(notif["params"], &params); err != nil {
t.Fatalf("unmarshal params: %v", err)
}
if len(params.Diagnostics) == 0 {
t.Fatal("expected parse-error diagnostic, got none")
}
d := params.Diagnostics[0]
if d.Severity != 1 {
t.Errorf("severity = %d; want 1 (Error)", d.Severity)
}
if !strings.Contains(d.Message, "YAML parse error") {
t.Errorf("message = %q; want YAML parse error", d.Message)
}
}
func TestServer_DidChange(t *testing.T) {
uri := "file:///tmp/ci.gitlab-ci.yml"
var buf bytes.Buffer
// Open with clean content.
buf.Write(frame(t, map[string]any{
"jsonrpc": "2.0", "method": "textDocument/didOpen",
"params": map[string]any{"textDocument": map[string]any{
"uri": uri, "languageId": "yaml", "version": 1,
"text": "stages: [build]\nbuild: {stage: build, script: echo}\n",
}},
}))
// Change to content with an error.
buf.Write(frame(t, map[string]any{
"jsonrpc": "2.0", "method": "textDocument/didChange",
"params": map[string]any{
"textDocument": map[string]any{"uri": uri, "version": 2},
"contentChanges": []map[string]any{
{"text": "stages: [build]\nbad: {stage: gone, script: hi}\n"},
},
},
}))
srv, out, _ := newTestServer(buf.Bytes())
srv.Run() //nolint:errcheck
r := bufio.NewReader(out)
_ = readMsg(t, r) // first publishDiagnostics (clean)
second := readMsg(t, r)
var params PublishDiagnosticsParams
if err := json.Unmarshal(second["params"], &params); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if len(params.Diagnostics) == 0 {
t.Error("expected diagnostics after change to broken content, got none")
}
}
func TestServer_DidClose_ClearsdiAgnostics(t *testing.T) {
uri := "file:///tmp/toclose.gitlab-ci.yml"
var buf bytes.Buffer
buf.Write(frame(t, map[string]any{
"jsonrpc": "2.0", "method": "textDocument/didOpen",
"params": map[string]any{"textDocument": map[string]any{
"uri": uri, "languageId": "yaml", "version": 1,
"text": "stages: [build]\nj: {stage: build, script: echo}\n",
}},
}))
buf.Write(frame(t, map[string]any{
"jsonrpc": "2.0", "method": "textDocument/didClose",
"params": map[string]any{"textDocument": map[string]any{"uri": uri}},
}))
srv, out, _ := newTestServer(buf.Bytes())
srv.Run() //nolint:errcheck
r := bufio.NewReader(out)
_ = readMsg(t, r) // publishDiagnostics from didOpen
closeNotif := readMsg(t, r)
var params PublishDiagnosticsParams
if err := json.Unmarshal(closeNotif["params"], &params); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if params.URI != uri {
t.Errorf("uri = %q; want %q", params.URI, uri)
}
if len(params.Diagnostics) != 0 {
t.Errorf("expected empty diagnostics on close, got %v", params.Diagnostics)
}
}
func TestServer_ContentLengthTooLarge(t *testing.T) {
// Craft a header with a content-length that exceeds the cap.
// The server must reject it before allocating a giant buffer.
header := fmt.Sprintf("Content-Length: %d\r\n\r\n", maxLSPMessageBytes+1)
srv, _, _ := newTestServer([]byte(header))
err := srv.Run()
if err == nil {
t.Fatal("expected error for oversized Content-Length, got nil")
}
if !strings.Contains(err.Error(), "exceeds maximum") {
t.Errorf("unexpected error: %v", err)
}
}
func TestServer_UriToPath(t *testing.T) {
tests := []struct {
uri string
want string
}{
{"file:///tmp/ci.yml", "/tmp/ci.yml"},
{"file:///home/user/project/.gitlab-ci.yml", "/home/user/project/.gitlab-ci.yml"},
{"https://example.com/file.yml", ""},
{"not-a-uri", ""},
}
for _, tc := range tests {
got := uriToPath(tc.uri)
if got != tc.want {
t.Errorf("uriToPath(%q) = %q; want %q", tc.uri, got, tc.want)
}
}
}
+112
View File
@@ -0,0 +1,112 @@
// Package lsp implements a minimal Language Server Protocol server for glint.
package lsp
import "encoding/json"
// Message is a JSON-RPC 2.0 message (request, response, or notification).
type Message struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id,omitempty"`
Method string `json:"method,omitempty"`
Params json.RawMessage `json:"params,omitempty"`
Result json.RawMessage `json:"result,omitempty"`
Error *RPCError `json:"error,omitempty"`
}
// RPCError is a JSON-RPC 2.0 error object.
type RPCError struct {
Code int `json:"code"`
Message string `json:"message"`
}
// InitializeResult is the server's response to the initialize request.
type InitializeResult struct {
Capabilities ServerCapabilities `json:"capabilities"`
ServerInfo *ServerInfo `json:"serverInfo,omitempty"`
}
// ServerCapabilities advertises what the server supports.
type ServerCapabilities struct {
// TextDocumentSync: 1 = Full (send entire document on every change).
TextDocumentSync int `json:"textDocumentSync"`
}
// ServerInfo identifies the server to the client.
type ServerInfo struct {
Name string `json:"name"`
Version string `json:"version,omitempty"`
}
// TextDocumentItem is a text document opened by the client.
type TextDocumentItem struct {
URI string `json:"uri"`
LanguageID string `json:"languageId"`
Version int `json:"version"`
Text string `json:"text"`
}
// TextDocumentIdentifier references a text document by URI.
type TextDocumentIdentifier struct {
URI string `json:"uri"`
}
// VersionedTextDocumentIdentifier includes a version number.
type VersionedTextDocumentIdentifier struct {
URI string `json:"uri"`
Version int `json:"version"`
}
// TextDocumentContentChangeEvent is a single content change event.
// With Full sync the Text field contains the complete new document text.
type TextDocumentContentChangeEvent struct {
Text string `json:"text"`
}
// DidOpenTextDocumentParams is the params for textDocument/didOpen.
type DidOpenTextDocumentParams struct {
TextDocument TextDocumentItem `json:"textDocument"`
}
// DidChangeTextDocumentParams is the params for textDocument/didChange.
type DidChangeTextDocumentParams struct {
TextDocument VersionedTextDocumentIdentifier `json:"textDocument"`
ContentChanges []TextDocumentContentChangeEvent `json:"contentChanges"`
}
// DidSaveTextDocumentParams is the params for textDocument/didSave.
type DidSaveTextDocumentParams struct {
TextDocument TextDocumentIdentifier `json:"textDocument"`
Text *string `json:"text,omitempty"`
}
// DidCloseTextDocumentParams is the params for textDocument/didClose.
type DidCloseTextDocumentParams struct {
TextDocument TextDocumentIdentifier `json:"textDocument"`
}
// PublishDiagnosticsParams is the params for textDocument/publishDiagnostics.
type PublishDiagnosticsParams struct {
URI string `json:"uri"`
Diagnostics []Diagnostic `json:"diagnostics"`
}
// Diagnostic is a lint finding expressed in LSP terms.
type Diagnostic struct {
Range Range `json:"range"`
Severity int `json:"severity"` // 1=Error, 2=Warning, 3=Information, 4=Hint
Code string `json:"code,omitempty"`
Source string `json:"source,omitempty"`
Message string `json:"message"`
}
// Range is a zero-based line/character range within a text document.
type Range struct {
Start Position `json:"start"`
End Position `json:"end"`
}
// Position is a zero-based line and character offset.
type Position struct {
Line int `json:"line"`
Character int `json:"character"`
}
+78
View File
@@ -0,0 +1,78 @@
package model
import "testing"
// FuzzParseBytes ensures the YAML parser never panics on arbitrary input and
// that successful parses return a structurally sound Pipeline.
// Run with: go test -fuzz=FuzzParseBytes ./internal/model/
// Found failures are saved to testdata/fuzz/FuzzParseBytes/.
func FuzzParseBytes(f *testing.F) {
// Seed corpus: representative inputs covering the main code paths in
// ParseBytes, including the sanitizeYAMLEscapes pre-processing step.
seeds := [][]byte{
{},
[]byte("null"),
[]byte("stages: [build]\nbuild-job:\n stage: build\n script: echo ok\n"),
[]byte(".base:\n script: [make]\nchild:\n extends: .base\n stage: test\n"),
[]byte("stages: [a, b]\njob-a:\n stage: a\n script: run\njob-b:\n stage: b\n needs: [job-a]\n script: run\n"),
[]byte("*undefined_anchor"),
[]byte("- item1\n- item2\n"),
[]byte("my-job: \"just a string\"\n"),
[]byte("my-job:\n stage: [build, test]\n"),
[]byte("# glint: ignore GL007\nlegacy:\n only: [main]\n script: ok\n"),
[]byte("workflow:\n rules:\n - if: '$CI_COMMIT_BRANCH == \"main\"'\n when: always\njob:\n script: echo ok\n"),
[]byte("include:\n - local: other.yml\njob:\n script: echo ok\n"),
[]byte("job:\n script: echo ok\n when: on_failure\n rules:\n - if: '$VAR =~ /^us\\//'\n"),
[]byte("job:\n stage: test\n image:\n name: golang:1.21\n entrypoint: ['']\n parallel:\n matrix:\n - PLATFORM: [linux, darwin]\n script: go build\n"),
[]byte("default:\n retry: 2\n timeout: 1h30m\nvariables:\n ENV: production\nstages: [build, test, deploy]\n"),
[]byte("&anchor\n script: [echo ok]\njob:\n <<: *anchor\n stage: build\n"),
[]byte("?"), // null/empty YAML key — must error, not produce an empty-named job
}
for _, s := range seeds {
f.Add(s)
}
f.Fuzz(func(t *testing.T, data []byte) {
p, err := ParseBytes(data)
if err != nil {
return // errors are acceptable; panics are not
}
if p == nil {
t.Fatal("ParseBytes returned nil pipeline with nil error")
}
for name := range p.Jobs {
if name == "" {
t.Fatal("ParseBytes produced a job with an empty name")
}
}
})
}
// FuzzSanitizeYAMLEscapes ensures the escape sanitizer never panics and never
// produces output shorter than its input (it can only expand \/ to \\/).
func FuzzSanitizeYAMLEscapes(f *testing.F) {
seeds := [][]byte{
{},
[]byte("stage: build"),
[]byte(`if: "$CI_BRANCH =~ /^us\//"`),
[]byte(`"pattern: /^us\//"`),
[]byte(`'single quoted \/ unchanged'`),
[]byte(`"\n\t\r"`),
[]byte(`"nested \"quote\" inside"`),
[]byte(`'it''s fine'`),
[]byte(`"unclosed`),
{'"', '\\'}, // double-quoted string ending with a lone backslash
{'"', '\\', '/'}, // the exact sequence being rewritten
}
for _, s := range seeds {
f.Add(s)
}
f.Fuzz(func(t *testing.T, data []byte) {
out := sanitizeYAMLEscapes(data)
if len(out) < len(data) {
t.Fatalf("sanitizeYAMLEscapes shrank output: input len=%d output len=%d\ninput: %q",
len(data), len(out), data)
}
})
}
+5 -1
View File
@@ -57,6 +57,9 @@ func ParseBytes(data []byte) (*Pipeline, error) {
keyNode := root.Content[i]
valNode := root.Content[i+1]
key := keyNode.Value
if key == "" {
return nil, fmt.Errorf("job name cannot be empty (null or missing YAML key)")
}
if ReservedKeys[key] {
continue
}
@@ -80,7 +83,8 @@ func ParseBytes(data []byte) (*Pipeline, error) {
return nil, fmt.Errorf("parsing job %q: %w", key, err)
}
j.Name = key
j.Line = keyNode.Line // exact line of the job name key
j.Line = keyNode.Line // exact line of the job name key
j.Column = keyNode.Column // exact column of the job name key
p.Jobs[key] = j
}
+174
View File
@@ -0,0 +1,174 @@
package model
import (
"os"
"path/filepath"
"testing"
)
// ── Parse (file) ──────────────────────────────────────────────────────────────
func TestParse(t *testing.T) {
t.Run("valid file", func(t *testing.T) {
content := `
stages: [build]
build-job:
stage: build
script: [make]
`
dir := t.TempDir()
path := filepath.Join(dir, ".gitlab-ci.yml")
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
p, err := Parse(path)
if err != nil {
t.Fatalf("Parse: %v", err)
}
if p.SourceFile != path {
t.Errorf("SourceFile: got %q want %q", p.SourceFile, path)
}
if _, ok := p.Jobs["build-job"]; !ok {
t.Error("build-job not found")
}
if p.Jobs["build-job"].File != path {
t.Errorf("job File not set: %q", p.Jobs["build-job"].File)
}
})
t.Run("missing file", func(t *testing.T) {
_, err := Parse("/nonexistent/path/ci.yml")
if err == nil {
t.Fatal("expected error for missing file")
}
})
}
// ── ParseBytes edge cases ─────────────────────────────────────────────────────
func TestParseBytes_EdgeCases(t *testing.T) {
// Empty YAML: doc.Kind is not DocumentNode → return empty Pipeline.
p, err := ParseBytes([]byte(""))
if err != nil {
t.Fatalf("empty YAML: unexpected error: %v", err)
}
if p == nil || len(p.Jobs) != 0 {
t.Error("empty YAML: expected empty pipeline")
}
// YAML null: second pass succeeds, doc root is ScalarNode → return empty Pipeline (line 51-53).
p2, err := ParseBytes([]byte("null"))
if err != nil {
t.Fatalf("null YAML: unexpected error: %v", err)
}
if p2 == nil || len(p2.Jobs) != 0 {
t.Error("null YAML: expected empty pipeline")
}
// Invalid YAML (undefined alias): first-pass Unmarshal fails (line 34-36).
_, err = ParseBytes([]byte("*undefined_anchor"))
if err == nil {
t.Error("undefined alias: expected error from ParseBytes")
}
// Sequence YAML: second-pass Unmarshal fails (cannot decode !!seq into Pipeline).
_, err = ParseBytes([]byte("- item1\n- item2\n"))
if err == nil {
t.Error("sequence YAML: expected error from ParseBytes")
}
// Job value that cannot be decoded as map[string]any (scalar job value).
_, err = ParseBytes([]byte("my-job: \"just a string\"\n"))
if err == nil {
t.Error("scalar job value: expected error from ParseBytes")
}
// Job with wrong field type: rawMap decode succeeds, Job decode fails (line 79-81).
_, err = ParseBytes([]byte("my-job:\n stage: [build, test]\n"))
if err == nil {
t.Error("wrong field type: expected error from ParseBytes (stage must be string)")
}
// Null/empty YAML key (e.g. bare "?"): job name cannot be empty.
_, err = ParseBytes([]byte("?"))
if err == nil {
t.Error("null key: expected error from ParseBytes (job name cannot be empty)")
}
}
// TestParse_ParseBytesError exercises the Parse → ParseBytes error path (line 18).
func TestParse_ParseBytesError(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "bad.yml")
if err := os.WriteFile(path, []byte("my-job: \"scalar\"\n"), 0o644); err != nil {
t.Fatal(err)
}
_, err := Parse(path)
if err == nil {
t.Error("scalar job value: expected error from Parse")
}
}
// TestParseSuppressComment_NonIgnore ensures that a "glint:" directive that is
// not "ignore" is silently skipped.
func TestParseSuppressComment_NonIgnore(t *testing.T) {
result := parseSuppressComment("# glint: refresh GL001", "")
if result != nil {
t.Errorf("non-ignore glint directive should return nil, got %v", result)
}
}
// ── sanitizeYAMLEscapes ───────────────────────────────────────────────────────
func TestSanitizeYAMLEscapes(t *testing.T) {
cases := []struct {
name string
input string
want string
}{
{
name: "no double quotes — unchanged",
input: "stage: build",
want: "stage: build",
},
{
name: "double-quoted string without backslash",
input: `if: "$CI_BRANCH == \"main\""`,
want: `if: "$CI_BRANCH == \"main\""`,
},
{
// \/ inside double-quoted YAML string becomes \\/ (yaml.v3 does not recognise \/).
name: "slash escape rewritten in larger context",
input: `if: "$CI_BRANCH =~ /^us\//"`,
want: `if: "$CI_BRANCH =~ /^us\\//"`,
},
{
name: "backslash-slash inside double quotes rewritten",
input: `"pattern: /^us\//"` ,
want: `"pattern: /^us\\//"`,
},
{
name: "single-quoted string unchanged",
input: `'hello \/ world'`,
want: `'hello \/ world'`,
},
{
name: "escaped single quote inside single-quoted string",
input: `'it''s fine'`,
want: `'it''s fine'`,
},
{
name: "other backslash escapes unchanged",
input: `"\n\t\r"`,
want: `"\n\t\r"`,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := string(sanitizeYAMLEscapes([]byte(tc.input)))
if got != tc.want {
t.Errorf("got %q\nwant %q", got, tc.want)
}
})
}
}
+5 -3
View File
@@ -45,9 +45,10 @@ type Workflow struct {
}
type Job struct {
Name string // set by parser, not from YAML
File string // source file; set by Parse / resolver
Line int // line of the job key in its source file; set by parser
Name string // set by parser, not from YAML
File string // source file; set by Parse / resolver
Line int // line of the job key in its source file; set by parser
Column int // column of the job key (1-indexed); set by parser
Stage string `yaml:"stage"`
Script any `yaml:"script"` // []string or string (block scalar)
Run any `yaml:"run"` // alternative to script (CI steps)
@@ -89,6 +90,7 @@ type Rule struct {
Changes any `yaml:"changes"` // []string or {paths,compare_to} map
Exists any `yaml:"exists"` // []string or map form
Variables map[string]any `yaml:"variables"` // set/override variables when rule matches (GitLab CI 15.0+)
Needs []any `yaml:"needs"` // override needs: when this rule matches (GitLab CI 16.4+)
}
// ReservedKeys are top-level GitLab CI keys that are NOT job definitions.
+22
View File
@@ -0,0 +1,22 @@
package model
import (
"testing"
)
func TestSetJobOrigin(t *testing.T) {
p := &Pipeline{
Jobs: map[string]Job{
"with-file": {Name: "with-file", File: "existing.yml"},
"no-file": {Name: "no-file"},
},
}
p.SetJobOrigin("default.yml")
if p.Jobs["with-file"].File != "existing.yml" {
t.Error("job with existing File should not be overwritten")
}
if p.Jobs["no-file"].File != "default.yml" {
t.Errorf("job without File should be set: got %q", p.Jobs["no-file"].File)
}
}
@@ -0,0 +1,2 @@
go test fuzz v1
[]byte("?")
+4 -1
View File
@@ -80,12 +80,15 @@ func Resolve(p *model.Pipeline) ([]ExtendWarning, error) {
return extWarnings, fmt.Errorf("job %q: re-decoding merged definition: %w", name, err)
}
j.Name = name
// Preserve source location — File/Line are not part of the YAML map
// Preserve source location — these fields are not part of the YAML map
// and are lost during the encode/decode round-trip.
orig := p.Jobs[name]
j.File = orig.File
j.Line = orig.Line
j.Column = orig.Column
p.Jobs[name] = j
// Write merged raw map back so p.RawJobs always reflects post-extends state.
p.RawJobs[name] = merged
}
return extWarnings, nil
+293
View File
@@ -0,0 +1,293 @@
package resolver
import (
"fmt"
"testing"
"git.k3nny.fr/glint/internal/model"
)
// errorYAMLMarshaler implements yaml.Marshaler and always returns an error,
// allowing tests to trigger the yaml.Marshal failure path in Resolve.
type errorYAMLMarshaler struct{}
func (e errorYAMLMarshaler) MarshalYAML() (interface{}, error) {
return nil, fmt.Errorf("forced marshal error for test")
}
// ── parseExtends ──────────────────────────────────────────────────────────────
func TestParseExtends(t *testing.T) {
cases := []struct {
name string
input any
want []string
wantErr bool
}{
{name: "nil", input: nil, want: nil},
{name: "single string", input: "base", want: []string{"base"}},
{name: "slice of strings", input: []any{"base1", "base2"}, want: []string{"base1", "base2"}},
{name: "empty slice", input: []any{}, want: []string{}},
{name: "invalid item in slice", input: []any{42}, wantErr: true},
{name: "unexpected type", input: 123, wantErr: true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := parseExtends(tc.input)
if (err != nil) != tc.wantErr {
t.Fatalf("wantErr=%v got err=%v", tc.wantErr, err)
}
if !tc.wantErr {
if len(got) != len(tc.want) {
t.Fatalf("len mismatch: got %v want %v", got, tc.want)
}
for i := range got {
if got[i] != tc.want[i] {
t.Errorf("[%d] got %q want %q", i, got[i], tc.want[i])
}
}
}
})
}
}
// ── topoSort ──────────────────────────────────────────────────────────────────
func TestTopoSort(t *testing.T) {
t.Run("simple chain", func(t *testing.T) {
// a → b (b must come before a)
graph := map[string][]string{"a": {"b"}, "b": {}}
order, err := topoSort(graph)
if err != nil {
t.Fatal(err)
}
// b must appear before a
pos := func(s string) int {
for i, v := range order { if v == s { return i } }
return -1
}
if pos("b") > pos("a") {
t.Errorf("b should come before a, got %v", order)
}
})
t.Run("no deps", func(t *testing.T) {
graph := map[string][]string{"a": {}, "b": {}}
order, err := topoSort(graph)
if err != nil {
t.Fatal(err)
}
if len(order) != 2 {
t.Fatalf("expected 2 items, got %v", order)
}
})
t.Run("cycle detected", func(t *testing.T) {
graph := map[string][]string{"a": {"b"}, "b": {"a"}}
_, err := topoSort(graph)
if err == nil {
t.Fatal("expected cycle error")
}
})
t.Run("already visited node skipped", func(t *testing.T) {
// diamond: a→b, a→c, b→d, c→d
graph := map[string][]string{
"a": {"b", "c"},
"b": {"d"},
"c": {"d"},
"d": {},
}
order, err := topoSort(graph)
if err != nil {
t.Fatal(err)
}
// d must come first (or at least before b, c, a)
pos := func(s string) int {
for i, v := range order { if v == s { return i } }
return -1
}
if pos("d") > pos("b") || pos("d") > pos("c") {
t.Errorf("d should come before b and c, got %v", order)
}
})
}
// ── deepMerge ─────────────────────────────────────────────────────────────────
func TestDeepMerge(t *testing.T) {
t.Run("override wins on scalar", func(t *testing.T) {
base := map[string]any{"a": "base", "b": "keep"}
over := map[string]any{"a": "new"}
got := deepMerge(base, over)
if got["a"] != "new" || got["b"] != "keep" {
t.Errorf("got %v", got)
}
})
t.Run("nested maps merged recursively", func(t *testing.T) {
base := map[string]any{"m": map[string]any{"x": 1, "y": 2}}
over := map[string]any{"m": map[string]any{"y": 99, "z": 3}}
got := deepMerge(base, over)
m := got["m"].(map[string]any)
if m["x"] != 1 || m["y"] != 99 || m["z"] != 3 {
t.Errorf("nested merge wrong: %v", m)
}
})
t.Run("override scalar wins over base map", func(t *testing.T) {
base := map[string]any{"m": map[string]any{"x": 1}}
over := map[string]any{"m": "scalar"}
got := deepMerge(base, over)
if got["m"] != "scalar" {
t.Errorf("expected scalar to win, got %v", got["m"])
}
})
t.Run("base map + override scalar - base is map, override is scalar", func(t *testing.T) {
base := map[string]any{"k": map[string]any{"a": 1}}
over := map[string]any{"k": "replaced"}
got := deepMerge(base, over)
if got["k"] != "replaced" {
t.Errorf("got %v", got["k"])
}
})
t.Run("empty maps", func(t *testing.T) {
got := deepMerge(map[string]any{}, map[string]any{})
if len(got) != 0 {
t.Errorf("expected empty, got %v", got)
}
})
}
// ── Resolve ───────────────────────────────────────────────────────────────────
func buildPipeline(rawJobs map[string]map[string]any) *model.Pipeline {
p := &model.Pipeline{
Jobs: make(map[string]model.Job),
RawJobs: rawJobs,
}
for name, raw := range rawJobs {
ext := raw["extends"]
p.Jobs[name] = model.Job{Name: name, Extends: ext}
}
return p
}
func TestResolve(t *testing.T) {
t.Run("no extends — noop", func(t *testing.T) {
p := buildPipeline(map[string]map[string]any{
"build": {"script": []any{"make"}},
})
warnings, err := Resolve(p)
if err != nil || len(warnings) != 0 {
t.Fatalf("unexpected: err=%v warnings=%v", err, warnings)
}
})
t.Run("single extends merges fields", func(t *testing.T) {
p := buildPipeline(map[string]map[string]any{
".base": {"image": "alpine", "script": []any{"echo base"}},
"child": {"extends": ".base", "stage": "test"},
})
warnings, err := Resolve(p)
if err != nil || len(warnings) != 0 {
t.Fatalf("unexpected: err=%v warnings=%v", err, warnings)
}
child := p.Jobs["child"]
if child.Stage != "test" {
t.Errorf("stage not preserved: %q", child.Stage)
}
})
t.Run("unknown base produces warning, not error", func(t *testing.T) {
p := buildPipeline(map[string]map[string]any{
"child": {"extends": ".missing", "script": []any{"echo x"}},
})
warnings, err := Resolve(p)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(warnings) != 1 || warnings[0].Job != "child" || warnings[0].Base != ".missing" {
t.Errorf("expected warning about .missing, got %v", warnings)
}
})
t.Run("multi-extend list", func(t *testing.T) {
p := buildPipeline(map[string]map[string]any{
".a": {"image": "alpine"},
".b": {"tags": []any{"docker"}},
"child": {"extends": []any{".a", ".b"}, "script": []any{"echo x"}},
})
warnings, err := Resolve(p)
if err != nil || len(warnings) != 0 {
t.Fatalf("unexpected: %v %v", err, warnings)
}
})
t.Run("cycle returns error", func(t *testing.T) {
p := buildPipeline(map[string]map[string]any{
"a": {"extends": "b"},
"b": {"extends": "a"},
})
_, err := Resolve(p)
if err == nil {
t.Fatal("expected cycle error")
}
})
t.Run("file and line preserved after merge", func(t *testing.T) {
p := buildPipeline(map[string]map[string]any{
".base": {"script": []any{"echo base"}},
"child": {"extends": ".base"},
})
p.Jobs["child"] = model.Job{Name: "child", Extends: ".base", File: "ci.yml", Line: 10}
_, err := Resolve(p)
if err != nil {
t.Fatal(err)
}
if p.Jobs["child"].File != "ci.yml" || p.Jobs["child"].Line != 10 {
t.Errorf("file/line lost after merge: %+v", p.Jobs["child"])
}
})
t.Run("parseExtends invalid type returns error", func(t *testing.T) {
p := buildPipeline(map[string]map[string]any{
"job": {"extends": 123},
})
p.Jobs["job"] = model.Job{Name: "job", Extends: 123}
_, err := Resolve(p)
if err == nil {
t.Fatal("expected error for invalid extends type")
}
})
t.Run("yaml.Marshal fails — errorYAMLMarshaler injected into merged map", func(t *testing.T) {
// Inject a value whose MarshalYAML() returns an error so yaml.Marshal
// fails at extends.go:74-77.
p := buildPipeline(map[string]map[string]any{
".base": {"script": []any{"echo"}},
"child": {"extends": ".base", "bad": errorYAMLMarshaler{}},
})
_, err := Resolve(p)
if err == nil {
t.Fatal("expected error when yaml.Marshal fails")
}
})
t.Run("yaml.Unmarshal fails — map injected where []string expected", func(t *testing.T) {
// Marshal succeeds (maps are valid YAML), but Unmarshal into model.Job
// fails because Dependencies []string cannot hold a mapping.
p := buildPipeline(map[string]map[string]any{
".base": {
"dependencies": map[string]any{"invalid": "not-a-list"},
},
"child": {"extends": ".base", "stage": "build"},
})
_, err := Resolve(p)
if err == nil {
t.Fatal("expected error when yaml.Unmarshal fails on incompatible type")
}
})
}
+6 -3
View File
@@ -133,6 +133,12 @@ func resolveLocalInclude(p *model.Pipeline, rawPath string, cfg fetcher.GitLabCo
absPath := filepath.Join(rootDir, relPath)
label := "local " + rawPath
// Guard against path traversal: reject any path that escapes rootDir.
rel, err := filepath.Rel(rootDir, absPath)
if err != nil || strings.HasPrefix(rel, "..") {
return []IncludeWarning{{Label: label, Err: fmt.Errorf("path escapes repository root: %s", rawPath)}}, nil
}
if visited[absPath] {
return nil, nil
}
@@ -305,9 +311,6 @@ func substituteInputs(data []byte, inputs map[string]any) []byte {
}
return inputPlaceholderRe.ReplaceAllFunc(data, func(match []byte) []byte {
groups := inputPlaceholderRe.FindSubmatch(match)
if len(groups) < 2 {
return match
}
if val, ok := inputs[string(groups[1])]; ok {
return []byte(fmt.Sprintf("%v", val))
}
+784
View File
@@ -1,7 +1,16 @@
package resolver
import (
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"git.k3nny.fr/glint/internal/fetcher"
"git.k3nny.fr/glint/internal/model"
)
func TestSubstituteInputs(t *testing.T) {
@@ -88,3 +97,778 @@ 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")
}
}
func TestResolveLocalInclude_PathTraversal(t *testing.T) {
dir := t.TempDir()
p := &model.Pipeline{Jobs: map[string]model.Job{}, RawJobs: map[string]map[string]any{}}
// A path that escapes the repository root via "../.." must produce a warning,
// not silently read an arbitrary file.
warnings, _ := resolveLocalInclude(p, "../../etc/passwd", fetcher.GitLabConfig{}, dir, map[string]bool{}, 0)
if len(warnings) == 0 {
t.Fatal("expected warning for path traversal, got none")
}
if !strings.Contains(warnings[0].Err.Error(), "path escapes") {
t.Errorf("unexpected warning: %v", warnings[0])
}
if len(p.Jobs) != 0 {
t.Error("no jobs should be merged when path escapes root")
}
}
func TestResolveLocalInclude_PathTraversalWithLeadingSlash(t *testing.T) {
dir := t.TempDir()
p := &model.Pipeline{Jobs: map[string]model.Job{}, RawJobs: map[string]map[string]any{}}
warnings, _ := resolveLocalInclude(p, "/../../etc/shadow", fetcher.GitLabConfig{}, dir, map[string]bool{}, 0)
if len(warnings) == 0 {
t.Fatal("expected warning for path traversal via leading slash, got none")
}
}
// ── 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)
}
}
+62
View File
@@ -0,0 +1,62 @@
# glint — GitLab CI/CD Catalog component
#
# Validates a pipeline file with glint before the rest of the pipeline runs.
#
# Usage (after publishing to a GitLab instance as a Catalog component):
#
# include:
# - component: $CI_SERVER_FQDN/k3nny/glint/check@v0.2.28
# inputs:
# stage: validate # optional — see inputs below
#
# Or as a plain remote include (no Catalog required):
#
# include:
# - remote: https://raw.githubusercontent.com/.../templates/check.yml
#
# Or copy this file into your repository and use a local include.
spec:
inputs:
stage:
description: "Stage in which to run the glint validation job."
default: validate
pipeline_file:
description: "Path to the pipeline file to validate."
default: .gitlab-ci.yml
version:
description: >-
glint release tag to download (e.g. 'v0.2.28'). Use 'latest' to
always pull the newest release — not recommended for production
pipelines since it may break on a new release.
default: latest
allow_failure:
description: "Set to true to let the job fail without blocking the pipeline."
default: false
extra_args:
description: "Additional arguments passed to 'glint check' (e.g. '--format sarif')."
default: ""
---
glint:check:
stage: $[[ inputs.stage ]]
image: alpine:3.19
variables:
GLINT_VERSION: "$[[ inputs.version ]]"
GLINT_FILE: "$[[ inputs.pipeline_file ]]"
GLINT_ARGS: "$[[ inputs.extra_args ]]"
before_script:
- apk add --no-cache curl
- |
if [ "$GLINT_VERSION" = "latest" ]; then
GLINT_VERSION=$(curl -sf \
"https://git.k3nny.fr/api/v1/repos/k3nny/glint/releases?limit=1" \
| grep '"tag_name"' | head -1 | cut -d'"' -f4)
fi
URL="https://git.k3nny.fr/k3nny/glint/releases/download/${GLINT_VERSION}/glint-${GLINT_VERSION}-linux-amd64"
curl -sfL "$URL" -o /usr/local/bin/glint
chmod +x /usr/local/bin/glint
glint --version
script:
- glint check $GLINT_ARGS "$GLINT_FILE"
allow_failure: $[[ inputs.allow_failure ]]
+26
View File
@@ -0,0 +1,26 @@
stages:
- build
- test
# No default: block — any inherit: default: declaration is a no-op.
# GL043: inherit: default: false but no default: block.
isolated-job:
stage: build
inherit:
default: false
script: echo isolated
# GL043: inherit: default: [list] but no default: block.
selective-inherit-job:
stage: test
inherit:
default: [image, tags]
script: echo selective
# Not GL043: inherit only touches variables: (no default: check needed).
vars-inherit-job:
stage: test
inherit:
variables: false
script: echo vars-only
+20
View File
@@ -0,0 +1,20 @@
stages:
- build
- test
default:
image: node:20
# GL043: before_script is in the list but not defined in default:.
selective-bad:
stage: build
inherit:
default: [image, before_script] # before_script not in default:
script: echo hi
# Not GL043: image IS defined in default: — list is valid.
selective-good:
stage: test
inherit:
default: [image]
script: echo hi
+8
View File
@@ -0,0 +1,8 @@
stages: [build]
include:
- remote: http://ci-templates.example.invalid/template.yml
build-job:
stage: build
script: echo hello
+4
View File
@@ -9,6 +9,10 @@ workflow:
when: always
- when: never
default:
tags:
- docker
variables:
GO_VERSION: "1.26"
+15
View File
@@ -0,0 +1,15 @@
stages:
- build
- test
build-job:
stage: build
script: make
test-job:
stage: test
script: make test
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
needs: [build-job, nonexistent-job] # nonexistent-job does not exist → GL044
- when: on_success
+19
View File
@@ -0,0 +1,19 @@
stages:
- build
- test
build-job:
stage: build
script: make
lint-job:
stage: build
script: make lint
test-job:
stage: test
script: make test
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
needs: [build-job, lint-job] # both exist → clean
- when: on_success
+48
View File
@@ -0,0 +1,48 @@
stages:
- deploy
- test
variables:
DEPLOY: "false"
SKIP_TEST: "true"
# GL042: both if: conditions always evaluate to false given the declared variables.
always-dead-deploy:
stage: deploy
rules:
- if: '$DEPLOY == "true"'
when: on_success
script: echo deploy
# GL042: if: is false, explicit when:on_success rule — still never fires.
always-dead-test:
stage: test
rules:
- if: '$SKIP_TEST == "false"'
when: on_success
script: echo test
# Not GL042: references a predefined CI_ variable → unknown value → skip check.
predefined-var-job:
stage: test
rules:
- if: '$CI_COMMIT_BRANCH == "nonexistent-branch"'
when: on_success
script: echo branch
# Not GL042: has an unconditional rule (no if: → always matches).
unconditional-job:
stage: test
rules:
- if: '$DEPLOY == "true"'
when: never
- when: on_success
script: echo always
# Not GL042: references undeclared variable → unknown → conservative.
undeclared-var-job:
stage: test
rules:
- if: '$UNDECLARED_VAR == "something"'
when: on_success
script: echo undeclared