diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1dc1fb0..f26b75a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
This project uses [Semantic Versioning](https://semver.org).
+## [0.2.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
diff --git a/README.md b/README.md
index 9c56cf3..7147c9f 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,7 @@
-
+
> **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.
diff --git a/ROADMAP.md b/ROADMAP.md
index 53c234c..e0a88b6 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -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
- ~~**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)
+- ~~**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,7 +115,7 @@ The SVG renderer and terminal tree cover the basic layout. These would bring it
## 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 `**~~ — ✓ 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)
- ~~**Subcommand CLI**~~ — shipped as `v0.2.0` (2026-06-11); `glint check` / `glint graph [mode]` with ruff-style `--help`
diff --git a/internal/graph/render.go b/internal/graph/render.go
index fed0410..6d690d5 100644
--- a/internal/graph/render.go
+++ b/internal/graph/render.go
@@ -16,21 +16,124 @@ import (
// Layout constants (pixels) – tuned to resemble GitLab's full pipeline graph view.
const (
- chipW = 178 // job chip width
- chipH = 34 // job chip height
- chipGap = 6 // vertical gap between chips in a column
- iconCX = 14 // status circle: x offset from chip left edge to circle centre
- iconR = 7 // status circle radius (14 px diameter)
- textLeft = 27 // job name text: x offset from chip left edge
- labelH = 30 // stage-name label area height (text + bottom gap)
- topPad = 40 // outer top padding
- sidePad = 40 // outer left / right padding
- stageGap = 50 // horizontal gap between stage columns (connector space)
- botPad = 48 // outer bottom padding (legend lives here)
+ chipW = 178 // job chip width
+ chipH = 34 // job chip height
+ chipGap = 6 // vertical gap between chips in a column
+ iconCX = 14 // status circle: x offset from chip left edge to centre
+ iconR = 7 // status circle radius (14 px diameter)
+ textLeft = 27 // job name text: x offset from chip left edge
+ labelH = 30 // stage-name label area height (text + bottom gap)
+ topPad = 40 // outer top padding
+ sidePad = 40 // outer left / right padding
+ stageGap = 50 // horizontal gap between stage columns (connector space)
+ subStageGap = 20 // horizontal gap between sub-columns within the same stage
+ botPad = 48 // outer bottom padding (legend lives here)
)
type svgPt struct{ x, y int }
+// column represents one vertical stack of job chips in the SVG.
+// A declared GitLab stage maps to one column unless jobs within it have
+// same-stage needs:, in which case it splits into topological sub-columns.
+type column struct {
+ stage string // declared GitLab stage name
+ jobs []string // job names in this column, topologically ordered
+}
+
+// computeColumns assigns jobs to SVG columns.
+// When jobs within the same declared stage have needs: relationships between
+// each other, the stage is split into topological sub-columns so that
+// depended-upon jobs appear to the left of the jobs that depend on them.
+func computeColumns(stages []string, byStage map[string][]string, jobs map[string]model.Job) []column {
+ var cols []column
+ for _, stage := range stages {
+ stageJobs := byStage[stage]
+ sort.Strings(stageJobs)
+ if len(stageJobs) == 0 {
+ continue
+ }
+
+ // Index same-stage membership.
+ stageSet := make(map[string]bool, len(stageJobs))
+ for _, j := range stageJobs {
+ stageSet[j] = true
+ }
+
+ // Check whether any job has a same-stage need.
+ hasIntraNeeds := false
+ outer:
+ for _, j := range stageJobs {
+ for _, need := range jobs[j].Needs {
+ if dep := needsJobName(need); dep != "" && stageSet[dep] {
+ hasIntraNeeds = true
+ break outer
+ }
+ }
+ }
+
+ if !hasIntraNeeds {
+ cols = append(cols, column{stage: stage, jobs: stageJobs})
+ continue
+ }
+
+ // Compute topological depth within the stage.
+ depth := make(map[string]int, len(stageJobs))
+ for _, j := range stageJobs {
+ depth[j] = -1
+ }
+ inProgress := make(map[string]bool, len(stageJobs))
+ var computeDepth func(j string) int
+ computeDepth = func(j string) int {
+ if depth[j] >= 0 {
+ return depth[j]
+ }
+ if inProgress[j] {
+ return 0 // cycle guard (cycles already rejected by GL029)
+ }
+ inProgress[j] = true
+ maxDep := -1
+ for _, need := range jobs[j].Needs {
+ dep := needsJobName(need)
+ if dep == "" || !stageSet[dep] {
+ continue
+ }
+ d := computeDepth(dep)
+ if d > maxDep {
+ maxDep = d
+ }
+ }
+ depth[j] = maxDep + 1
+ return depth[j]
+ }
+ for _, j := range stageJobs {
+ if depth[j] < 0 {
+ computeDepth(j)
+ }
+ }
+
+ // One sub-column per topological depth level.
+ maxDepth := 0
+ for _, j := range stageJobs {
+ if depth[j] > maxDepth {
+ maxDepth = depth[j]
+ }
+ }
+ for l := 0; l <= maxDepth; l++ {
+ var levelJobs []string
+ for _, j := range stageJobs {
+ if depth[j] == l {
+ levelJobs = append(levelJobs, j)
+ }
+ }
+ if len(levelJobs) > 0 {
+ sort.Strings(levelJobs)
+ cols = append(cols, column{stage: stage, jobs: levelJobs})
+ }
+ }
+ }
+ return cols
+}
+
// RenderPipeline writes a PNG (or SVG fallback) file with a GitLab CI-style
// pipeline layout and returns the path to the generated file.
func RenderPipeline(p *model.Pipeline, outDir string, 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
rightMid := make(map[string]svgPt)
leftMid := make(map[string]svgPt)
- for i, stage := range stages {
- jobs := byStage[stage]
- sort.Strings(jobs)
- n := len(jobs)
+ for ci, col := range cols {
+ n := len(col.jobs)
h := n*chipH + max(0, n-1)*chipGap
if h > maxColH {
maxColH = h
}
- cx := sidePad + i*(chipW+stageGap)
- for j, name := range jobs {
+ for j, name := range col.jobs {
chy := topPad + labelH + j*(chipH+chipGap)
- rightMid[name] = svgPt{cx + chipW, chy + chipH/2}
- leftMid[name] = svgPt{cx, chy + chipH/2}
+ rightMid[name] = svgPt{colX[ci] + chipW, chy + chipH/2}
+ leftMid[name] = svgPt{colX[ci], chy + chipH/2}
}
}
- svgW := sidePad*2 + len(stages)*chipW + max(0, len(stages)-1)*stageGap
+ svgW := colX[len(cols)-1] + chipW + sidePad
svgH := topPad + labelH + maxColH + botPad
// DAG mode: any visible job with a needs: list triggers job-to-job arrows.
@@ -199,30 +316,117 @@ func pipelineSVG(p *model.Pipeline, ctx *cicontext.Context) string {
// White page background.
wf(` `, svgW, svgH)
- // ── Stage columns ─────────────────────────────────────────────────────────
- for i, stage := range stages {
- cx := sidePad + i*(chipW+stageGap)
- jobs := byStage[stage]
- sort.Strings(jobs)
+ // ── Stage headers ─────────────────────────────────────────────────────────
+ // Each declared stage may span multiple sub-columns; draw one header per stage.
+ drawnHeader := make(map[string]bool)
+ for ci, col := range cols {
+ if drawnHeader[col.stage] {
+ continue
+ }
+ // Span all sub-columns that belong to this stage.
+ x1 := colX[ci]
+ x2 := colX[ci] + chipW
+ for j := ci + 1; j < len(cols) && cols[j].stage == col.stage; j++ {
+ x2 = colX[j] + chipW
+ }
+ centerX := (x1 + x2) / 2
- // Stage name – small, gray, uppercase, centered above the chip stack.
+ // Stage name – small, gray, uppercase.
wf(` %s`,
- 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(` `,
- cx, topPad+21, cx+chipW, topPad+21)
+ x1, topPad+21, x2, topPad+21)
- // Job chips – each wrapped in a with tooltip metadata.
- for j, name := range jobs {
+ drawnHeader[col.stage] = true
+ }
+
+ // ── 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(` `,
+ src.x, src.y, cpX, src.y, cpX, dst.y, dst.x-7, dst.y, connStroke)
+ // Small right-pointing triangle arrowhead.
+ wf(` `,
+ 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(` `,
+ midX, busMinY, midX, busMaxY, connStroke)
+ }
+
+ // Horizontal stubs from each source job to the bus.
+ for _, y := range srcY {
+ wf(` `,
+ x1, y, midX, y, connStroke)
+ }
+
+ // Horizontal stubs from bus to each destination job, with arrowhead.
+ for _, y := range dstY {
+ wf(` `,
+ midX, y, x2-7, y, connStroke)
+ wf(` `,
+ 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 content: shown in the HTML sidebar and SVG viewer tooltips.
- desc := "stage: " + stage
+ desc := "stage: " + col.stage
if 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(` `,
- src.x, src.y, cpX, src.y, cpX, dst.y, dst.x-7, dst.y, connStroke)
- // Small right-pointing triangle arrowhead.
- wf(` `,
- 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(` `,
- midX, busMinY, midX, busMaxY, connStroke)
- }
-
- // Horizontal stubs from each source job to the bus.
- for _, y := range srcY {
- wf(` `,
- x1, y, midX, y, connStroke)
- }
-
- // Horizontal stubs from bus to each destination job, with arrowhead.
- for _, y := range dstY {
- wf(` `,
- midX, y, x2-7, y, connStroke)
- wf(` `,
- x2-7, y-4, x2, y, x2-7, y+4, connStroke)
- }
- }
- }
-
// ── Legend ────────────────────────────────────────────────────────────────
legend := []struct{ color, label string }{
{"#1f75cb", "regular"},
diff --git a/internal/graph/render_test.go b/internal/graph/render_test.go
index 20363a9..76b80a3 100644
--- a/internal/graph/render_test.go
+++ b/internal/graph/render_test.go
@@ -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, " connector between intra-stage jobs")
+ }
+}
+
// ── htmlPage ──────────────────────────────────────────────────────────────────
func TestHtmlPage(t *testing.T) {