Compare commits

...

11 Commits

Author SHA1 Message Date
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
35 changed files with 5288 additions and 211 deletions
+5
View File
@@ -33,6 +33,11 @@ coverage.txt
*.swo *.swo
*~ *~
# VS Code extension build artifacts
editors/vscode/node_modules/
editors/vscode/out/
editors/vscode/*.vsix
# OS # OS
.DS_Store .DS_Store
Thumbs.db 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'
+56
View File
@@ -5,6 +5,62 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
This project uses [Semantic Versioning](https://semver.org). This project uses [Semantic Versioning](https://semver.org).
## [0.2.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 ## [0.2.24] - 2026-06-25
### Added ### Added
+22 -1
View File
@@ -282,8 +282,29 @@ jobs or pipeline-level findings.
| `pipeline` | GitLab CI-style SVG/PNG written to `--out` directory (default: `glint-out/`); converted to PNG when `rsvg-convert`, `inkscape`, or `magick` is available | | `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 | | `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 In DAG pipelines (any job has `needs:`) the pipeline graph uses job-to-job
Bézier connectors instead of stage-to-stage lines. 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.
--- ---
+85 -3
View File
@@ -6,7 +6,7 @@
<p align="center"> <p align="center">
<a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache%202.0-blue.svg" alt="License"></a> <a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache%202.0-blue.svg" alt="License"></a>
<a href="CHANGELOG.md"><img src="https://img.shields.io/badge/release-v0.2.24-blue.svg" alt="Release"></a> <a href="CHANGELOG.md"><img src="https://img.shields.io/badge/release-v0.2.30-blue.svg" alt="Release"></a>
</p> </p>
> **Disclaimer:** This tool was built through iterative AI-assisted development with [Claude](https://claude.ai). It is experimental, incomplete, and not intended for production use. Coverage of GitLab CI keywords is best-effort and may lag behind GitLab's evolving spec. Use it at your own discretion — no correctness guarantees are made. Contributions and bug reports are welcome. > **Disclaimer:** This tool was built through iterative AI-assisted development with [Claude](https://claude.ai). It is experimental, incomplete, and not intended for production use. Coverage of GitLab CI keywords is best-effort and may lag behind GitLab's evolving spec. Use it at your own discretion — no correctness guarantees are made. Contributions and bug reports are welcome.
@@ -20,7 +20,9 @@ A local tool to validate and lint `.gitlab-ci.yml` pipelines without needing a G
- **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 - **Simulates context** — `--branch`, `--tag`, `--source` flags evaluate `rules:if:` and `only`/`except` to show which jobs would be active, manual, or skipped; `--context branch=main --context branch=develop` prints a multi-column comparison table across multiple contexts in one run
- **Multiple output formats** — `--format text` (default, ruff-style), `json`, `sarif` (GitHub Code Scanning / GitLab SAST), `junit`, `github` (PR annotations) - **Multiple output formats** — `--format text` (default, ruff-style), `json`, `sarif` (GitHub Code Scanning / GitLab SAST), `junit`, `github` (PR annotations)
- **Project config** — `.glint.yml` for rule suppression, severity overrides, token/URL defaults; `# glint: ignore RULE` for per-job inline suppression - **Project config** — `.glint.yml` for rule suppression, severity overrides, token/URL defaults; `# glint: ignore RULE` for per-job inline suppression
- **Graph visualization** — `glint graph` prints a terminal job tree; `glint graph pipeline` renders a GitLab CI-style SVG/PNG - **Graph visualization** — `glint graph` prints a terminal job tree; `glint graph pipeline` renders a GitLab CI-style SVG/PNG; `--format mermaid` emits a Mermaid flowchart; `--format html` produces a self-contained HTML file with pan/zoom and a job-detail sidebar; context flags grey out skipped jobs
- **LSP server** — `glint lsp` starts a Language Server Protocol server over stdin/stdout; connect with any LSP client to get inline diagnostics (rule ID as code, error/warning severity) in VS Code, Neovim, Emacs, JetBrains, etc.
- **VS Code extension** — `editors/vscode/` wraps the LSP server; inline squiggles for every glint rule directly in the editor
See [FEATURES.md](FEATURES.md) for the complete feature reference and lint rules table, and [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.
@@ -32,7 +34,7 @@ See [FEATURES.md](FEATURES.md) for the complete feature reference and lint rules
## Installation ## Installation
```bash ```bash
git clone https://git.k3nny.fr/glint git clone https://git.k3nny.fr/k3nny/glint
cd glint cd glint
go build -o glint ./cmd/glint/... go build -o glint ./cmd/glint/...
``` ```
@@ -52,12 +54,82 @@ Commands:
check Lint a pipeline file — exits 0 (clean) or 1 (errors found) check Lint a pipeline file — exits 0 (clean) or 1 (errors found)
graph Visualise the pipeline as a job tree or Mermaid graph graph Visualise the pipeline as a job tree or Mermaid graph
explain Print description and fix for a lint rule explain Print description and fix for a lint rule
lsp Start a Language Server Protocol server (stdin/stdout)
``` ```
Run `glint <command> --help` for all flags. See [USAGE.md](USAGE.md) for full Run `glint <command> --help` for all flags. See [USAGE.md](USAGE.md) for full
examples covering output formats, context simulation, remote includes, cache, examples covering output formats, context simulation, remote includes, cache,
graph modes, and project configuration. graph modes, and project configuration.
## Integrations
### Pre-commit hook
Add to `.pre-commit-config.yaml` in your repository to run glint automatically whenever `.gitlab-ci.yml` changes:
```yaml
repos:
- repo: https://git.k3nny.fr/k3nny/glint
rev: v0.2.28
hooks:
- id: glint
```
Requires [pre-commit](https://pre-commit.com) and Go 1.21+. On first run, pre-commit builds glint from source automatically.
### GitLab CI component
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
# As a local include (copy templates/check.yml to your repo first):
include:
- local: .gitlab/glint-check.yml
# 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
```
The component downloads the glint Linux binary, runs `glint check`, and respects all inputs defined in the `spec:` block.
### GitHub Actions
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
cd editors/vscode
npm install # install dependencies (once)
npm run compile # compile TypeScript → out/
```
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.
Make sure `glint` is on your `PATH`, or set `glint.executablePath` in VS Code settings to the full path of the binary.
To package a `.vsix` for local installation:
```bash
task ext-package # produces glint-X.Y.Z.vsix
code --install-extension glint-X.Y.Z.vsix
```
## Development ## Development
This project uses [Task](https://taskfile.dev) as a task runner. This project uses [Task](https://taskfile.dev) as a task runner.
@@ -69,11 +141,21 @@ task test # run Go unit tests
task lint-go # run go vet task lint-go # run go vet
task validate # run the binary against all testdata fixtures task validate # run the binary against all testdata fixtures
task ci # full check: vet → test → build → validate 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-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 build-linux # cross-compile for Linux x64 (requires a tagged commit → glint-<tag>-linux-amd64)
task clean # remove build artifacts 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 ## Project structure
``` ```
+68 -70
View File
@@ -8,23 +8,23 @@ This document tracks planned improvements to `glint`. Items are grouped by theme
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. 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.
- ~~**Single-context simulation**~~ — ✓ shipped v0.2.0; `--branch`, `--tag`, `--source`, `--var` flags on both subcommands; jobs classified as active / manual / skipped - [x] **Single-context simulation** shipped v0.2.0; `--branch`, `--tag`, `--source`, `--var` flags on both subcommands; jobs classified as active / manual / skipped
- ~~**`workflow:rules:variables:` propagation**~~ — ✓ shipped post-v0.2.0; variables from the matching workflow rule entry injected into the evaluation context before job `rules:if:` expressions are evaluated - [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
- ~~**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: multi-line `if:` values** shipped post-v0.2.0; newlines in block-scalar and folded YAML `if:` values treated as whitespace
- ~~**Expression evaluator: `${VAR}` syntax**~~ — ✓ shipped post-v0.2.0; `${CI_COMMIT_BRANCH}` equivalent to `$CI_COMMIT_BRANCH` everywhere - [x] **Expression evaluator: `${VAR}` syntax** shipped post-v0.2.0; `${CI_COMMIT_BRANCH}` equivalent to `$CI_COMMIT_BRANCH` everywhere
- ~~**Expression evaluator: regex flags**~~ — ✓ shipped post-v0.2.0; `/pattern/i`, `/pattern/m`, `/pattern/s` supported - [x] **Expression evaluator: regex flags** shipped post-v0.2.0; `/pattern/i`, `/pattern/m`, `/pattern/s` supported
- ~~**Expression evaluator: variable as regex RHS**~~ — ✓ shipped post-v0.2.0; `$BRANCH =~ $PATTERN` where `$PATTERN` holds a `/regex/` string evaluates correctly - [x] **Expression evaluator: variable as regex RHS** shipped post-v0.2.0; `$BRANCH =~ $PATTERN` where `$PATTERN` holds a `/regex/` string evaluates correctly
- ~~**Expression evaluator: bare `true`/`false` and integer literals**~~ — ✓ shipped post-v0.2.0; `$FLAG == true`, `$COUNT == 4` compare as decimal strings matching GitLab CI behaviour - [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
- ~~**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] **Implicit default context** shipped v0.2.11; defaults to `--branch main --source push` when no context flag is given, so `rules:if:` is always evaluated
- ~~**`--list-vars` debug flag**~~ — ✓ shipped v0.2.11; prints sorted `KEY=VALUE` of all collected pipeline variables (root file + includes + workflow-rule union + effective context) to stderr - [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
- ~~**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] **Variable expansion** shipped v0.2.13; `$VAR`/`${VAR}` references within variable values expanded after all sources are merged; transitive chains resolved; visible in `--list-vars`
- ~~**Non-string scalar variables**~~ — ✓ shipped v0.2.13; `BUILD: true`, `RETRIES: 3` rendered and injected correctly instead of being silently dropped - [x] **Non-string scalar variables** shipped v0.2.13; `BUILD: true`, `RETRIES: 3` rendered and injected correctly instead of being silently dropped
- ~~**YAML `\/` escape in double-quoted strings**~~ — ✓ shipped v0.2.13; regex patterns like `/^us\//` in double-quoted `if:` blocks no longer cause a parse error - [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
- ~~**Workflow rule strict evaluation**~~ — ✓ shipped v0.2.14; unparseable `if:` skips the rule instead of matching everything; prevents wrong variables being injected - [x] **Workflow rule strict evaluation** shipped v0.2.14; unparseable `if:` skips the rule instead of matching everything; prevents wrong variables being injected
- ~~**Single `=` operator**~~ — ✓ shipped v0.2.14; bare `=` accepted as alias for `==` in `rules:if:` expressions - [x] **Single `=` operator** shipped v0.2.14; bare `=` accepted as alias for `==` in `rules:if:` expressions
- ~~**`rules:changes:` evaluation**~~ — ✓ shipped v0.2.21; `--changes PATH` and `--changes-from REF` flags; doublestar glob matching (`*` within segment, `**` across segments); permissive when no file list provided - [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
- ~~**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] **Multi-context simulation** shipped v0.2.22; `--context KEY=VALUE[,...]`; repeatable; prints a comparison table of `active`/`manual`/`skipped`/`blocked` per job across all contexts
- ~~**Context-scoped linting**~~ — ✓ shipped v0.2.23; jobs evaluated as `JobSkipped` in the supplied context are excluded from `needs:`/`dependencies:` cross-checks (GL027GL031) to eliminate false-positive errors for conditionally-gated jobs - [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
--- ---
@@ -32,36 +32,36 @@ Pass `--branch`, `--tag`, `--source`, or `--var` to `glint check` or `glint grap
The current rule set covers the most common sources of broken pipelines. These are the gaps most likely to matter in practice. The current rule set covers the most common sources of broken pipelines. These are the gaps most likely to matter in practice.
- ~~**Variable reference validation (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] **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 - [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
- ~~**`services:` validation (GL034)**~~ — ✓ shipped v0.2.16; map form requires `name`; `alias` must be a valid DNS label - [x] **`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 - [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
- ~~**`timeout` format validation (GL036)**~~ — ✓ shipped v0.2.16; validates job-level and `default.timeout` against recognised GitLab CI duration strings - [x] **`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 - [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
- ~~**`pages:publish` + `artifacts.paths` consistency (GL039)**~~ — ✓ shipped v0.2.16; warns when the publish directory is missing from `artifacts.paths` - [x] **`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:` - [x] **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 - [x] **`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 - [x] **Unreachable jobs** — covered by GL033 (shipped v0.2.15); every-`when:never` rules block is statically dead
- ~~**`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:` - [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 resolution
- ~~**`include: local:`** full resolution~~ — ✓ shipped in v0.2.0; local files are read from disk, recursively resolved, and merged before linting - [x] **`include: local:` full resolution** — shipped 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 - [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
- ~~**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] **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` - [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`
- ~~**`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: inputs:`** shipped v0.2.17; `$[[ inputs.KEY ]]` and `$[[ inputs.KEY | default(…) ]]` placeholders in fetched component YAML are substituted from the include's `with:` block before parsing
--- ---
## Output formats — ✓ shipped v0.2.18 ## Output formats
- ~~**JSON** (`--format json`)~~ — ✓ shipped v0.2.18; machine-readable findings with stable schema (version 1) - [x] **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 - [x] **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 - [x] **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] **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
--- ---
@@ -69,53 +69,51 @@ The current rule set covers the most common sources of broken pipelines. These a
The SVG renderer and terminal tree cover the basic layout. These would bring it closer to GitLab's full interactive view. 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 - [x] **Terminal job tree** — shipped 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 - [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
- **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 - [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
- **Job tooltip / detail panel** — embed a hidden `<title>` and `<desc>` per chip so SVG viewers show `stage`, `when`, `image`, and `needs` on hover - [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
- **`when: on_failure` visual distinction** — dashed border or distinct icon for failure-path jobs - [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
- **Blocked / skipped state colouring** — grey out jobs that are statically unreachable given known `rules:` conditions - [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
- **Interactive HTML output** — self-contained `.html` file with pan/zoom and a job-detail sidebar; no external dependencies - [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
- **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] **Mermaid pipeline output**shipped v0.2.25; `glint graph pipeline --format mermaid` prints a Mermaid flowchart to stdout (paste into mermaid.live)
- [x] **Same-stage job ordering** — shipped v0.2.26; jobs within a stage that have `needs:` between each other are placed in topological sub-columns (left-to-right by depth); stage header spans all sub-columns
- [x] **Graph links rendered behind job chips** — shipped v0.2.26; SVG connectors (Bézier curves and bus-bar stubs) are drawn before job chips so lines pass behind rectangles
--- ---
## Findings quality — ✓ file and line numbers shipped post-v0.2.0; ruff-style format shipped v0.2.11 ## Findings quality
~~**File and line numbers on findings**~~ shipped post-v0.2.0; every finding includes the source file and exact line of the job key. Works across local includes, remote project templates, and fetched component templates. - [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
~~**Ruff-style output format**~~ shipped v0.2.11; findings follow `file:line: RULEID [severity] message` matching the convention used by ruff and other modern linters. - [x] **`needs: optional: true` false-positive errors** — shipped post-v0.2.0; optional missing needs are downgraded to `[WARNING]`
- [x] **`extends:` jobs with missing script false errors** — shipped post-v0.2.0; jobs using `extends:` that have no `script` after resolution emit `[WARNING]` (the script may come from an unfetchable remote base)
**Remaining improvements** - [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)
- ~~**`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 (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 ## 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 - [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
- **GitHub Actions action** — `uses: k3nny/glint@v1` wrapper for repositories that mirror or manage GitLab pipelines from GitHub - [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`
- **Pre-commit hook** — entry for [pre-commit](https://pre-commit.com) so `glint` runs automatically on `git commit` when `.gitlab-ci.yml` changes - [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
- **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 - [x] **LSP server**shipped v0.2.29; `glint lsp` runs a JSON-RPC 2.0 LSP server over stdin/stdout; `textDocument/didOpen`, `didChange`, `didSave`, `didClose` all publish diagnostics; rule IDs appear as the diagnostic `code`; include resolution is best-effort using env-var token and default cache dir
- **VS Code extension** — thin wrapper around the LSP server with syntax highlighting for `.gitlab-ci.yml` - [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 - [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
- ~~**Inline suppression comments**~~ — ✓ shipped v0.2.19; `# glint: ignore GL007` before a job definition; comma/space-separated rules; `# glint: ignore all` wildcard - [x] **Inline suppression comments** shipped v0.2.19; `# glint: ignore GL007` before a job definition; comma/space-separated rules; `# glint: ignore all` wildcard
--- ---
## Reliability and developer experience ## 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; GL042GL043 added v0.2.20; GL044 added v0.2.24 - [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
- ~~**`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] **`glint explain <rule-id>`** shipped v0.2.20; prints rule description, rationale, bad-YAML example, and fix; `glint explain` (no arg) lists all rules
- ~~**Semantic versioning and first release**~~ — shipped as `v0.1.0` (2026-06-07) - [x] **Semantic versioning and first release** — shipped 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` - [x] **Subcommand CLI** — shipped 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 - [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
- **Fuzz testing** — add a `go test -fuzz` target for the YAML parser to harden it against malformed input - [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)
+33
View File
@@ -175,6 +175,39 @@ tasks:
generates: generates:
- "{{.BINARY}}-{{.TAG}}-linux-amd64" - "{{.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: clean:
desc: Remove build artifacts desc: Remove build artifacts
cmd: rm -f {{.BINARY}} {{.BINARY}}-*.exe {{.BINARY}}-*-linux-amd64 cmd: rm -f {{.BINARY}} {{.BINARY}}-*.exe {{.BINARY}}-*-linux-amd64
+32 -1
View File
@@ -206,7 +206,16 @@ glint graph includes .gitlab-ci.yml > includes.mmd
glint graph pipeline .gitlab-ci.yml glint graph pipeline .gitlab-ci.yml
# prints the output path, e.g.: glint-out/pipeline-20260614-143022.png # prints the output path, e.g.: glint-out/pipeline-20260614-143022.png
# Mermaid to stdout + pipeline file path to stderr # 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 glint graph all .gitlab-ci.yml > includes.mmd
# Custom output directory # Custom output directory
@@ -224,6 +233,28 @@ include type: orange (main file), purple (project), green (component), blue
**Pipeline graph** — GitLab CI-style SVG rendered to a timestamped file. **Pipeline graph** — GitLab CI-style SVG rendered to a timestamped file.
Converted to PNG when `rsvg-convert`, `inkscape`, or `magick` is available. 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 DAG mode (Bézier arrows between jobs) activates automatically when any job has
a `needs:` list; classic mode uses stage-column connectors otherwise. a `needs:` list; classic mode uses stage-column connectors otherwise.
+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"
+71
View File
@@ -0,0 +1,71 @@
package main
import (
"flag"
"fmt"
"os"
"git.k3nny.fr/glint/internal/fetcher"
"git.k3nny.fr/glint/internal/lsp"
)
func cmdLSP(args []string) {
fs := flag.NewFlagSet("glint lsp", flag.ExitOnError)
token := fs.String("token", "", "GitLab personal access token (overrides GITLAB_TOKEN)")
gitlabURL := fs.String("gitlab-url", "", "GitLab instance URL (overrides CI_SERVER_URL / GITLAB_URL)")
cacheDir := fs.String("cache-dir", "", "directory for caching fetched remote includes")
offline := fs.Bool("offline", false, "skip all network calls; serve only from --cache-dir")
fs.Usage = func() {
fmt.Fprintf(os.Stderr, "glint %s\n\n", version)
fmt.Fprint(os.Stderr, `Start a Language Server Protocol server for .gitlab-ci.yml files.
Reads JSON-RPC 2.0 messages from stdin and writes responses to stdout using
the standard Content-Length framing. Connect with any LSP client (VS Code,
Neovim, Emacs, etc.).
Usage: glint lsp [OPTIONS]
Options:
--token <TOKEN>
GitLab personal access token used for resolving project: and
component: includes. Defaults to GITLAB_TOKEN env var.
--gitlab-url <URL>
GitLab instance URL for resolving remote includes.
[env: CI_SERVER_URL | GITLAB_URL] [default: https://gitlab.com]
--cache-dir <DIR>
Cache directory for fetched remote includes. Defaults to
~/.cache/glint so subsequent opens are served from cache.
--offline
Do not make any network calls; resolve only local includes.
Implies --cache-dir default (~/.cache/glint) when not set.
-h, --help
Print help
Examples:
glint lsp
glint lsp --token glpat-xxxx --cache-dir ~/.cache/glint
glint lsp --offline
`)
}
_ = fs.Parse(args)
resolvedCacheDir := *cacheDir
if resolvedCacheDir == "" {
resolvedCacheDir = defaultCacheDir()
}
if *offline && resolvedCacheDir == "" {
resolvedCacheDir = defaultCacheDir()
}
cfg := fetcher.AutoConfig().WithOverrides(*gitlabURL, *token, resolvedCacheDir, *offline)
srv := lsp.New(os.Stdin, os.Stdout, cfg, version)
if err := srv.Run(); err != nil {
fmt.Fprintf(os.Stderr, "glint lsp: %v\n", err)
exit(2)
}
}
+31 -3
View File
@@ -69,6 +69,7 @@ Commands:
check Lint a pipeline file — exits 0 (clean) or 1 (errors found) check Lint a pipeline file — exits 0 (clean) or 1 (errors found)
graph Visualise the pipeline as a job tree or Mermaid graph graph Visualise the pipeline as a job tree or Mermaid graph
explain Show description and fix for a lint rule (e.g. glint explain GL007) explain Show description and fix for a lint rule (e.g. glint explain GL007)
lsp Start a Language Server Protocol server (stdin/stdout)
Options: Options:
-h, --help Print help -h, --help Print help
@@ -90,6 +91,8 @@ func main() {
cmdGraph(os.Args[2:]) cmdGraph(os.Args[2:])
case "explain": case "explain":
cmdExplain(os.Args[2:]) cmdExplain(os.Args[2:])
case "lsp":
cmdLSP(os.Args[2:])
case "-h", "--help", "help": case "-h", "--help", "help":
fmt.Fprintf(os.Stderr, "glint %s\n\n", version) fmt.Fprintf(os.Stderr, "glint %s\n\n", version)
fmt.Fprint(os.Stderr, globalUsage) fmt.Fprint(os.Stderr, globalUsage)
@@ -445,7 +448,8 @@ func cmdGraph(args []string) {
gitlabURL := fs.String("gitlab-url", "", "GitLab instance URL (overrides CI_SERVER_URL / GITLAB_URL)") gitlabURL := fs.String("gitlab-url", "", "GitLab instance URL (overrides CI_SERVER_URL / GITLAB_URL)")
cacheDir := fs.String("cache-dir", "", "directory to cache fetched remote includes (created if needed)") 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") offline := fs.Bool("offline", false, "skip all network calls; serve only from --cache-dir")
out := fs.String("out", "glint-out", "output directory for Mermaid graph files (pipeline mode)") out := fs.String("out", "glint-out", "output directory for rendered graph files (pipeline mode)")
format := fs.String("format", "svg", "pipeline output format: svg, mermaid, or html")
fs.Usage = func() { fs.Usage = func() {
fmt.Fprintf(os.Stderr, "glint %s\n\n", version) fmt.Fprintf(os.Stderr, "glint %s\n\n", version)
fmt.Fprint(os.Stderr, `Visualise the pipeline as a job tree and/or Mermaid graph. fmt.Fprint(os.Stderr, `Visualise the pipeline as a job tree and/or Mermaid graph.
@@ -462,6 +466,15 @@ Options:
Output directory for rendered graph files. Output directory for rendered graph files.
Used by the pipeline and all modes only. [default: glint-out] 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> --token <TOKEN>
GitLab personal access token. Used to fetch remote project: includes GitLab personal access token. Used to fetch remote project: includes
when building the include dependency graph. when building the include dependency graph.
@@ -518,6 +531,8 @@ Examples:
glint graph includes .gitlab-ci.yml > includes.mmd glint graph includes .gitlab-ci.yml > includes.mmd
glint graph pipeline .gitlab-ci.yml glint graph pipeline .gitlab-ci.yml
glint graph pipeline --out /tmp/graphs .gitlab-ci.yml glint graph pipeline --out /tmp/graphs .gitlab-ci.yml
glint graph pipeline --format mermaid .gitlab-ci.yml
glint graph pipeline --format html .gitlab-ci.yml
glint graph all .gitlab-ci.yml > includes.mmd glint graph all .gitlab-ci.yml > includes.mmd
`) `)
} }
@@ -602,16 +617,29 @@ Examples:
case "includes": case "includes":
fmt.Print(graph.Includes(path, p.Include, cfg)) fmt.Print(graph.Includes(path, p.Include, cfg))
case "pipeline": case "pipeline":
outPath, err := graph.RenderPipeline(p, *out) switch *format {
case "mermaid":
fmt.Print(graph.Pipeline(p))
case "html":
outPath, err := graph.RenderHTML(p, *out, ctx)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "error: rendering pipeline graph: %v\n", err) fmt.Fprintf(os.Stderr, "error: rendering pipeline graph: %v\n", err)
exit(2) exit(2)
return return
} }
fmt.Println(outPath) 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)
}
case "all": case "all":
fmt.Print(graph.Includes(path, p.Include, cfg)) fmt.Print(graph.Includes(path, p.Include, cfg))
outPath, err := graph.RenderPipeline(p, *out) outPath, err := graph.RenderPipeline(p, *out, ctx)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "error: rendering pipeline graph: %v\n", err) fmt.Fprintf(os.Stderr, "error: rendering pipeline graph: %v\n", err)
exit(2) exit(2)
+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 go 1.26.4
require gopkg.in/yaml.v3 v3.0.1
require ( require (
github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c // indirect 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/exp/typeparams v0.0.0-20231108232855-2478ac86f678 // indirect
golang.org/x/mod v0.31.0 // indirect golang.org/x/mod v0.35.0 // indirect
golang.org/x/sync v0.19.0 // indirect golang.org/x/sync v0.20.0 // indirect
golang.org/x/tools v0.40.1-0.20260108161641-ca281cf95054 // indirect golang.org/x/tools v0.44.1-0.20260420230617-19499e7caabc // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect honnef.co/go/tools v0.8.0-rc.1 // indirect
honnef.co/go/tools v0.7.0 // indirect
) )
tool honnef.co/go/tools/cmd/staticcheck 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 h1:pxW6RcqyfI9/kWtOwnv/G+AzdKuy2ZrqINhenH4HyNs=
github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= 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 h1:1P7xPZEwZMoBoz0Yze5Nx2/4pxj6nw9ZqHWXqP0iRgQ=
golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= 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.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/tools v0.40.1-0.20260108161641-ca281cf95054 h1:CHVDrNHx9ZoOrNN9kKWYIbT5Rj+WF2rlwPkhbQQ5V4U= golang.org/x/tools v0.44.1-0.20260420230617-19499e7caabc h1:vSv/HN1q9eoPD7lMyJYVJ/GPYnqtqu6adMxUmrxOB78=
golang.org/x/tools v0.40.1-0.20260108161641-ca281cf95054/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= 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/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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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.8.0-rc.1 h1:wqMm2kjcEXMOr+6yau+pdKqJKe6l2N1aKPkpini+Kzk=
honnef.co/go/tools v0.7.0/go.mod h1:pm29oPxeP3P82ISxZDgIYeOaf9ta6Pi0EWvCFoLG2vc= honnef.co/go/tools v0.8.0-rc.1/go.mod h1:XA+OnlRA9EDh/ukGvXMNSZNKGwFQJ+5dER0ioUkOxks=
+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)
})
}
+3
View File
@@ -27,6 +27,7 @@ func Pipeline(p *model.Pipeline) string {
w(" classDef manual fill:#fc6d26,stroke:#e56b1f,color:#fff") w(" classDef manual fill:#fc6d26,stroke:#e56b1f,color:#fff")
w(" classDef trigger fill:#6b4fbb,stroke:#5a3fa0,color:#fff") w(" classDef trigger fill:#6b4fbb,stroke:#5a3fa0,color:#fff")
w(" classDef delayed fill:#fca326,stroke:#d98a1e,color:#333") w(" classDef delayed fill:#fca326,stroke:#d98a1e,color:#333")
w(" classDef on_failure fill:#d9534f,stroke:#c0392b,color:#fff")
w("") w("")
// Collect visible (non-template) job names; sort for stable output. // Collect visible (non-template) job names; sort for stable output.
@@ -149,6 +150,8 @@ func jobClass(job model.Job) string {
return "manual" return "manual"
case "delayed": case "delayed":
return "delayed" return "delayed"
case "on_failure":
return "on_failure"
} }
return "regular" return "regular"
} }
+13
View File
@@ -105,10 +105,23 @@ func TestSanitizeID(t *testing.T) {
func TestJobClass(t *testing.T) { func TestJobClass(t *testing.T) {
if jobClass(model.Job{When: "manual"}) != "manual" { t.Error("manual") } 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: "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{Trigger: "x"}) != "trigger" { t.Error("trigger") }
if jobClass(model.Job{}) != "regular" { t.Error("regular") } 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 ────────────────────────────────────────────────────────────── // ── needsJobName ──────────────────────────────────────────────────────────────
func TestNeedsJobName(t *testing.T) { func TestNeedsJobName(t *testing.T) {
+493 -71
View File
@@ -10,6 +10,7 @@ import (
"strings" "strings"
"time" "time"
"git.k3nny.fr/glint/internal/cicontext"
"git.k3nny.fr/glint/internal/model" "git.k3nny.fr/glint/internal/model"
) )
@@ -18,26 +19,129 @@ const (
chipW = 178 // job chip width chipW = 178 // job chip width
chipH = 34 // job chip height chipH = 34 // job chip height
chipGap = 6 // vertical gap between chips in a column chipGap = 6 // vertical gap between chips in a column
iconCX = 14 // status circle: x offset from chip left edge to circle centre iconCX = 14 // status circle: x offset from chip left edge to centre
iconR = 7 // status circle radius (14 px diameter) iconR = 7 // status circle radius (14 px diameter)
textLeft = 27 // job name text: x offset from chip left edge textLeft = 27 // job name text: x offset from chip left edge
labelH = 30 // stage-name label area height (text + bottom gap) labelH = 30 // stage-name label area height (text + bottom gap)
topPad = 40 // outer top padding topPad = 40 // outer top padding
sidePad = 40 // outer left / right padding sidePad = 40 // outer left / right padding
stageGap = 50 // horizontal gap between stage columns (connector space) 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) botPad = 48 // outer bottom padding (legend lives here)
) )
type svgPt struct{ x, y int } 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 // RenderPipeline writes a PNG (or SVG fallback) file with a GitLab CI-style
// pipeline layout and returns the path to the generated file. // 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 { if err := os.MkdirAll(outDir, 0o755); err != nil {
return "", fmt.Errorf("creating output directory %s: %w", outDir, err) return "", fmt.Errorf("creating output directory %s: %w", outDir, err)
} }
svg := pipelineSVG(p) svg := pipelineSVG(p, ctx)
ts := time.Now().Format("20060102-150405") ts := time.Now().Format("20060102-150405")
svgPath := filepath.Join(outDir, "pipeline-"+ts+".svg") svgPath := filepath.Join(outDir, "pipeline-"+ts+".svg")
@@ -53,6 +157,24 @@ func RenderPipeline(p *model.Pipeline, outDir string) (string, error) {
return svgPath, nil 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). // convertToPNG tries rsvg-convert, Inkscape, magick, and convert (not on Windows).
func convertToPNG(svgPath, pngPath string) bool { func convertToPNG(svgPath, pngPath string) bool {
candidates := [][]string{ candidates := [][]string{
@@ -75,7 +197,7 @@ func convertToPNG(svgPath, pngPath string) bool {
return false return false
} }
func pipelineSVG(p *model.Pipeline) string { func pipelineSVG(p *model.Pipeline, ctx *cicontext.Context) string {
// Collect visible (non-template) job names in sorted order. // Collect visible (non-template) job names in sorted order.
var visible []string var visible []string
for name := range p.Jobs { for name := range p.Jobs {
@@ -89,6 +211,16 @@ func pipelineSVG(p *model.Pipeline) string {
return svgEmpty() 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. // Group by stage; fall back to "test" (GitLab default) when stage is unset.
byStage := make(map[string][]string) byStage := make(map[string][]string)
for _, name := range visible { for _, name := range visible {
@@ -119,32 +251,42 @@ func pipelineSVG(p *model.Pipeline) string {
} }
} }
// Compute per-stage column heights and job anchor points for connectors. // ── Column layout ─────────────────────────────────────────────────────────
colH := make([]int, len(stages)) // height of the chip stack (no label) // Split stages into sub-columns when intra-stage needs exist.
colCtY := make([]int, len(stages)) // y centre of the chip stack 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 maxColH := 0
rightMid := make(map[string]svgPt) rightMid := make(map[string]svgPt)
leftMid := make(map[string]svgPt) leftMid := make(map[string]svgPt)
for i, stage := range stages { for ci, col := range cols {
jobs := byStage[stage] n := len(col.jobs)
sort.Strings(jobs)
n := len(jobs)
h := n*chipH + max(0, n-1)*chipGap h := n*chipH + max(0, n-1)*chipGap
colH[i] = h
if h > maxColH { if h > maxColH {
maxColH = h maxColH = h
} }
cx := sidePad + i*(chipW+stageGap) for j, name := range col.jobs {
for j, name := range jobs {
chy := topPad + labelH + j*(chipH+chipGap) chy := topPad + labelH + j*(chipH+chipGap)
rightMid[name] = svgPt{cx + chipW, chy + chipH/2} rightMid[name] = svgPt{colX[ci] + chipW, chy + chipH/2}
leftMid[name] = svgPt{cx, 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 svgH := topPad + labelH + maxColH + botPad
// DAG mode: any visible job with a needs: list triggers job-to-job arrows. // DAG mode: any visible job with a needs: list triggers job-to-job arrows.
@@ -174,49 +316,35 @@ func pipelineSVG(p *model.Pipeline) string {
// White page background. // White page background.
wf(` <rect width="%d" height="%d" fill="#ffffff"/>`, svgW, svgH) wf(` <rect width="%d" height="%d" fill="#ffffff"/>`, svgW, svgH)
// ── Stage columns ───────────────────────────────────────────────────────── // ── Stage headers ─────────────────────────────────────────────────────────
for i, stage := range stages { // Each declared stage may span multiple sub-columns; draw one header per stage.
cx := sidePad + i*(chipW+stageGap) drawnHeader := make(map[string]bool)
jobs := byStage[stage] for ci, col := range cols {
sort.Strings(jobs) 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" `+ wf(` <text x="%d" y="%d" text-anchor="middle" `+
`font-family="'GitLab Sans','Segoe UI',-apple-system,BlinkMacSystemFont,sans-serif" `+ `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>`, `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"/>`, 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. drawnHeader[col.stage] = true
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)))
}
} }
// ── Connectors ──────────────────────────────────────────────────────────── // ── Connectors (drawn before chips so they appear behind job blocks) ───────
const connStroke = "#dbdbdb" const connStroke = "#dbdbdb"
if dagMode { if dagMode {
@@ -241,27 +369,123 @@ func pipelineSVG(p *model.Pipeline) string {
} }
} }
} else { } else {
// Classic: one connector per adjacent stage pair. // Classic: bus-bar connectors between adjacent stage columns.
for i := 0; i < len(stages)-1; i++ { // Every job in stage[i] fans to a vertical bus at the midpoint gap,
x1 := sidePad + i*(chipW+stageGap) + chipW // then fans out to every job in stage[i+1].
x2 := sidePad + (i+1)*(chipW+stageGap) for ci := 0; ci < len(cols)-1; ci++ {
y1 := colCtY[i] x1 := colX[ci] + chipW // right edge of current column
y2 := colCtY[i+1] x2 := colX[ci+1] // left edge of next column
midX := (x1 + x2) / 2 midX := (x1 + x2) / 2
if y1 == y2 { srcJobs := cols[ci].jobs
// Straight horizontal line. dstJobs := cols[ci+1].jobs
wf(` <line x1="%d" y1="%d" x2="%d" y2="%d" stroke="%s" stroke-width="2"/>`,
x1, y1, x2-7, y2, connStroke) // Collect all Y midpoints to span the vertical bus bar.
} else { var allYs []int
// L-shaped elbow: right → vertical → right. srcY := make([]int, len(srcJobs))
wf(` <polyline points="%d,%d %d,%d %d,%d %d,%d" `+ dstY := make([]int, len(dstJobs))
`stroke="%s" stroke-width="2" fill="none" stroke-linejoin="round"/>`, for j, name := range srcJobs {
x1, y1, midX, y1, midX, y2, x2-7, y2, connStroke) srcY[j] = rightMid[name].y
allYs = append(allYs, srcY[j])
} }
// Arrowhead at the destination. for j, name := range dstJobs {
dstY[j] = leftMid[name].y
allYs = append(allYs, dstY[j])
}
sort.Ints(allYs)
busMinY, busMaxY := allYs[0], allYs[len(allYs)-1]
// Vertical bus bar at midX (only when there are multiple Y levels).
if busMinY < busMaxY {
wf(` <line x1="%d" y1="%d" x2="%d" y2="%d" stroke="%s" stroke-width="2"/>`,
midX, busMinY, midX, busMaxY, connStroke)
}
// Horizontal stubs from each source job to the bus.
for _, y := range srcY {
wf(` <line x1="%d" y1="%d" x2="%d" y2="%d" stroke="%s" stroke-width="2"/>`,
x1, y, midX, y, connStroke)
}
// Horizontal stubs from bus to each destination job, with arrowhead.
for _, y := range dstY {
wf(` <line x1="%d" y1="%d" x2="%d" y2="%d" stroke="%s" stroke-width="2"/>`,
midX, y, x2-7, y, connStroke)
wf(` <polygon points="%d,%d %d,%d %d,%d" fill="%s"/>`, wf(` <polygon points="%d,%d %d,%d %d,%d" fill="%s"/>`,
x2-7, y2-4, x2, y2, x2-7, y2+4, connStroke) x2-7, y-4, x2, y, x2-7, y+4, connStroke)
}
}
}
// ── Job chips (drawn after connectors so they appear on top) ─────────────
for ci, col := range cols {
for j, name := range col.jobs {
job := p.Jobs[name]
chy := topPad + labelH + j*(chipH+chipGap)
isSkipped := skippedJobs[name]
cx := colX[ci]
// Build <desc> content: shown in the HTML sidebar and SVG viewer tooltips.
desc := "stage: " + col.stage
if job.When != "" {
desc += "\nwhen: " + job.When
}
if img := imageString(job.Image); img != "" {
desc += "\nimage: " + img
}
var needNames []string
for _, n := range job.Needs {
if s := needsJobName(n); s != "" {
needNames = append(needNames, s)
}
}
if len(needNames) > 0 {
desc += "\nneeds: " + strings.Join(needNames, ", ")
}
if isSkipped {
desc += "\nstate: skipped"
}
// data-job attribute enables JS click detection in the HTML output.
wf(` <g data-job="%s">`, svgEsc(name))
wf(` <title>%s</title>`, svgEsc(name))
wf(` <desc>%s</desc>`, svgEsc(desc))
color := chipColor(job)
if isSkipped {
color = "#868686"
}
// Chip card (white, rounded, subtle border + shadow).
// on_failure jobs get a dashed border to signal the failure path.
dashAttr := ""
if !isSkipped && job.When == "on_failure" {
dashAttr = ` stroke-dasharray="4,3"`
}
wf(` <rect x="%d" y="%d" width="%d" height="%d" rx="4" `+
`fill="#ffffff" stroke="#dde1e7" stroke-width="1"%s filter="url(#chip-shadow)"/>`,
cx, chy, chipW, chipH, dashAttr)
// Colored status indicator circle.
wf(` <circle cx="%d" cy="%d" r="%d" fill="%s"/>`,
cx+iconCX, chy+chipH/2, iconR, color)
// Icon inside the circle (omitted for skipped — grey circle speaks for itself).
if !isSkipped {
drawChipIcon(&sb, job, cx+iconCX, chy+chipH/2)
}
// Job name text — dimmed for skipped jobs.
textColor := "#303030"
if isSkipped {
textColor = "#868686"
}
wf(` <text x="%d" y="%d" dominant-baseline="middle" `+
`font-family="'GitLab Sans','Segoe UI',-apple-system,BlinkMacSystemFont,sans-serif" `+
`font-size="13" fill="%s">%s</text>`,
cx+textLeft, chy+chipH/2, textColor, svgEsc(svgTrunc(name, 20)))
wf(` </g>`)
} }
} }
@@ -271,6 +495,7 @@ func pipelineSVG(p *model.Pipeline) string {
{"#fc6d26", "manual"}, {"#fc6d26", "manual"},
{"#6b4fbb", "trigger"}, {"#6b4fbb", "trigger"},
{"#fca326", "delayed"}, {"#fca326", "delayed"},
{"#d9534f", "on_failure"},
} }
const legendItemW = 82 const legendItemW = 82
legendY := topPad + labelH + maxColH + botPad/2 legendY := topPad + labelH + maxColH + botPad/2
@@ -304,14 +529,20 @@ func drawChipIcon(sb *strings.Builder, job model.Job, cx, cy int) {
fmt.Fprintf(sb, " <polygon points=\"%d,%d %d,%d %d,%d\" fill=\"#fff\"/>\n", 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) cx-3, cy-4, cx+5, cy, cx-3, cy+4)
case "delayed": case "delayed":
// Clock hands: vertical + horizontal. // Clock: circle + hands.
fmt.Fprintf(sb, " <circle cx=\"%d\" cy=\"%d\" r=\"5\" stroke=\"#fff\" stroke-width=\"1.2\" fill=\"none\"/>\n", cx, cy) fmt.Fprintf(sb, " <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", 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) 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) 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: default:
// Regular job: small white checkmark outline. // Regular job: small white checkmark.
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", "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) cx-3, cy, cx-1, cy+3, cx+4, cy-3)
@@ -327,10 +558,25 @@ func chipColor(job model.Job) string {
return "#fc6d26" return "#fc6d26"
case "delayed": case "delayed":
return "#fca326" return "#fca326"
case "on_failure":
return "#d9534f"
} }
return "#1f75cb" 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 { func svgEsc(s string) string {
s = strings.ReplaceAll(s, "&", "&amp;") s = strings.ReplaceAll(s, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;") s = strings.ReplaceAll(s, "<", "&lt;")
@@ -356,3 +602,179 @@ func svgEmpty() string {
`</svg>`, `</svg>`,
w, h, w, h, w/2, h/2) 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()
}
+341 -23
View File
@@ -5,6 +5,7 @@ import (
"strings" "strings"
"testing" "testing"
"git.k3nny.fr/glint/internal/cicontext"
"git.k3nny.fr/glint/internal/model" "git.k3nny.fr/glint/internal/model"
) )
@@ -59,9 +60,20 @@ func TestChipColor(t *testing.T) {
if chipColor(model.Job{Trigger: "x"}) != "#6b4fbb" { t.Error("trigger color") } 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: "manual"}) != "#fc6d26" { t.Error("manual color") }
if chipColor(model.Job{When: "delayed"}) != "#fca326" { t.Error("delayed 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") } 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 ────────────────────────────────────────────────────────────── // ── drawChipIcon ──────────────────────────────────────────────────────────────
func TestDrawChipIcon(t *testing.T) { func TestDrawChipIcon(t *testing.T) {
@@ -72,6 +84,7 @@ func TestDrawChipIcon(t *testing.T) {
{model.Job{Trigger: "x"}, "<polyline"}, {model.Job{Trigger: "x"}, "<polyline"},
{model.Job{When: "manual"}, "<polygon"}, {model.Job{When: "manual"}, "<polygon"},
{model.Job{When: "delayed"}, "<circle"}, {model.Job{When: "delayed"}, "<circle"},
{model.Job{When: "on_failure"}, "<line"},
{model.Job{}, "<polyline"}, {model.Job{}, "<polyline"},
} }
for _, tc := range cases { for _, tc := range cases {
@@ -86,7 +99,7 @@ func TestDrawChipIcon(t *testing.T) {
// ── pipelineSVG ─────────────────────────────────────────────────────────────── // ── pipelineSVG ───────────────────────────────────────────────────────────────
func TestPipelineSVG_Empty(t *testing.T) { func TestPipelineSVG_Empty(t *testing.T) {
svg := pipelineSVG(&model.Pipeline{}) svg := pipelineSVG(&model.Pipeline{}, nil)
if !strings.Contains(svg, "no jobs defined") { if !strings.Contains(svg, "no jobs defined") {
t.Errorf("expected 'no jobs defined', got:\n%s", svg) t.Errorf("expected 'no jobs defined', got:\n%s", svg)
} }
@@ -100,13 +113,32 @@ func TestPipelineSVG_ClassicMode(t *testing.T) {
"test-job": {Name: "test-job", Stage: "test"}, "test-job": {Name: "test-job", Stage: "test"},
}, },
} }
svg := pipelineSVG(p) svg := pipelineSVG(p, nil)
if !strings.Contains(svg, "<svg") { t.Error("expected <svg") } if !strings.Contains(svg, "<svg") { t.Error("expected <svg") }
if !strings.Contains(svg, "build-job") { t.Error("expected build-job") } if !strings.Contains(svg, "build-job") { t.Error("expected build-job") }
if !strings.Contains(svg, "test-job") { t.Error("expected test-job") } if !strings.Contains(svg, "test-job") { t.Error("expected test-job") }
// Classic mode connector // Classic mode bus-bar draws <line> elements for connectors
if !strings.Contains(svg, "<polyline") && !strings.Contains(svg, "<line") { if !strings.Contains(svg, "<line") {
t.Error("expected connector in classic mode") 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")
} }
} }
@@ -118,7 +150,7 @@ func TestPipelineSVG_DAGMode(t *testing.T) {
"test-job": {Name: "test-job", Stage: "test", Needs: []any{"build-job"}}, "test-job": {Name: "test-job", Stage: "test", Needs: []any{"build-job"}},
}, },
} }
svg := pipelineSVG(p) svg := pipelineSVG(p, nil)
// DAG mode draws <path> bezier curves // DAG mode draws <path> bezier curves
if !strings.Contains(svg, "<path") { if !strings.Contains(svg, "<path") {
t.Error("expected <path> connector in DAG mode") t.Error("expected <path> connector in DAG mode")
@@ -126,7 +158,7 @@ func TestPipelineSVG_DAGMode(t *testing.T) {
} }
func TestPipelineSVG_DAGMode_StraightConnector(t *testing.T) { func TestPipelineSVG_DAGMode_StraightConnector(t *testing.T) {
// Two stages at same height → straight <line> connector in classic mode // Two stages at same height → straight connector in classic mode
p := &model.Pipeline{ p := &model.Pipeline{
Stages: []string{"a", "b"}, Stages: []string{"a", "b"},
Jobs: map[string]model.Job{ Jobs: map[string]model.Job{
@@ -134,7 +166,7 @@ func TestPipelineSVG_DAGMode_StraightConnector(t *testing.T) {
"j2": {Name: "j2", Stage: "b"}, "j2": {Name: "j2", Stage: "b"},
}, },
} }
svg := pipelineSVG(p) svg := pipelineSVG(p, nil)
if !strings.Contains(svg, "<svg") { t.Error("expected <svg") } if !strings.Contains(svg, "<svg") { t.Error("expected <svg") }
} }
@@ -145,13 +177,18 @@ func TestPipelineSVG_AllJobTypes(t *testing.T) {
"manual-job": {Name: "manual-job", Stage: "deploy", When: "manual"}, "manual-job": {Name: "manual-job", Stage: "deploy", When: "manual"},
"delayed-job": {Name: "delayed-job", Stage: "deploy", When: "delayed"}, "delayed-job": {Name: "delayed-job", Stage: "deploy", When: "delayed"},
"trigger-job": {Name: "trigger-job", Stage: "deploy", Trigger: "other/project"}, "trigger-job": {Name: "trigger-job", Stage: "deploy", Trigger: "other/project"},
"onfailure-job": {Name: "onfailure-job", Stage: "deploy", When: "on_failure"},
}, },
} }
svg := pipelineSVG(p) svg := pipelineSVG(p, nil)
// Each type has its own color in the SVG
if !strings.Contains(svg, "#fc6d26") { t.Error("expected manual color") } if !strings.Contains(svg, "#fc6d26") { t.Error("expected manual color") }
if !strings.Contains(svg, "#fca326") { t.Error("expected delayed 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, "#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) { func TestPipelineSVG_JobNoStage(t *testing.T) {
@@ -160,7 +197,7 @@ func TestPipelineSVG_JobNoStage(t *testing.T) {
"myjob": {Name: "myjob"}, "myjob": {Name: "myjob"},
}, },
} }
svg := pipelineSVG(p) svg := pipelineSVG(p, nil)
if !strings.Contains(svg, "myjob") { t.Error("expected myjob") } if !strings.Contains(svg, "myjob") { t.Error("expected myjob") }
} }
@@ -172,10 +209,85 @@ func TestPipelineSVG_CrossPipelineNeed(t *testing.T) {
Needs: []any{map[string]any{"pipeline": "other", "job": "x"}}}, Needs: []any{map[string]any{"pipeline": "other", "job": "x"}}},
}, },
} }
svg := pipelineSVG(p) svg := pipelineSVG(p, nil)
if !strings.Contains(svg, "<svg") { t.Error("expected <svg") } 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 ──────────────────────────────────────────────────────────── // ── RenderPipeline ────────────────────────────────────────────────────────────
func TestRenderPipeline(t *testing.T) { func TestRenderPipeline(t *testing.T) {
@@ -186,7 +298,7 @@ func TestRenderPipeline(t *testing.T) {
}, },
} }
dir := t.TempDir() dir := t.TempDir()
path, err := RenderPipeline(p, dir) path, err := RenderPipeline(p, dir, nil)
if err != nil { if err != nil {
t.Fatalf("RenderPipeline: %v", err) t.Fatalf("RenderPipeline: %v", err)
} }
@@ -202,7 +314,7 @@ func TestRenderPipeline_InvalidDir(t *testing.T) {
if err := os.WriteFile(filePath, []byte("x"), 0o644); err != nil { if err := os.WriteFile(filePath, []byte("x"), 0o644); err != nil {
t.Fatal(err) t.Fatal(err)
} }
_, err := RenderPipeline(&model.Pipeline{}, filePath+"/nested") _, err := RenderPipeline(&model.Pipeline{}, filePath+"/nested", nil)
if err == nil { if err == nil {
t.Error("expected error writing to path under a file") t.Error("expected error writing to path under a file")
} }
@@ -222,7 +334,7 @@ func TestRenderPipeline_WriteFileFails(t *testing.T) {
Stages: []string{"build"}, Stages: []string{"build"},
Jobs: map[string]model.Job{"j": {Name: "j", Stage: "build"}}, Jobs: map[string]model.Job{"j": {Name: "j", Stage: "build"}},
} }
_, err := RenderPipeline(p, dir) _, err := RenderPipeline(p, dir, nil)
if err == nil { if err == nil {
t.Error("expected error when writing SVG to read-only directory") t.Error("expected error when writing SVG to read-only directory")
} }
@@ -241,7 +353,7 @@ func TestRenderPipeline_SVGFallback(t *testing.T) {
Jobs: map[string]model.Job{"j": {Name: "j", Stage: "build"}}, Jobs: map[string]model.Job{"j": {Name: "j", Stage: "build"}},
} }
dir := t.TempDir() dir := t.TempDir()
path, err := RenderPipeline(p, dir) path, err := RenderPipeline(p, dir, nil)
if err != nil { if err != nil {
t.Fatalf("RenderPipeline: %v", err) t.Fatalf("RenderPipeline: %v", err)
} }
@@ -264,7 +376,7 @@ func TestConvertToPNG_NoConverter(t *testing.T) {
func TestConvertToPNG_FakeConverter(t *testing.T) { func TestConvertToPNG_FakeConverter(t *testing.T) {
// Install a fake rsvg-convert that just creates the output file and exits 0. // Install a fake rsvg-convert that just creates the output file and exits 0.
// This exercises the "return true" branch (line 72) and os.Remove in RenderPipeline. // This exercises the "return true" branch and os.Remove in RenderPipeline.
binDir := t.TempDir() binDir := t.TempDir()
script := "#!/bin/sh\n# rsvg-convert --output <out> <in>\ncp \"$3\" \"$2\"\n" script := "#!/bin/sh\n# rsvg-convert --output <out> <in>\ncp \"$3\" \"$2\"\n"
fakeBin := binDir + "/rsvg-convert" fakeBin := binDir + "/rsvg-convert"
@@ -303,7 +415,7 @@ func TestRenderPipeline_PNGConversion(t *testing.T) {
Jobs: map[string]model.Job{"j": {Name: "j", Stage: "build"}}, Jobs: map[string]model.Job{"j": {Name: "j", Stage: "build"}},
} }
dir := t.TempDir() dir := t.TempDir()
path, err := RenderPipeline(p, dir) path, err := RenderPipeline(p, dir, nil)
if err != nil { if err != nil {
t.Fatalf("RenderPipeline: %v", err) t.Fatalf("RenderPipeline: %v", err)
} }
@@ -315,7 +427,7 @@ func TestRenderPipeline_PNGConversion(t *testing.T) {
// ── pipelineSVG coverage gaps ───────────────────────────────────────────────── // ── pipelineSVG coverage gaps ─────────────────────────────────────────────────
func TestPipelineSVG_LShapedElbow(t *testing.T) { func TestPipelineSVG_LShapedElbow(t *testing.T) {
// Classic mode with unequal stage sizes → different column heights → L-shaped elbow // Classic mode with unequal stage sizes → vertical bus bar required
p := &model.Pipeline{ p := &model.Pipeline{
Stages: []string{"build", "test"}, Stages: []string{"build", "test"},
Jobs: map[string]model.Job{ Jobs: map[string]model.Job{
@@ -324,9 +436,10 @@ func TestPipelineSVG_LShapedElbow(t *testing.T) {
"j3": {Name: "j3", Stage: "test"}, "j3": {Name: "j3", Stage: "test"},
}, },
} }
svg := pipelineSVG(p) svg := pipelineSVG(p, nil)
if !strings.Contains(svg, "<polyline") { // The bus bar is a <line>; arrowheads are <polygon>
t.Error("expected <polyline> for L-shaped elbow connector") if !strings.Contains(svg, "<polygon") {
t.Error("expected <polygon> arrowhead in bus-bar connector")
} }
} }
@@ -339,9 +452,214 @@ func TestPipelineSVG_DAGNeedNotInPositionMap(t *testing.T) {
".hidden-template": {Name: ".hidden-template", Stage: "build"}, ".hidden-template": {Name: ".hidden-template", Stage: "build"},
}, },
} }
svg := pipelineSVG(p) svg := pipelineSVG(p, nil)
// Should render without panic; .hidden-template is not visible so no connector drawn. // Should render without panic; .hidden-template is not visible so no connector drawn.
if !strings.Contains(svg, "<svg") { if !strings.Contains(svg, "<svg") {
t.Error("expected valid SVG output") 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") }
}
+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)
})
}
+332
View File
@@ -0,0 +1,332 @@
package lsp
import (
"bufio"
"encoding/json"
"fmt"
"io"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"git.k3nny.fr/glint/internal/fetcher"
"git.k3nny.fr/glint/internal/linter"
"git.k3nny.fr/glint/internal/model"
"git.k3nny.fr/glint/internal/resolver"
)
// Server is a minimal Language Server Protocol server that publishes glint
// diagnostics for .gitlab-ci.yml files opened in an editor.
//
// Transport: JSON-RPC 2.0 over stdin/stdout with Content-Length framing.
// Sync mode: Full — the client sends the complete document text on every change.
type Server struct {
in *bufio.Reader
out io.Writer
cfg fetcher.GitLabConfig
version string
docs map[string]string // uri → current document text
// Exit is called with the process exit code when the LSP client sends the
// "exit" notification. Defaults to os.Exit; replace in tests.
Exit func(int)
shutdownReceived bool
}
// New creates a Server reading from r and writing to w.
func New(r io.Reader, w io.Writer, cfg fetcher.GitLabConfig, version string) *Server {
return &Server{
in: bufio.NewReader(r),
out: w,
cfg: cfg,
version: version,
docs: make(map[string]string),
Exit: os.Exit,
}
}
// Run processes LSP messages until the connection closes or a fatal error occurs.
// It returns nil on a clean EOF (client disconnected) and a non-nil error for
// unrecoverable protocol failures.
func (s *Server) Run() error {
for {
msg, err := s.readMessage()
if err == io.EOF {
return nil
}
if err != nil {
return fmt.Errorf("reading LSP message: %w", err)
}
if err := s.dispatch(msg); err != nil {
return err
}
}
}
// readMessage reads one Content-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")
}
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)
}
+383
View File
@@ -0,0 +1,383 @@
package lsp
import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"io"
"strconv"
"strings"
"testing"
"git.k3nny.fr/glint/internal/fetcher"
)
// frame encodes v as a Content-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_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)
}
})
}
+3
View File
@@ -57,6 +57,9 @@ func ParseBytes(data []byte) (*Pipeline, error) {
keyNode := root.Content[i] keyNode := root.Content[i]
valNode := root.Content[i+1] valNode := root.Content[i+1]
key := keyNode.Value key := keyNode.Value
if key == "" {
return nil, fmt.Errorf("job name cannot be empty (null or missing YAML key)")
}
if ReservedKeys[key] { if ReservedKeys[key] {
continue continue
} }
+6
View File
@@ -88,6 +88,12 @@ func TestParseBytes_EdgeCases(t *testing.T) {
if err == nil { if err == nil {
t.Error("wrong field type: expected error from ParseBytes (stage must be string)") 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). // TestParse_ParseBytesError exercises the Parse → ParseBytes error path (line 18).
@@ -0,0 +1,2 @@
go test fuzz v1
[]byte("?")
+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 ]]