Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9342ce0eff | |||
| 7a4d55ec9a |
@@ -5,6 +5,22 @@ 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.27] - 2026-06-25
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Fuzz testing** — `FuzzParseBytes` and `FuzzSanitizeYAMLEscapes` in `internal/model/fuzz_test.go` verify that neither the YAML parser nor the escape sanitizer panics on arbitrary input. Successful parses are also checked for structural integrity (non-nil pipeline, no empty job names). Run with `task fuzz` (default 30 s per target; set `FUZZ_TIME=60s` to extend). Found failures are saved to `testdata/fuzz/` for regression.
|
||||||
|
|
||||||
|
- **Changelog automation** — `cliff.toml` configures [git-cliff](https://git-cliff.org) to generate Keep-a-Changelog–compatible release notes from Conventional Commits. `task changelog` regenerates `CHANGELOG.md` from the full git history; `task changelog-next` previews only unreleased commits without writing. Install git-cliff with `brew install git-cliff` or `cargo install git-cliff`.
|
||||||
|
|
||||||
|
## [0.2.26] - 2026-06-25
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Same-stage job ordering** — when jobs within the same declared GitLab stage have `needs:` relationships between each other, the pipeline graph now splits that stage into topological sub-columns: jobs with no same-stage dependencies occupy the leftmost sub-column; jobs that depend on them are placed one sub-column to the right. Sub-columns use a narrower 20 px gap (vs. the 50 px gap between stages), and the stage header spans all sub-columns. Stages with no intra-stage `needs:` are unaffected.
|
||||||
|
|
||||||
|
- **Graph links rendered behind job chips** — SVG connector lines (Bézier curves in DAG mode, bus-bar stubs in classic mode) are now drawn before the job chip rectangles, so connector lines pass behind chips rather than on top of them.
|
||||||
|
|
||||||
## [0.2.25] - 2026-06-25
|
## [0.2.25] - 2026-06-25
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -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.25-blue.svg" alt="Release"></a>
|
<a href="CHANGELOG.md"><img src="https://img.shields.io/badge/release-v0.2.27-blue.svg" alt="Release"></a>
|
||||||
</p>
|
</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.
|
||||||
@@ -69,11 +69,18 @@ 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 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
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
+5
-3
@@ -77,6 +77,8 @@ The SVG renderer and terminal tree cover the basic layout. These would bring it
|
|||||||
- ~~**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
|
- ~~**Blocked / skipped state colouring**~~ — ✓ shipped v0.2.25; `glint graph pipeline` accepts context flags (`--branch`, `--tag`, etc.); jobs evaluated as skipped are greyed out (`#868686`) with dimmed text and no icon
|
||||||
- ~~**Interactive HTML output**~~ — ✓ shipped v0.2.25; `glint graph pipeline --format html` writes a self-contained `.html` file with mouse pan/zoom and a click-to-open job-detail sidebar; no external dependencies
|
- ~~**Interactive HTML output**~~ — ✓ shipped v0.2.25; `glint graph pipeline --format html` writes a self-contained `.html` file with mouse pan/zoom and a click-to-open job-detail sidebar; no external dependencies
|
||||||
- ~~**Mermaid pipeline output**~~ — ✓ shipped v0.2.25; `glint graph pipeline --format mermaid` prints a Mermaid flowchart to stdout (paste into mermaid.live)
|
- ~~**Mermaid pipeline output**~~ — ✓ shipped v0.2.25; `glint graph pipeline --format mermaid` prints a Mermaid flowchart to stdout (paste into mermaid.live)
|
||||||
|
- ~~**Same-stage job ordering**~~ — ✓ shipped v0.2.26; jobs within a stage that have `needs:` between each other are placed in topological sub-columns (left-to-right by depth); stage header spans all sub-columns
|
||||||
|
- ~~**Graph links rendered behind job chips**~~ — ✓ shipped v0.2.26; SVG connectors (Bézier curves and bus-bar stubs) are drawn before job chips so lines pass behind rectangles
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -113,9 +115,9 @@ The SVG renderer and terminal tree cover the basic layout. These would bring it
|
|||||||
|
|
||||||
## Reliability and developer experience
|
## Reliability and developer experience
|
||||||
|
|
||||||
- ~~**Structured rule IDs**~~ — ✓ shipped post-v0.2.0; GL001–GL031 assigned; GL032 added v0.2.11; GL033 added v0.2.15; GL034–GL041 added v0.2.16; output formats (--format json/sarif/junit/github) added v0.2.18; GL042–GL043 added v0.2.20; GL044 added v0.2.24; graph improvements shipped v0.2.25
|
- ~~**Structured rule IDs**~~ — ✓ shipped post-v0.2.0; GL001–GL031 assigned; GL032 added v0.2.11; GL033 added v0.2.15; GL034–GL041 added v0.2.16; output formats (--format json/sarif/junit/github) added v0.2.18; GL042–GL043 added v0.2.20; GL044 added v0.2.24; graph improvements shipped v0.2.25–v0.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
|
- ~~**`glint explain <rule-id>`**~~ — ✓ shipped v0.2.20; prints rule description, rationale, bad-YAML example, and fix; `glint explain` (no arg) lists all rules
|
||||||
- ~~**Semantic versioning and first release**~~ — shipped as `v0.1.0` (2026-06-07)
|
- ~~**Semantic versioning and first release**~~ — shipped as `v0.1.0` (2026-06-07)
|
||||||
- ~~**Subcommand CLI**~~ — shipped as `v0.2.0` (2026-06-11); `glint check` / `glint graph [mode]` with ruff-style `--help`
|
- ~~**Subcommand CLI**~~ — shipped as `v0.2.0` (2026-06-11); `glint check` / `glint graph [mode]` with ruff-style `--help`
|
||||||
- **Changelog automation** — generate release notes from Conventional Commits via `git-cliff` or similar
|
- ~~**Changelog automation**~~ — ✓ shipped v0.2.27; `cliff.toml` configures git-cliff to produce Keep-a-Changelog–compatible release notes from Conventional Commits; `task changelog` regenerates `CHANGELOG.md`, `task changelog-next` previews unreleased entries
|
||||||
- **Fuzz testing** — add a `go test -fuzz` target for the YAML parser to harden it against malformed input
|
- ~~**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)
|
||||||
|
|||||||
@@ -175,6 +175,20 @@ tasks:
|
|||||||
generates:
|
generates:
|
||||||
- "{{.BINARY}}-{{.TAG}}-linux-amd64"
|
- "{{.BINARY}}-{{.TAG}}-linux-amd64"
|
||||||
|
|
||||||
|
fuzz:
|
||||||
|
desc: "Run fuzz tests for the YAML parser and sanitizer (set FUZZ_TIME=60s to control duration, default 30s)"
|
||||||
|
cmds:
|
||||||
|
- "{{.GO}} test -fuzz=FuzzParseBytes -fuzztime=${FUZZ_TIME:-30s} ./internal/model/"
|
||||||
|
- "{{.GO}} test -fuzz=FuzzSanitizeYAMLEscapes -fuzztime=${FUZZ_TIME:-30s} ./internal/model/"
|
||||||
|
|
||||||
|
changelog:
|
||||||
|
desc: "Regenerate CHANGELOG.md from git history (requires git-cliff — see README)"
|
||||||
|
cmd: git cliff --config cliff.toml --output CHANGELOG.md
|
||||||
|
|
||||||
|
changelog-next:
|
||||||
|
desc: "Preview unreleased changelog entries without writing (requires git-cliff)"
|
||||||
|
cmd: git cliff --config cliff.toml --unreleased
|
||||||
|
|
||||||
clean:
|
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
|
||||||
|
|||||||
+65
@@ -0,0 +1,65 @@
|
|||||||
|
# git-cliff configuration for glint
|
||||||
|
# Install: brew install git-cliff OR cargo install git-cliff
|
||||||
|
# Usage: task changelog -- regenerate full CHANGELOG.md
|
||||||
|
# task changelog-next -- preview unreleased section (dry-run)
|
||||||
|
|
||||||
|
[changelog]
|
||||||
|
header = """
|
||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||||
|
This project uses [Semantic Versioning](https://semver.org).
|
||||||
|
"""
|
||||||
|
body = """
|
||||||
|
{% if version %}\
|
||||||
|
## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
|
||||||
|
|
||||||
|
{% else %}\
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
{% endif %}\
|
||||||
|
{% for group, commits in commits | group_by(attribute="group") %}\
|
||||||
|
### {{ group | upper_first }}
|
||||||
|
|
||||||
|
{% for commit in commits %}\
|
||||||
|
- {% if commit.scope %}**{{ commit.scope }}**: {% endif %}\
|
||||||
|
{{ commit.message | upper_first }}\
|
||||||
|
{% if commit.breaking %} **[BREAKING]**{% endif %}
|
||||||
|
|
||||||
|
{% endfor %}\
|
||||||
|
{% endfor %}\
|
||||||
|
"""
|
||||||
|
trim = true
|
||||||
|
footer = ""
|
||||||
|
postprocessors = []
|
||||||
|
|
||||||
|
[git]
|
||||||
|
conventional_commits = true
|
||||||
|
filter_unconventional = true
|
||||||
|
split_commits = false
|
||||||
|
commit_preprocessors = [
|
||||||
|
# Drop Co-Authored-By trailers (should not appear in subjects, but guard anyway).
|
||||||
|
{ pattern = "Co-Authored-By:.*", replace = "" },
|
||||||
|
]
|
||||||
|
commit_parsers = [
|
||||||
|
# Breaking changes (type! or scope!) — promote above everything else.
|
||||||
|
{ message = "^[a-z]+\\([a-z-]+\\)!:|^[a-z]+!:", group = "Breaking Changes" },
|
||||||
|
{ message = "^feat", group = "Added" },
|
||||||
|
{ message = "^fix", group = "Fixed" },
|
||||||
|
{ message = "^perf", group = "Changed" },
|
||||||
|
{ message = "^refactor", group = "Changed" },
|
||||||
|
# Maintenance commits — omit from the changelog body.
|
||||||
|
{ message = "^docs", skip = true },
|
||||||
|
{ message = "^style", skip = true },
|
||||||
|
{ message = "^test", skip = true },
|
||||||
|
{ message = "^chore", skip = true },
|
||||||
|
{ message = "^build", skip = true },
|
||||||
|
{ message = "^claude", skip = true },
|
||||||
|
]
|
||||||
|
protect_breaking_commits = false
|
||||||
|
filter_commits = true
|
||||||
|
tag_pattern = "v[0-9].*"
|
||||||
|
topo_order = false
|
||||||
|
sort_commits = "oldest"
|
||||||
+237
-109
@@ -16,21 +16,124 @@ import (
|
|||||||
|
|
||||||
// Layout constants (pixels) – tuned to resemble GitLab's full pipeline graph view.
|
// Layout constants (pixels) – tuned to resemble GitLab's full pipeline graph view.
|
||||||
const (
|
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)
|
||||||
botPad = 48 // outer bottom padding (legend lives here)
|
subStageGap = 20 // horizontal gap between sub-columns within the same stage
|
||||||
|
botPad = 48 // outer bottom padding (legend lives here)
|
||||||
)
|
)
|
||||||
|
|
||||||
type svgPt struct{ x, y int }
|
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, ctx *cicontext.Context) (string, error) {
|
func RenderPipeline(p *model.Pipeline, outDir string, ctx *cicontext.Context) (string, error) {
|
||||||
@@ -148,28 +251,42 @@ func pipelineSVG(p *model.Pipeline, ctx *cicontext.Context) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compute per-stage column heights and job anchor points for connectors.
|
// ── Column layout ─────────────────────────────────────────────────────────
|
||||||
|
// Split stages into sub-columns when intra-stage needs exist.
|
||||||
|
cols := computeColumns(stages, byStage, p.Jobs)
|
||||||
|
|
||||||
|
// X position of each column's left edge.
|
||||||
|
colX := make([]int, len(cols))
|
||||||
|
if len(cols) > 0 {
|
||||||
|
colX[0] = sidePad
|
||||||
|
for i := 1; i < len(cols); i++ {
|
||||||
|
gap := stageGap
|
||||||
|
if cols[i].stage == cols[i-1].stage {
|
||||||
|
gap = subStageGap
|
||||||
|
}
|
||||||
|
colX[i] = colX[i-1] + chipW + gap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute SVG dimensions and per-job connector anchor points.
|
||||||
maxColH := 0
|
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
|
||||||
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}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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.
|
||||||
@@ -199,30 +316,117 @@ func pipelineSVG(p *model.Pipeline, ctx *cicontext.Context) 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 – each wrapped in a <g> with tooltip metadata.
|
drawnHeader[col.stage] = true
|
||||||
for j, name := range jobs {
|
}
|
||||||
|
|
||||||
|
// ── Connectors (drawn before chips so they appear behind job blocks) ───────
|
||||||
|
const connStroke = "#dbdbdb"
|
||||||
|
|
||||||
|
if dagMode {
|
||||||
|
// Job-to-job bezier curves from needs:.
|
||||||
|
for _, name := range visible {
|
||||||
|
for _, need := range p.Jobs[name].Needs {
|
||||||
|
dep := needsJobName(need)
|
||||||
|
if dep == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
src, okS := rightMid[dep]
|
||||||
|
dst, okD := leftMid[name]
|
||||||
|
if !okS || !okD {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cpX := (src.x + dst.x) / 2
|
||||||
|
wf(` <path d="M%d,%d C%d,%d %d,%d %d,%d" stroke="%s" stroke-width="2" fill="none"/>`,
|
||||||
|
src.x, src.y, cpX, src.y, cpX, dst.y, dst.x-7, dst.y, connStroke)
|
||||||
|
// Small right-pointing triangle arrowhead.
|
||||||
|
wf(` <polygon points="%d,%d %d,%d %d,%d" fill="%s"/>`,
|
||||||
|
dst.x-7, dst.y-4, dst.x, dst.y, dst.x-7, dst.y+4, connStroke)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Classic: bus-bar connectors between adjacent stage columns.
|
||||||
|
// Every job in stage[i] fans to a vertical bus at the midpoint gap,
|
||||||
|
// then fans out to every job in stage[i+1].
|
||||||
|
for ci := 0; ci < len(cols)-1; ci++ {
|
||||||
|
x1 := colX[ci] + chipW // right edge of current column
|
||||||
|
x2 := colX[ci+1] // left edge of next column
|
||||||
|
midX := (x1 + x2) / 2
|
||||||
|
|
||||||
|
srcJobs := cols[ci].jobs
|
||||||
|
dstJobs := cols[ci+1].jobs
|
||||||
|
|
||||||
|
// Collect all Y midpoints to span the vertical bus bar.
|
||||||
|
var allYs []int
|
||||||
|
srcY := make([]int, len(srcJobs))
|
||||||
|
dstY := make([]int, len(dstJobs))
|
||||||
|
for j, name := range srcJobs {
|
||||||
|
srcY[j] = rightMid[name].y
|
||||||
|
allYs = append(allYs, srcY[j])
|
||||||
|
}
|
||||||
|
for j, name := range dstJobs {
|
||||||
|
dstY[j] = leftMid[name].y
|
||||||
|
allYs = append(allYs, dstY[j])
|
||||||
|
}
|
||||||
|
sort.Ints(allYs)
|
||||||
|
busMinY, busMaxY := allYs[0], allYs[len(allYs)-1]
|
||||||
|
|
||||||
|
// Vertical bus bar at midX (only when there are multiple Y levels).
|
||||||
|
if busMinY < busMaxY {
|
||||||
|
wf(` <line x1="%d" y1="%d" x2="%d" y2="%d" stroke="%s" stroke-width="2"/>`,
|
||||||
|
midX, busMinY, midX, busMaxY, connStroke)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Horizontal stubs from each source job to the bus.
|
||||||
|
for _, y := range srcY {
|
||||||
|
wf(` <line x1="%d" y1="%d" x2="%d" y2="%d" stroke="%s" stroke-width="2"/>`,
|
||||||
|
x1, y, midX, y, connStroke)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Horizontal stubs from bus to each destination job, with arrowhead.
|
||||||
|
for _, y := range dstY {
|
||||||
|
wf(` <line x1="%d" y1="%d" x2="%d" y2="%d" stroke="%s" stroke-width="2"/>`,
|
||||||
|
midX, y, x2-7, y, connStroke)
|
||||||
|
wf(` <polygon points="%d,%d %d,%d %d,%d" fill="%s"/>`,
|
||||||
|
x2-7, y-4, x2, y, x2-7, y+4, connStroke)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Job chips (drawn after connectors so they appear on top) ─────────────
|
||||||
|
for ci, col := range cols {
|
||||||
|
for j, name := range col.jobs {
|
||||||
job := p.Jobs[name]
|
job := p.Jobs[name]
|
||||||
chy := topPad + labelH + j*(chipH+chipGap)
|
chy := topPad + labelH + j*(chipH+chipGap)
|
||||||
isSkipped := skippedJobs[name]
|
isSkipped := skippedJobs[name]
|
||||||
|
cx := colX[ci]
|
||||||
|
|
||||||
// Build <desc> content: shown in the HTML sidebar and SVG viewer tooltips.
|
// Build <desc> content: shown in the HTML sidebar and SVG viewer tooltips.
|
||||||
desc := "stage: " + stage
|
desc := "stage: " + col.stage
|
||||||
if job.When != "" {
|
if job.When != "" {
|
||||||
desc += "\nwhen: " + job.When
|
desc += "\nwhen: " + job.When
|
||||||
}
|
}
|
||||||
@@ -285,82 +489,6 @@ func pipelineSVG(p *model.Pipeline, ctx *cicontext.Context) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Connectors ────────────────────────────────────────────────────────────
|
|
||||||
const connStroke = "#dbdbdb"
|
|
||||||
|
|
||||||
if dagMode {
|
|
||||||
// Job-to-job bezier curves from needs:.
|
|
||||||
for _, name := range visible {
|
|
||||||
for _, need := range p.Jobs[name].Needs {
|
|
||||||
dep := needsJobName(need)
|
|
||||||
if dep == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
src, okS := rightMid[dep]
|
|
||||||
dst, okD := leftMid[name]
|
|
||||||
if !okS || !okD {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
cpX := (src.x + dst.x) / 2
|
|
||||||
wf(` <path d="M%d,%d C%d,%d %d,%d %d,%d" stroke="%s" stroke-width="2" fill="none"/>`,
|
|
||||||
src.x, src.y, cpX, src.y, cpX, dst.y, dst.x-7, dst.y, connStroke)
|
|
||||||
// Small right-pointing triangle arrowhead.
|
|
||||||
wf(` <polygon points="%d,%d %d,%d %d,%d" fill="%s"/>`,
|
|
||||||
dst.x-7, dst.y-4, dst.x, dst.y, dst.x-7, dst.y+4, connStroke)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Classic: bus-bar connectors – every job in stage[i] fans to a vertical
|
|
||||||
// bus at the midpoint column gap, then fans out to every job in stage[i+1].
|
|
||||||
// This gives each job pair its own visual connection instead of a single
|
|
||||||
// center-to-center line that misses jobs at the top and bottom of columns.
|
|
||||||
for i := 0; i < len(stages)-1; i++ {
|
|
||||||
x1 := sidePad + i*(chipW+stageGap) + chipW // right edge of left column
|
|
||||||
x2 := sidePad + (i+1)*(chipW+stageGap) // left edge of right column
|
|
||||||
midX := (x1 + x2) / 2
|
|
||||||
|
|
||||||
srcJobs := byStage[stages[i]]
|
|
||||||
sort.Strings(srcJobs)
|
|
||||||
dstJobs := byStage[stages[i+1]]
|
|
||||||
sort.Strings(dstJobs)
|
|
||||||
|
|
||||||
// Collect all Y midpoints to span the vertical bus bar.
|
|
||||||
var allYs []int
|
|
||||||
srcY := make([]int, len(srcJobs))
|
|
||||||
dstY := make([]int, len(dstJobs))
|
|
||||||
for j, name := range srcJobs {
|
|
||||||
srcY[j] = rightMid[name].y
|
|
||||||
allYs = append(allYs, srcY[j])
|
|
||||||
}
|
|
||||||
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 drawn when there are multiple Y levels).
|
|
||||||
if busMinY < busMaxY {
|
|
||||||
wf(` <line x1="%d" y1="%d" x2="%d" y2="%d" stroke="%s" stroke-width="2"/>`,
|
|
||||||
midX, busMinY, midX, busMaxY, connStroke)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Horizontal stubs from each source job to the bus.
|
|
||||||
for _, y := range srcY {
|
|
||||||
wf(` <line x1="%d" y1="%d" x2="%d" y2="%d" stroke="%s" stroke-width="2"/>`,
|
|
||||||
x1, y, midX, y, connStroke)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Horizontal stubs from bus to each destination job, with arrowhead.
|
|
||||||
for _, y := range dstY {
|
|
||||||
wf(` <line x1="%d" y1="%d" x2="%d" y2="%d" stroke="%s" stroke-width="2"/>`,
|
|
||||||
midX, y, x2-7, y, connStroke)
|
|
||||||
wf(` <polygon points="%d,%d %d,%d %d,%d" fill="%s"/>`,
|
|
||||||
x2-7, y-4, x2, y, x2-7, y+4, connStroke)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Legend ────────────────────────────────────────────────────────────────
|
// ── Legend ────────────────────────────────────────────────────────────────
|
||||||
legend := []struct{ color, label string }{
|
legend := []struct{ color, label string }{
|
||||||
{"#1f75cb", "regular"},
|
{"#1f75cb", "regular"},
|
||||||
|
|||||||
@@ -520,6 +520,138 @@ func TestRenderHTML_WriteFileFails(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 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 ──────────────────────────────────────────────────────────────────
|
// ── htmlPage ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func TestHtmlPage(t *testing.T) {
|
func TestHtmlPage(t *testing.T) {
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// FuzzParseBytes ensures the YAML parser never panics on arbitrary input and
|
||||||
|
// that successful parses return a structurally sound Pipeline.
|
||||||
|
// Run with: go test -fuzz=FuzzParseBytes ./internal/model/
|
||||||
|
// Found failures are saved to testdata/fuzz/FuzzParseBytes/.
|
||||||
|
func FuzzParseBytes(f *testing.F) {
|
||||||
|
// Seed corpus: representative inputs covering the main code paths in
|
||||||
|
// ParseBytes, including the sanitizeYAMLEscapes pre-processing step.
|
||||||
|
seeds := [][]byte{
|
||||||
|
{},
|
||||||
|
[]byte("null"),
|
||||||
|
[]byte("stages: [build]\nbuild-job:\n stage: build\n script: echo ok\n"),
|
||||||
|
[]byte(".base:\n script: [make]\nchild:\n extends: .base\n stage: test\n"),
|
||||||
|
[]byte("stages: [a, b]\njob-a:\n stage: a\n script: run\njob-b:\n stage: b\n needs: [job-a]\n script: run\n"),
|
||||||
|
[]byte("*undefined_anchor"),
|
||||||
|
[]byte("- item1\n- item2\n"),
|
||||||
|
[]byte("my-job: \"just a string\"\n"),
|
||||||
|
[]byte("my-job:\n stage: [build, test]\n"),
|
||||||
|
[]byte("# glint: ignore GL007\nlegacy:\n only: [main]\n script: ok\n"),
|
||||||
|
[]byte("workflow:\n rules:\n - if: '$CI_COMMIT_BRANCH == \"main\"'\n when: always\njob:\n script: echo ok\n"),
|
||||||
|
[]byte("include:\n - local: other.yml\njob:\n script: echo ok\n"),
|
||||||
|
[]byte("job:\n script: echo ok\n when: on_failure\n rules:\n - if: '$VAR =~ /^us\\//'\n"),
|
||||||
|
[]byte("job:\n stage: test\n image:\n name: golang:1.21\n entrypoint: ['']\n parallel:\n matrix:\n - PLATFORM: [linux, darwin]\n script: go build\n"),
|
||||||
|
[]byte("default:\n retry: 2\n timeout: 1h30m\nvariables:\n ENV: production\nstages: [build, test, deploy]\n"),
|
||||||
|
[]byte("&anchor\n script: [echo ok]\njob:\n <<: *anchor\n stage: build\n"),
|
||||||
|
}
|
||||||
|
for _, s := range seeds {
|
||||||
|
f.Add(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
f.Fuzz(func(t *testing.T, data []byte) {
|
||||||
|
p, err := ParseBytes(data)
|
||||||
|
if err != nil {
|
||||||
|
return // errors are acceptable; panics are not
|
||||||
|
}
|
||||||
|
if p == nil {
|
||||||
|
t.Fatal("ParseBytes returned nil pipeline with nil error")
|
||||||
|
}
|
||||||
|
for name := range p.Jobs {
|
||||||
|
if name == "" {
|
||||||
|
t.Fatal("ParseBytes produced a job with an empty name")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// FuzzSanitizeYAMLEscapes ensures the escape sanitizer never panics and never
|
||||||
|
// produces output shorter than its input (it can only expand \/ to \\/).
|
||||||
|
func FuzzSanitizeYAMLEscapes(f *testing.F) {
|
||||||
|
seeds := [][]byte{
|
||||||
|
{},
|
||||||
|
[]byte("stage: build"),
|
||||||
|
[]byte(`if: "$CI_BRANCH =~ /^us\//"`),
|
||||||
|
[]byte(`"pattern: /^us\//"`),
|
||||||
|
[]byte(`'single quoted \/ unchanged'`),
|
||||||
|
[]byte(`"\n\t\r"`),
|
||||||
|
[]byte(`"nested \"quote\" inside"`),
|
||||||
|
[]byte(`'it''s fine'`),
|
||||||
|
[]byte(`"unclosed`),
|
||||||
|
{'"', '\\'}, // double-quoted string ending with a lone backslash
|
||||||
|
{'"', '\\', '/'}, // the exact sequence being rewritten
|
||||||
|
}
|
||||||
|
for _, s := range seeds {
|
||||||
|
f.Add(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
f.Fuzz(func(t *testing.T, data []byte) {
|
||||||
|
out := sanitizeYAMLEscapes(data)
|
||||||
|
if len(out) < len(data) {
|
||||||
|
t.Fatalf("sanitizeYAMLEscapes shrank output: input len=%d output len=%d\ninput: %q",
|
||||||
|
len(data), len(out), data)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user