5 Commits
Author SHA1 Message Date
mathias 6967d12d1d feat(atlas): weight replay pacing on CI/CD stages by real job durations (#5)
CD / Detect unsubstituted template (push) Successful in 1s
CD / Lint / Test / Vet (push) Successful in 6s
CD / var-go/oath (push) Has been skipped
CD / Build & Import (push) Successful in 18s
CD / Deploy via GitOps (push) Has been skipped
TDD: Job.Seconds() + StageSeconds() derive real CI/CD dwell time from the
latest run's per-job created_at/updated_at (last job in pipeline order =
deploy/stage 07, everything before it = CI/stage 06).

Frontend: weightedSpineDist() redistributes the pixel-time-budget the
06/07 segments already had, splitting it by real CI:CD duration ratio
instead of raw pixel width. Falls back to the exact prior constant-speed
sweep when no run data is available (weights default to segment pixel
length) or when a job is skipped (0 duration) — no behavior change for
stages 00-05/08, which still have no live timing source (same gap as #5's
ledger item).

Verified: real duration values flow through /api/atlas.json (ci_duration_s:
18 observed against a real run), full page screenshot confirms no visual
regression.
2026-07-20 23:22:53 +02:00
mathias b2761a4747 feat(gitea): surface mathias's own open Gitea issues on stage 03 (#4)
CD / Detect unsubstituted template (push) Successful in 1s
CD / Lint / Test / Vet (push) Successful in 5s
CD / var-go/oath (push) Has been skipped
CD / Build & Import (push) Successful in 12s
CD / Deploy via GitOps (push) Has been skipped
TDD: IssueNodes parses /repos/issues/search into stage nodes (title, repo
tag, clickable html_url). gitea.MyIssues() reads GITEA_TOKEN and skips
gracefully when unset, mirroring the existing liveOverlay fallback pattern.

Single-operator homelab, not per-visitor OAuth: a static read-only PAT
gates on "cleared Authentik forward-auth", not per-user token exchange —
see #4 discussion. GITEA_TOKEN provisioning in infra (ExternalSecret) is
a separate follow-up; without it the overlay is inert (no crash, just no
live nodes), so this ships safely ahead of that wiring.

Nodes with a url now render as clickable <a class="node"> instead of
<div class="node">.
2026-07-20 23:04:44 +02:00
mathias 68ae01cb75 docs(project): drop stale branch-protection caveat, closed by #8
CD / Detect unsubstituted template (push) Successful in 1s
CD / Lint / Test / Vet (push) Successful in 6s
CD / var-go/oath (push) Has been skipped
CD / Build & Import (push) Successful in 14s
CD / Deploy via GitOps (push) Successful in 1s
2026-07-20 20:53:18 +00:00
mathias 2f5fca8513 docs(oath): mark S3 enforced now that branch protection requires var-go/oath (closes #8)
CD / Detect unsubstituted template (push) Successful in 1s
CD / Lint / Test / Vet (push) Successful in 6s
CD / var-go/oath (push) Has been skipped
CD / Build & Import (push) Successful in 14s
CD / Deploy via GitOps (push) Successful in 1s
2026-07-20 20:51:35 +00:00
mathiasandClaude Sonnet 5 805b76d7c3 feat(oath): gate cad-atlas's own real candidate, not swedsl's toy stub (#8)
CD / Detect unsubstituted template (push) Successful in 0s
CD / Lint / Test / Vet (push) Successful in 5s
CD / var-go/oath (push) Has been skipped
CD / Build & Import (push) Successful in 14s
CD / Deploy via GitOps (push) Successful in 1s
oathcandidate/ is a separate Go module (mirrors swedsl's own
oath/testdata/selfcandidate pattern, keeping var-go's transitive deps
out of the deployed atlas binary) whose Build() parses the committed
.gitea/workflows/cd.yml and checks the "oath" job exists and invokes
cmd/vargo-gate. TDD: passes against the real file, fails closed on a
fixture missing the job.

Rewires the oath CI job to go-run vargo-gate from its real module path
(git.d-ma.be/mathias/swedsl/oath/cmd/vargo-gate@oath/v0.28.0, unblocked
by swedsl#35/#38) against VARGO_CANDIDATE_DIR=oathcandidate, instead of
checking out swedsl and gating its hardcoded toy fixture. Private-module
auth via a short-lived GIT_ASKPASS script (token never in argv, never
written to git config, matches act_runner's env:-block-with-secrets
gotcha).

Discovered along the way: var-go's parser needs single-line,
period-separated oath sentences with no Given/When/Then/And keyword
stripping — this repo's older oaths (incl. #1) used an unverified
multi-line keyword-prefixed style. #8's oath uses the proven format.

Still not required by branch protection pending a real-PR confirmation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 14:33:17 +02:00
16 changed files with 552 additions and 62 deletions
+10 -10
View File
@@ -54,16 +54,16 @@ assessor-loop ledger) → `06 PR → CI` (go test/vet/lint/govulncheck + **var-g
This repo is built *through* the workflow it depicts. It is `dispatch-allow`-enabled, and its This repo is built *through* the workflow it depicts. It is `dispatch-allow`-enabled, and its
own build increments are governed by a **var-go Oath** embedded in their spec issues (see the own build increments are governed by a **var-go Oath** embedded in their spec issues (see the
Stage-03 tracking issue). Bootstrapping honesty (per swedsl honest-stub discipline): `cmd/vargo-gate` Stage-03 tracking issue). `cmd/vargo-gate` is wired into `.gitea/workflows/cd.yml`'s `oath` job —
is wired into `.gitea/workflows/cd.yml`'s `oath` job (issue #1, verified with a real PR) — it runs on every pull_request it fetches the linked issue's oath and gates cad-atlas's **own real
on every pull_request, fetches the linked issue's oath, and posts a `var-go/oath` commit status. candidate** (`oathcandidate/`, #8): it parses the committed `.gitea/workflows/cd.yml` and checks
But its candidate is still swedsl's hardcoded toy self-test registry, not cad-atlas's own — it the `oath` job actually exists and invokes `cmd/vargo-gate`, then posts the `var-go/oath` commit
fails closed against any real oath. var-go's Executor/Reviewer path is **not** the blocker (that status. This is a real check (TDD'd: passes on the real file, fails closed on a fixture missing
was explicitly killed by swedsl's ADR-0002, swedsl#27 — var-go is gate-only by design, each the job), not swedsl's toy self-test stub — swedsl#35 (import path) and swedsl#38 (real-candidate
consumer supplies its own candidate). The real blocker is `swedsl/oath`'s import path subprocess gating) unblocked it. **Required by branch protection on `main`** (#8, closed
(`swedsl#35`); once fixed, cad-atlas writes its own candidate (`#8`). The status is **not** 2026-07-20), confirmed holding on a real PR. Direct pushes remain allowlisted for `mathias` per
required by branch protection, so it can't block merges yet. Disclosed in the CI config comment, this repo's TBD convention. Disclosed in the CI config comment, this doc, and
this doc, and `docs/INCEPTION-OATH.md`. `docs/INCEPTION-OATH.md`.
## Brain references (source of truth — `brain_get <path>`) ## Brain references (source of truth — `brain_get <path>`)
+48 -25
View File
@@ -53,50 +53,73 @@ jobs:
- name: Run checks - name: Run checks
run: task check run: task check
- name: oathcandidate module — vet + test (private dep, short-lived askpass)
working-directory: oathcandidate
run: |
set -euo pipefail
export DMABE_GITEA_API_TOKEN='${{ secrets.DMABE_GITEA_API_TOKEN }}'
ASKPASS=$(mktemp)
{ echo '#!/bin/sh'
echo 'case "$1" in'
echo ' *Username*) echo oauth2 ;;'
echo ' *) echo "$DMABE_GITEA_API_TOKEN" ;;'
echo 'esac'
} > "$ASKPASS"
chmod 700 "$ASKPASS"
export GIT_ASKPASS="$ASKPASS" GIT_TERMINAL_PROMPT=0 GOPRIVATE=git.d-ma.be
go vet ./...
go test ./...
rm -f "$ASKPASS"
oath: oath:
name: var-go/oath name: var-go/oath
needs: guard needs: guard
# Only a real pull_request event carries a linked-issue oath to gate (mirrors # cad-atlas's own real candidate (#8): oathcandidate/ parses the committed
# swedsl's own oath job, .gitea/workflows/ci.yml). v1 simplification (swedsl#30): # .gitea/workflows/cd.yml and gates it against cad-atlas#8's oath — replacing the
# the oath issue number is the PR's OWN number. # earlier wiring-only proof (#1) that always gated swedsl's toy self-test fixture
# # and always failed closed. cmd/vargo-gate (swedsl#35/#37/#38) now go-installs
# DISCLOSED LIMITATION (honest-stub discipline, see docs/INCEPTION-OATH.md S3 and # cleanly from its real module path and runs the candidate module in a sandboxed
# knowledge/swedsl-vargo-sprint1-enforcement-teeth-verdict.md): cmd/vargo-gate's # subprocess (SubprocessGate, ADR-0003) — a green status here means "the committed
# candidate is a hardcoded toy self-test registry (swedsl's own #9 fixture # CI config satisfies its oath", not merely "the wiring ran". Still NOT required by
# vocabulary), not a real PR-diff checker. It will fail closed against any oath # branch protection (#8) until proven green on a real PR.
# that isn't that toy vocabulary — which is every real oath, including this repo's
# own #1. A red or green "var-go/oath" status here currently proves the WIRING
# (fetch issue -> gate -> post commit status) runs end-to-end on a real PR, not
# that the PR satisfies its linked issue's oath. Deliberately NOT required by
# branch protection until cad-atlas has its own candidate matching its real oath
# vocabulary (#8, blocked on swedsl/oath's import path, swedsl#35) — making it
# required now would permanently block every cad-atlas PR.
if: needs.guard.outputs.is_template != 'true' && github.event_name == 'pull_request' if: needs.guard.outputs.is_template != 'true' && github.event_name == 'pull_request'
runs-on: self-hosted runs-on: self-hosted
steps: steps:
- name: Checkout swedsl (var-go source — not go-installable, module path isn't a real import path) - uses: actions/checkout@v4
uses: actions/checkout@v4
with:
repository: mathias/swedsl
path: swedsl
token: ${{ secrets.DMABE_GITEA_API_TOKEN }}
- uses: actions/setup-go@v5 - uses: actions/setup-go@v5
with: with:
go-version-file: swedsl/oath/go.mod go-version-file: oathcandidate/go.mod
cache: false cache: false
- name: Run vargo-gate (fetch -> gate -> post status against this PR) - name: Run vargo-gate (fetch linked oath -> sandboxed-gate the real candidate -> post status)
working-directory: swedsl/oath
env: env:
VARGO_GITEA_BASEURL: ${{ github.server_url }} VARGO_GITEA_BASEURL: ${{ github.server_url }}
VARGO_GITEA_OWNER: ${{ github.repository_owner }} VARGO_GITEA_OWNER: ${{ github.repository_owner }}
VARGO_GITEA_REPO: cad-atlas VARGO_GITEA_REPO: cad-atlas
# Oath issue resolution (swedsl#38): a "Closes #NN" reference in the PR body
# picks the linked oath issue; VARGO_GITEA_ISSUE is the fallback (PR's own
# number, correct only for a PR filed directly against its oath issue).
VARGO_PR_BODY: ${{ github.event.pull_request.body }}
VARGO_GITEA_ISSUE: ${{ github.event.pull_request.number }} VARGO_GITEA_ISSUE: ${{ github.event.pull_request.number }}
VARGO_GITEA_SHA: ${{ github.event.pull_request.head.sha }} VARGO_GITEA_SHA: ${{ github.event.pull_request.head.sha }}
VARGO_CANDIDATE_DIR: oathcandidate
# Sandbox is ON by default (untrusted PR code runs in a fresh user+net
# namespace, swedsl#37); no need to set VARGO_SANDBOX here.
run: | run: |
set -euo pipefail
export DMABE_GITEA_API_TOKEN='${{ secrets.DMABE_GITEA_API_TOKEN }}' export DMABE_GITEA_API_TOKEN='${{ secrets.DMABE_GITEA_API_TOKEN }}'
go run ./cmd/vargo-gate ASKPASS=$(mktemp)
{ echo '#!/bin/sh'
echo 'case "$1" in'
echo ' *Username*) echo oauth2 ;;'
echo ' *) echo "$DMABE_GITEA_API_TOKEN" ;;'
echo 'esac'
} > "$ASKPASS"
chmod 700 "$ASKPASS"
export GIT_ASKPASS="$ASKPASS" GIT_TERMINAL_PROMPT=0 GOPRIVATE=git.d-ma.be
go run git.d-ma.be/mathias/swedsl/oath/cmd/vargo-gate@oath/v0.28.0
rm -f "$ASKPASS"
build: build:
name: Build & Import name: Build & Import
+13 -8
View File
@@ -3,9 +3,10 @@
The acceptance contract for standing up cad-atlas. The sprint is finalized only when this The acceptance contract for standing up cad-atlas. The sprint is finalized only when this
Oath holds. Methodology: brain `wiki/homelab/decisions/inception-sprint-and-oath.md`. Oath holds. Methodology: brain `wiki/homelab/decisions/inception-sprint-and-oath.md`.
> **Status of enforcement:** this Oath is currently **advisory** (human-verified). `var-go/oath` > **Status of enforcement:** `var-go/oath` gates a real candidate (`oathcandidate/`, #8 — parses
> is wired (issue #1) and runs on every PR, but its candidate is a toy self-test — it fails closed > the committed CI workflow, TDD'd pass/fail-closed) and is now **required by branch protection**
> against any real oath and is not required by branch protection. See the honesty rule below. > on `main` (verified green on a real PR). Direct pushes remain allowlisted for `mathias` per this
> repo's TBD convention.
## General clauses (any inception sprint) ## General clauses (any inception sprint)
@@ -25,7 +26,7 @@ Oath holds. Methodology: brain `wiki/homelab/decisions/inception-sprint-and-oath
|---|--------|--------|----------| |---|--------|--------|----------|
| S1 | Atlas served at `/`, renders all 9 stages signal→pod | ✅ | `internal/web/handler.go` + `static/cad-atlas.html` | | S1 | Atlas served at `/`, renders all 9 stages signal→pod | ✅ | `internal/web/handler.go` + `static/cad-atlas.html` |
| S2 | Oath covered in the viz (stages 03 + 06) | ✅ | var-go Oath nodes in the atlas | | S2 | Oath covered in the viz (stages 03 + 06) | ✅ | var-go Oath nodes in the atlas |
| S3 | `var-go/oath` enforces cad-atlas's own PRs | **wired, not enforcing → #8** | `oath` job runs + posts status (#1, verified with a real PR). Not branch-protection-required: candidate is still swedsl's toy self-test, fails closed on every real oath. Blocked on `swedsl/oath` import path (swedsl#35) → cad-atlas writing its own candidate (#8). Not blocked on an Executor — that path was killed by swedsl's ADR-0002. See honesty rule. | | S3 | `var-go/oath` enforces cad-atlas's own PRs | ✅ | `oathcandidate/` gates the real `.gitea/workflows/cd.yml` (TDD green: passes real file, fails closed on a fixture missing the job) via swedsl's sandboxed `SubprocessGate` (swedsl#35/#38). Branch protection on `main` now requires `var-go/oath`, confirmed holding on a real PR (#8). |
## Deployment ## Deployment
@@ -37,10 +38,14 @@ namespace `cad-atlas`, 1 replica, `cad-atlas:80 → :8080` (manifests in `mathia
## The honesty rule ## The honesty rule
A clause blocked by an external dependency is **descoped and tracked, never marked satisfied** A clause blocked by an external dependency is **descoped and tracked, never marked satisfied**
a self-lying Oath is a rubber stamp, the exact failure the Oath exists to prevent. S3's real a self-lying Oath is a rubber stamp, the exact failure the Oath exists to prevent. S3 is now fully
enforcement depends on `swedsl/oath` becoming importable (swedsl#35) and cad-atlas writing its own enforced: real candidate wired and branch-protection-required (#8), confirmed on a real PR. The
candidate (#8); it is tracked there, not claimed here. The `DMABE_GITEA_API_TOKEN` Actions secret `DMABE_GITEA_API_TOKEN` Actions secret is pre-provisioned so #1 and #8 both landed without a
is pre-provisioned so #1 landed without a secret-write. secret-write.
Also surfaced by #8: this file's own "Oath (advisory form)" below predates the discovery that
var-go's parser requires single-line, period-separated sentences with no `Given`/`Then`/`And`
keyword stripping — it has never been machine-gated and would need reformatting first if it ever is.
## The Oath (advisory form) ## The Oath (advisory form)
+1 -1
View File
@@ -23,7 +23,7 @@
{"t":"🏛️ LLM Council","plain_t":"AI review panel","plain":"For hard calls, several AI models answer independently, anonymously critique each other, and a \"chair\" model synthesises one verdict — reducing any single model's bias.","cls":"council","pill":"var(--violet)","d":"fan-out → anonymous cross-review → chairman synth. glm-4.7-flash · qwen36-35b · gemma4-31b (chair).","tags":["hard strategic Q","chat.d-ma.be"]}, {"t":"🏛️ LLM Council","plain_t":"AI review panel","plain":"For hard calls, several AI models answer independently, anonymously critique each other, and a \"chair\" model synthesises one verdict — reducing any single model's bias.","cls":"council","pill":"var(--violet)","d":"fan-out → anonymous cross-review → chairman synth. glm-4.7-flash · qwen36-35b · gemma4-31b (chair).","tags":["hard strategic Q","chat.d-ma.be"]},
{"t":"Autoresearch Council","plain_t":"Research review panel","plain":"A parallel version of the same review that vets research findings before they're allowed through.","cls":"council","pill":"var(--violet)","d":"Sibling pipe — ratifies research before the gate.","tags":["proposed: → standalone svc"]} {"t":"Autoresearch Council","plain_t":"Research review panel","plain":"A parallel version of the same review that vets research findings before they're allowed through.","cls":"council","pill":"var(--violet)","d":"Sibling pipe — ratifies research before the gate.","tags":["proposed: → standalone svc"]}
]}, ]},
{"no":"STAGE 03","short":"Write order","title":"Spec → Gitea issue","plain_title":"Write the work order","plain":"The decision is turned into a precise, self-contained work order an AI agent can execute unsupervised — with a pass/fail definition of done, a risk rating, and a tamper-proof seal.","path":"agent-ready contract", {"no":"STAGE 03","short":"Write order","title":"Spec → Gitea issue","plain_title":"Write the work order","plain":"The decision is turned into a precise, self-contained work order an AI agent can execute unsupervised — with a pass/fail definition of done, a risk rating, and a tamper-proof seal.","path":"agent-ready contract","generate":"gitea-issues",
"trans_label":"Sealed & agent-ready","trans":"Advances to the gate only when the spec is a complete contract: a pass/fail test, a risk tier, a regulatory note, no open human dependencies, one embedded Oath, and a valid cryptographic signature. A malformed or unsigned order fails closed and never reaches the gate.","nodes":[ "trans_label":"Sealed & agent-ready","trans":"Advances to the gate only when the spec is a complete contract: a pass/fail test, a risk tier, a regulatory note, no open human dependencies, one embedded Oath, and a valid cryptographic signature. A malformed or unsigned order fails closed and never reaches the gate.","nodes":[
{"t":"Contract enforced","plain_t":"The work-order rules","plain":"The work order must have a clear pass/fail test, a risk rating, a regulatory-risk note, and no unfinished human dependencies before it counts as agent-ready.","d":"Binary ISC · declared risk tier · reg-risk assessment · no open human deps.","tags":["LOW / MED / HIGH"]}, {"t":"Contract enforced","plain_t":"The work-order rules","plain":"The work order must have a clear pass/fail test, a risk rating, a regulatory-risk note, and no unfinished human dependencies before it counts as agent-ready.","d":"Binary ISC · declared risk tier · reg-risk assessment · no open human deps.","tags":["LOW / MED / HIGH"]},
{"t":"Admission controller","plain_t":"Tamper-proof seal","plain":"The work order is cryptographically signed when created, so any later tampering is detectable and the eventual change can be checked against it.","d":"Ed25519-sign issue body at creation (#36). Verify sig + PR alignment at infra boundary.","tags":["chain of custody"]}, {"t":"Admission controller","plain_t":"Tamper-proof seal","plain":"The work order is cryptographically signed when created, so any later tampering is detectable and the eventual change can be checked against it.","d":"Ed25519-sign issue body at creation (#36). Verify sig + PR alignment at infra boundary.","tags":["chain of custody"]},
+32
View File
@@ -0,0 +1,32 @@
package atlas
import (
"encoding/json"
"fmt"
)
// IssueNodes parses a Gitea `/repos/issues/search` response (the
// authenticated user's own open issues, newest first) into one node per
// issue, linking out to the issue.
func IssueNodes(searchJSON []byte) ([]Node, error) {
var issues []struct {
Number int `json:"number"`
Title string `json:"title"`
HTMLURL string `json:"html_url"`
Repository struct {
FullName string `json:"full_name"`
} `json:"repository"`
}
if err := json.Unmarshal(searchJSON, &issues); err != nil {
return nil, fmt.Errorf("parse issues: %w", err)
}
nodes := make([]Node, 0, len(issues))
for _, i := range issues {
nodes = append(nodes, Node{
Title: fmt.Sprintf("#%d %s", i.Number, i.Title),
Tags: []string{"live · Gitea", i.Repository.FullName},
URL: i.HTMLURL,
})
}
return nodes, nil
}
+41
View File
@@ -0,0 +1,41 @@
package atlas_test
import (
"testing"
"git.d-ma.be/mathias/cad-atlas/internal/atlas"
)
func TestIssueNodes_OneNodePerIssueWithRepoTagAndURL(t *testing.T) {
search := []byte(`[
{"number":212,"title":"segment-embedder: add smoke test","html_url":"https://git.d-ma.be/mathias/infra/issues/212","repository":{"full_name":"mathias/infra"}},
{"number":8,"title":"Write cad-atlas's own vargo-gate candidate","html_url":"https://git.d-ma.be/mathias/cad-atlas/issues/8","repository":{"full_name":"mathias/cad-atlas"}}
]`)
nodes, err := atlas.IssueNodes(search)
if err != nil {
t.Fatalf("IssueNodes: %v", err)
}
if len(nodes) != 2 {
t.Fatalf("want 2 nodes, got %d", len(nodes))
}
if nodes[0].Title != "#212 segment-embedder: add smoke test" {
t.Fatalf("title = %q", nodes[0].Title)
}
if nodes[0].URL != "https://git.d-ma.be/mathias/infra/issues/212" {
t.Fatalf("url = %q", nodes[0].URL)
}
if len(nodes[0].Tags) != 2 || nodes[0].Tags[0] != "live · Gitea" || nodes[0].Tags[1] != "mathias/infra" {
t.Fatalf("tags = %v", nodes[0].Tags)
}
}
func TestIssueNodes_EmptyListReturnsEmptyNotNil(t *testing.T) {
nodes, err := atlas.IssueNodes([]byte(`[]`))
if err != nil {
t.Fatalf("IssueNodes: %v", err)
}
if nodes == nil || len(nodes) != 0 {
t.Fatalf("nodes = %+v, want empty non-nil slice", nodes)
}
}
+3
View File
@@ -23,6 +23,7 @@ type Node struct {
Tags []string `json:"tags,omitempty"` Tags []string `json:"tags,omitempty"`
Risk bool `json:"risk,omitempty"` Risk bool `json:"risk,omitempty"`
Gate bool `json:"gate,omitempty"` Gate bool `json:"gate,omitempty"`
URL string `json:"url,omitempty"`
} }
// Stage is one column of the pipeline. PlainTitle/Plain are the plain-language // Stage is one column of the pipeline. PlainTitle/Plain are the plain-language
@@ -50,6 +51,8 @@ type Atlas struct {
Substrate []Host `json:"substrate"` Substrate []Host `json:"substrate"`
NS string `json:"ns,omitempty"` NS string `json:"ns,omitempty"`
Timeline []RunDot `json:"timeline,omitempty"` Timeline []RunDot `json:"timeline,omitempty"`
CIDurationS float64 `json:"ci_duration_s,omitempty"`
CDDurationS float64 `json:"cd_duration_s,omitempty"`
Stages []Stage `json:"stages"` Stages []Stage `json:"stages"`
} }
+37 -2
View File
@@ -3,13 +3,18 @@ package atlas
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"time"
) )
// Job is one job within a workflow run (a Gitea Actions "task"). // Job is one job within a workflow run (a Gitea Actions "task"). Started/
// Finished are best-effort (zero value if the job hasn't completed or the
// timestamp failed to parse).
type Job struct { type Job struct {
Name string Name string
Status string Status string
Conclusion string Conclusion string
Started time.Time
Finished time.Time
} }
// State is the effective outcome: conclusion if set, else status. // State is the effective outcome: conclusion if set, else status.
@@ -20,6 +25,14 @@ func (j Job) State() string {
return j.Status return j.Status
} }
// Seconds is how long the job ran, or 0 if either timestamp is missing/invalid.
func (j Job) Seconds() float64 {
if j.Started.IsZero() || j.Finished.Before(j.Started) {
return 0
}
return j.Finished.Sub(j.Started).Seconds()
}
// RunSummary is the newest workflow run and its per-job outcomes. // RunSummary is the newest workflow run and its per-job outcomes.
type RunSummary struct { type RunSummary struct {
Number int Number int
@@ -100,6 +113,8 @@ func LatestRunJobs(tasksJSON []byte) (RunSummary, error) {
Conclusion string `json:"conclusion"` Conclusion string `json:"conclusion"`
SHA string `json:"head_sha"` SHA string `json:"head_sha"`
Title string `json:"display_title"` Title string `json:"display_title"`
Created string `json:"created_at"`
Updated string `json:"updated_at"`
} `json:"workflow_runs"` } `json:"workflow_runs"`
} }
if err := json.Unmarshal(tasksJSON, &resp); err != nil { if err := json.Unmarshal(tasksJSON, &resp); err != nil {
@@ -113,7 +128,12 @@ func LatestRunJobs(tasksJSON []byte) (RunSummary, error) {
s := RunSummary{Number: latest.RunNumber, SHA: latest.SHA, Title: latest.Title} s := RunSummary{Number: latest.RunNumber, SHA: latest.SHA, Title: latest.Title}
for _, t := range resp.Tasks { for _, t := range resp.Tasks {
if t.RunNumber == latest.RunNumber { if t.RunNumber == latest.RunNumber {
s.Jobs = append(s.Jobs, Job{Name: t.Name, Status: t.Status, Conclusion: t.Conclusion}) started, _ := time.Parse(time.RFC3339, t.Created)
finished, _ := time.Parse(time.RFC3339, t.Updated)
s.Jobs = append(s.Jobs, Job{
Name: t.Name, Status: t.Status, Conclusion: t.Conclusion,
Started: started, Finished: finished,
})
} }
} }
// Gitea lists newest (last-finished) first; reverse to pipeline order. // Gitea lists newest (last-finished) first; reverse to pipeline order.
@@ -142,6 +162,21 @@ func RunNodes(s RunSummary) []Node {
return nodes return nodes
} }
// StageSeconds splits a run's real job durations across the two live-timed
// stages: the last job in pipeline order is the deploy (stage 07), everything
// before it is CI (stage 06). Returns 0, 0 if there are no jobs.
func StageSeconds(s RunSummary) (ci, cd float64) {
if len(s.Jobs) == 0 {
return 0, 0
}
last := len(s.Jobs) - 1
for _, j := range s.Jobs[:last] {
ci += j.Seconds()
}
cd = s.Jobs[last].Seconds()
return ci, cd
}
func statePill(state string) string { func statePill(state string) string {
switch state { switch state {
case "success": case "success":
+50
View File
@@ -2,6 +2,7 @@ package atlas_test
import ( import (
"testing" "testing"
"time"
"git.d-ma.be/mathias/cad-atlas/internal/atlas" "git.d-ma.be/mathias/cad-atlas/internal/atlas"
) )
@@ -104,3 +105,52 @@ func TestLatestRunJobs_ErrorsWhenEmpty(t *testing.T) {
t.Fatal("expected error on empty, got nil") t.Fatal("expected error on empty, got nil")
} }
} }
func TestLatestRunJobs_ParsesPerJobTimestampsIntoSeconds(t *testing.T) {
tasks := []byte(`{"workflow_runs":[
{"run_number":28,"name":"Deploy via GitOps","status":"success","created_at":"2026-07-20T21:05:44Z","updated_at":"2026-07-20T21:05:50Z"},
{"run_number":28,"name":"Lint / Test / Vet","status":"success","created_at":"2026-07-20T21:05:39Z","updated_at":"2026-07-20T21:05:44Z"}
]}`)
s, err := atlas.LatestRunJobs(tasks)
if err != nil {
t.Fatalf("LatestRunJobs: %v", err)
}
// pipeline order: Lint first, Deploy last
if got := s.Jobs[0].Seconds(); got != 5 {
t.Fatalf("Lint job seconds = %v, want 5", got)
}
if got := s.Jobs[1].Seconds(); got != 6 {
t.Fatalf("Deploy job seconds = %v, want 6", got)
}
}
func TestStageSeconds_LastJobIsDeploySumOfRestIsCI(t *testing.T) {
s := atlas.RunSummary{Jobs: []atlas.Job{
{Name: "Lint", Started: mustParse("2026-07-20T21:00:00Z"), Finished: mustParse("2026-07-20T21:00:10Z")}, // 10s
{Name: "Build", Started: mustParse("2026-07-20T21:00:10Z"), Finished: mustParse("2026-07-20T21:00:25Z")}, // 15s
{Name: "Deploy", Started: mustParse("2026-07-20T21:00:25Z"), Finished: mustParse("2026-07-20T21:00:33Z")}, // 8s
}}
ci, cd := atlas.StageSeconds(s)
if ci != 25 {
t.Fatalf("ci = %v, want 25", ci)
}
if cd != 8 {
t.Fatalf("cd = %v, want 8", cd)
}
}
func TestStageSeconds_NoJobsReturnsZero(t *testing.T) {
ci, cd := atlas.StageSeconds(atlas.RunSummary{})
if ci != 0 || cd != 0 {
t.Fatalf("ci=%v cd=%v, want 0,0", ci, cd)
}
}
func mustParse(s string) time.Time {
t, err := time.Parse(time.RFC3339, s)
if err != nil {
panic(err)
}
return t
}
+33 -4
View File
@@ -4,6 +4,7 @@
package gitea package gitea
import ( import (
"context"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@@ -21,9 +22,37 @@ func base() string {
// Runs returns the raw /actions/tasks JSON for mathias/cad-atlas (newest first). // Runs returns the raw /actions/tasks JSON for mathias/cad-atlas (newest first).
func Runs() ([]byte, error) { func Runs() ([]byte, error) {
url := base() + "/api/v1/repos/mathias/cad-atlas/actions/tasks?limit=50" return get(base()+"/api/v1/repos/mathias/cad-atlas/actions/tasks?limit=50", "")
client := &http.Client{Timeout: 5 * time.Second} }
resp, err := client.Get(url) //nolint:noctx // short-lived, timeout on the client
// MyIssues returns the raw /repos/issues/search JSON for the token owner's
// own open issues across every repo they can see. Requires GITEA_TOKEN — a
// read-only PAT for the mathias account (this is a single-operator homelab,
// not per-visitor OAuth: anyone who clears Authentik forward-auth sees
// Mathias's own data). Returns an error if GITEA_TOKEN is unset, so callers
// can skip the overlay gracefully.
func MyIssues() ([]byte, error) {
token := os.Getenv("GITEA_TOKEN")
if token == "" {
return nil, fmt.Errorf("GITEA_TOKEN not set")
}
url := base() + "/api/v1/repos/issues/search?state=open&created=true&type=issues&limit=8"
return get(url, token)
}
// get performs a short-lived GET, optionally with a bearer token, and returns
// the response body.
func get(url, token string) ([]byte, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
if token != "" {
req.Header.Set("Authorization", "token "+token)
}
resp, err := http.DefaultClient.Do(req)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -33,7 +62,7 @@ func Runs() ([]byte, error) {
return nil, err return nil, err
} }
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("gitea runs: %s", resp.Status) return nil, fmt.Errorf("gitea: %s", resp.Status)
} }
return body, nil return body, nil
} }
+14
View File
@@ -55,6 +55,14 @@ func NewHandler() http.Handler {
a.Stages[i].Nodes = nodes a.Stages[i].Nodes = nodes
} }
} }
a.CIDurationS, a.CDDurationS = atlas.StageSeconds(*ld.run)
}
if ld.issues != nil {
for i := range a.Stages {
if a.Stages[i].Generate == "gitea-issues" {
a.Stages[i].Nodes = append(ld.issues, a.Stages[i].Nodes...)
}
}
} }
if ld.deploy != nil || ld.flux != nil { if ld.deploy != nil || ld.flux != nil {
var live []atlas.Node var live []atlas.Node
@@ -84,6 +92,7 @@ type liveOverlayData struct {
deploy *atlas.Deploy deploy *atlas.Deploy
flux *atlas.Flux flux *atlas.Flux
timeline []atlas.RunDot timeline []atlas.RunDot
issues []atlas.Node
} }
// live cache: query the cluster at most once per TTL; fall back to the authored // live cache: query the cluster at most once per TTL; fall back to the authored
@@ -123,6 +132,11 @@ func liveOverlay() liveOverlayData {
d.timeline = dots d.timeline = dots
} }
} }
if raw, err := gitea.MyIssues(); err == nil {
if nodes, err := atlas.IssueNodes(raw); err == nil {
d.issues = nodes
}
}
if raw, err := cluster.Deployment(); err == nil { if raw, err := cluster.Deployment(); err == nil {
if dep, err := atlas.DeployState(raw); err == nil { if dep, err := atlas.DeployState(raw); err == nil {
d.deploy = &dep d.deploy = &dep
+44 -7
View File
@@ -111,8 +111,8 @@
.stage .no{color:var(--dim);font-size:11px;letter-spacing:2px} .stage .no{color:var(--dim);font-size:11px;letter-spacing:2px}
.stage h2{font-size:17px;margin:6px 0 2px} .stage h2{font-size:17px;margin:6px 0 2px}
.stage .path{color:var(--mono);font-size:11.5px;margin-bottom:6px;min-height:16px} .stage .path{color:var(--mono);font-size:11.5px;margin-bottom:6px;min-height:16px}
.node{border:1px solid var(--line);border-radius:11px;background:var(--panel); .node{display:block;border:1px solid var(--line);border-radius:11px;background:var(--panel);
padding:12px 13px;margin-top:12px;position:relative; padding:12px 13px;margin-top:12px;position:relative;color:inherit;text-decoration:none;
transition:border-color .25s,box-shadow .25s,transform .25s} transition:border-color .25s,box-shadow .25s,transform .25s}
.node .t{font-weight:600;margin-bottom:3px;display:flex;align-items:center;gap:7px} .node .t{font-weight:600;margin-bottom:3px;display:flex;align-items:center;gap:7px}
.node .d{color:var(--dim);font-size:12px} .node .d{color:var(--dim);font-size:12px}
@@ -214,7 +214,7 @@
// stage from the live cd.yml + substrate from the live cluster). These start // stage from the live cd.yml + substrate from the live cluster). These start
// empty and are filled by init()'s fetch; on failure the page shows an error // empty and are filled by init()'s fetch; on failure the page shows an error
// banner rather than stale inline data. // banner rather than stale inline data.
let SUBSTRATE=[], NS="", STAGES=[], TIMELINE=[]; let SUBSTRATE=[], NS="", STAGES=[], TIMELINE=[], CI_DUR=0, CD_DUR=0;
let MODE = localStorage.getItem('atlas-mode') || 'plain'; // 'plain' | 'technical' let MODE = localStorage.getItem('atlas-mode') || 'plain'; // 'plain' | 'technical'
const track=document.getElementById('track'); const track=document.getElementById('track');
@@ -246,7 +246,9 @@ function renderAtlas(){
if(n.risk)inner+=`<div class="risk mono"><span class="lo">LOW · auto</span><span class="md">MED · ntfy gate</span><span class="hi">HIGH · blocked</span></div>`; if(n.risk)inner+=`<div class="risk mono"><span class="lo">LOW · auto</span><span class="md">MED · ntfy gate</span><span class="hi">HIGH · blocked</span></div>`;
} }
if(n.gate)inner+=`<div class="gatebtns mono"><div class="g ok">✓ approve</div><div class="g no">✕ reject</div></div>`; if(n.gate)inner+=`<div class="gatebtns mono"><div class="g ok">✓ approve</div><div class="g no">✕ reject</div></div>`;
h+=`<div class="node ${n.cls||''}">${inner}</div>`; const tag = n.url ? 'a' : 'div';
const link = n.url ? ` href="${n.url}" target="_blank" rel="noopener"` : '';
h+=`<${tag} class="node ${n.cls||''}"${link}>${inner}</${tag}>`;
}); });
st.innerHTML=h;track.appendChild(st);stageEls.push(st); st.innerHTML=h;track.appendChild(st);stageEls.push(st);
// stacked-layout transition row (shown on mobile where the SVG spine is hidden) // stacked-layout transition row (shown on mobile where the SVG spine is hidden)
@@ -328,14 +330,47 @@ function build(){
} }
spineLen=spinePath.getTotalLength();loopLen=loopPath.getTotalLength(); spineLen=spinePath.getTotalLength();loopLen=loopPath.getTotalLength();
} }
// weightedSpineDist maps f (0..1 time-progress across the spine) to an arc-length
// distance. Default weight per stage-to-stage segment is its own pixel length —
// reduces to the old constant-pixel-speed sweep. When a real run's CI/CD durations
// are known, the pixel-time-budget already held by the 06(CI)/07(CD) segments is
// re-split by their real relative duration instead of by raw pixel width — so the
// reel visits stage 06 vs 07 at speeds proportional to how long they actually took.
function weightedSpineDist(f){
const n=cs.length-1;
if(n<=0)return 0;
const segLens=[];for(let i=0;i<n;i++)segLens.push(cs[i+1]-cs[i]);
const weights=segLens.slice();
if(CI_DUR>0&&CD_DUR>0&&n>=7){
const budget=weights[5]+weights[6], tot=CI_DUR+CD_DUR;
weights[5]=budget*CI_DUR/tot; weights[6]=budget*CD_DUR/tot;
}
const totalW=weights.reduce((a,b)=>a+b,0);
const target=f*totalW;
let acc=0;
for(let i=0;i<n;i++){
if(target<=acc+weights[i]||i===n-1){
const local=weights[i]>0?(target-acc)/weights[i]:0;
const segStart=cs[i]-cs[0];
return Math.min(spineLen,Math.max(0,segStart+segLens[i]*Math.min(1,Math.max(0,local))));
}
acc+=weights[i];
}
return spineLen;
}
function run(ts){ function run(ts){
if(mobile)return; if(mobile)return;
if(!t0)t0=ts; if(!t0)t0=ts;
const dur=slow?16000:7000; const dur=slow?16000:7000;
const p=Math.min((ts-t0)/dur,1); const p=Math.min((ts-t0)/dur,1);
const total=spineLen+loopLen, dist=total*p; const spineFrac=spineLen/(spineLen+loopLen);
let pt,onLoop=dist>spineLen; let dist,onLoop;
pt=onLoop?loopPath.getPointAtLength(dist-spineLen):spinePath.getPointAtLength(dist); if(p<spineFrac){
dist=weightedSpineDist(p/spineFrac); onLoop=false;
}else{
dist=spineLen+loopLen*((p-spineFrac)/(1-spineFrac)); onLoop=true;
}
let pt=onLoop?loopPath.getPointAtLength(dist-spineLen):spinePath.getPointAtLength(dist);
pulse.style.left=pt.x+'px';pulse.style.top=pt.y+'px'; pulse.style.left=pt.x+'px';pulse.style.top=pt.y+'px';
pulse.style.background=onLoop?'var(--violet)':'var(--amber)'; pulse.style.background=onLoop?'var(--violet)':'var(--amber)';
pulse.style.boxShadow=onLoop?'0 0 15px 4px rgba(155,140,255,.75)':'0 0 15px 4px rgba(245,185,66,.75)'; pulse.style.boxShadow=onLoop?'0 0 15px 4px rgba(155,140,255,.75)':'0 0 15px 4px rgba(245,185,66,.75)';
@@ -368,6 +403,8 @@ async function init(){
if(typeof data.ns==='string') NS=data.ns; if(typeof data.ns==='string') NS=data.ns;
if(Array.isArray(data.stages)) STAGES=data.stages; if(Array.isArray(data.stages)) STAGES=data.stages;
if(Array.isArray(data.timeline)) TIMELINE=data.timeline; if(Array.isArray(data.timeline)) TIMELINE=data.timeline;
if(typeof data.ci_duration_s==='number') CI_DUR=data.ci_duration_s;
if(typeof data.cd_duration_s==='number') CD_DUR=data.cd_duration_s;
if(data.version) document.getElementById('ver').textContent=data.version; if(data.version) document.getElementById('ver').textContent=data.version;
}catch(e){ }catch(e){
console.error('atlas: failed to load /api/atlas.json —',e); console.error('atlas: failed to load /api/atlas.json —',e);
+108
View File
@@ -0,0 +1,108 @@
// Package oathcandidate supplies cad-atlas's own real var-go candidate (cad-atlas#8):
// steps that gate its own CI-workflow oath by actually parsing the committed
// .gitea/workflows/cd.yml, not a stub that hardcodes an unrelated toy vocabulary.
// var-go injects and owns the gate across the subprocess boundary (SubprocessGate,
// ADR-0003), so this package supplies only the prose->behaviour binding and never a
// verdict — it cannot self-certify.
package oathcandidate
import (
"os"
"path/filepath"
"strings"
oath "git.d-ma.be/mathias/swedsl/oath"
"gopkg.in/yaml.v3"
)
// workflowState is the candidate's domain: the job names and concatenated step-run
// scripts parsed out of one Gitea Actions workflow file.
type workflowState struct {
jobNames map[string]bool
jobRuns map[string]string // job name -> every step's `run:` script, concatenated
}
type workflowFile struct {
Jobs map[string]struct {
Steps []struct {
Run string `yaml:"run"`
} `yaml:"steps"`
} `yaml:"jobs"`
}
// Build returns cad-atlas's candidate registry. cmd/vargo-gate runs the generated
// harness with cwd = this module's own directory (SubprocessGate's
// cmd.Dir = candidateModuleDir contract) — one level under the cad-atlas repo root
// in cad-atlas's real layout — so a workflow path in the oath text like
// ".gitea/workflows/cd.yml" is read relative to "..".
func Build() *oath.Registry[workflowState] {
reg := oath.NewRegistry[workflowState]()
if err := reg.Stimulus(`the CI workflow file {string} is parsed`,
func(_ workflowState, path string) workflowState {
return parseWorkflow(path)
}); err != nil {
panic(err)
}
if err := reg.Sensor(`it defines a job named {string}`,
func(s workflowState, name string) string {
if s.jobNames[name] {
return name
}
return "<no such job>"
}); err != nil {
panic(err)
}
// Checks what the workflow file can actually attest to: the job's run script
// invokes the gate binary. The "var-go/oath" commit-status context string
// itself lives in vargo-gate's Go code, not the YAML — not something this
// file-level check can see, so it isn't what's asserted here.
if err := reg.Sensor(`the job named {string} invokes {string}`,
func(s workflowState, job, cmd string) (string, string) {
run, ok := s.jobRuns[job]
foundJob := "<no such job>"
if ok {
foundJob = job
}
foundCmd := cmd
if !ok || !strings.Contains(run, cmd) {
foundCmd = "<not invoked>"
}
return foundJob, foundCmd
}); err != nil {
panic(err)
}
return reg
}
// parseWorkflow reads and parses a Gitea Actions workflow file relative to the
// repo root (see Build's doc comment for the cwd contract). A read or parse
// failure returns an empty state — every sensor then observes "not found",
// which fails the gate closed rather than silently skipping the check.
func parseWorkflow(repoRelativePath string) workflowState {
state := workflowState{jobNames: map[string]bool{}, jobRuns: map[string]string{}}
data, err := os.ReadFile(filepath.Join("..", repoRelativePath))
if err != nil {
return state
}
var wf workflowFile
if err := yaml.Unmarshal(data, &wf); err != nil {
return state
}
for name, job := range wf.Jobs {
state.jobNames[name] = true
var runs strings.Builder
for _, step := range job.Steps {
runs.WriteString(step.Run)
runs.WriteString("\n")
}
state.jobRuns[name] = runs.String()
}
return state
}
+79
View File
@@ -0,0 +1,79 @@
package oathcandidate
import (
"os"
"path/filepath"
"testing"
oath "git.d-ma.be/mathias/swedsl/oath"
)
// realOath is cad-atlas#8's actual oath text — the same var block committed to
// that issue. Gating it against the REAL checked-out .gitea/workflows/cd.yml
// proves the candidate reads real CI config, not a fixture standing in for it.
//
// Format note (discovered writing this test): var-go's parser requires a
// SINGLE-LINE paragraph — sentences are split by "." within that line, not by
// newline — and does NOT strip Given/When/Then/And keywords before matching a
// step. cad-atlas's older oaths (e.g. issue #1) use a multi-line, keyword-prefixed
// style that was never actually exercised against this parser (every prior gate
// run errored before reaching real sentence matching). Plain declarative
// sentences, period-separated, one line — see swedsl's own gate_test.go fixtures.
const realOath = "```var\n" +
`the CI workflow file ".gitea/workflows/cd.yml" is parsed. it defines a job named "oath". the job named "oath" invokes "cmd/vargo-gate".` +
"\n```\n"
// TestBuild_GatesRealWorkflow is named before Build existed (TDD): it fails to
// compile until Build() and the workflow-parsing steps exist, and fails to pass
// until they parse the REAL committed cd.yml correctly — this is the file that
// must go from red to green, not a mock.
func TestBuild_GatesRealWorkflow(t *testing.T) {
// go test's cwd is already this package's dir (oathcandidate/), matching
// SubprocessGate's cmd.Dir = candidateModuleDir contract exactly — no chdir
// needed to reproduce it here.
verdict, err := oath.Gate([]byte(realOath), Build())
if err != nil {
t.Fatalf("Gate returned error: %v", err)
}
if !verdict.Pass {
if verdict.Failure != nil {
t.Fatalf("Gate did not pass: failure=%+v", *verdict.Failure)
}
t.Fatalf("Gate did not pass against the real committed cd.yml: %+v", verdict)
}
}
// TestBuild_FailsClosedOnMissingJob proves the candidate is a REAL check, not a
// rubber stamp: gating a workflow file that has no "oath" job must fail.
func TestBuild_FailsClosedOnMissingJob(t *testing.T) {
dir := t.TempDir()
workflowsDir := filepath.Join(dir, ".gitea", "workflows")
if err := os.MkdirAll(workflowsDir, 0o755); err != nil {
t.Fatal(err)
}
noOathJob := "jobs:\n check:\n steps:\n - run: go test ./...\n"
if err := os.WriteFile(filepath.Join(workflowsDir, "cd.yml"), []byte(noOathJob), 0o644); err != nil {
t.Fatal(err)
}
cwd, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
// SubprocessGate always runs the candidate with cmd.Dir = candidateModuleDir,
// one level under the repo root (cad-atlas's real layout) — reproduce that by
// chdir-ing into a sibling "candidate/" dir under the fixture root.
candDir := filepath.Join(dir, "candidate")
if err := os.MkdirAll(candDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.Chdir(candDir); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chdir(cwd) })
verdict, err := oath.Gate([]byte(realOath), Build())
if err == nil && verdict.Pass {
t.Fatalf("expected the gate to fail closed on a workflow with no oath job, got Pass=true")
}
}
+18
View File
@@ -0,0 +1,18 @@
// Package oathcandidate is cad-atlas's committed real candidate: the STEPS that
// gate its own CI-workflow oath (cad-atlas#8). Deliberately a separate module (not
// part of the main cad-atlas module) so var-go's transitive deps (cucumber-expressions,
// goldmark) never link into the deployed atlas binary mirrors swedsl's own
// oath/testdata/selfcandidate pattern.
module oathcandidate
go 1.26.4
require (
git.d-ma.be/mathias/swedsl/oath v0.28.0
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/cucumber/cucumber-expressions/go/v18 v18.1.0 // indirect
github.com/yuin/goldmark v1.8.2 // indirect
)
+16
View File
@@ -0,0 +1,16 @@
git.d-ma.be/mathias/swedsl/oath v0.28.0 h1:q4WXlGtMlDymhmuw9Pdc025OTLs1wl8THsrz/raeMxs=
git.d-ma.be/mathias/swedsl/oath v0.28.0/go.mod h1:kEOX7Wubf3g/HTKzuoHD4fm6zNSl55cSV/qgy+ezMoI=
github.com/cucumber/cucumber-expressions/go/v18 v18.1.0 h1:zvZFnbmtQxwHq6ru5gHxpfBloLq9wmjoKbdwOzt/XNA=
github.com/cucumber/cucumber-expressions/go/v18 v18.1.0/go.mod h1:+Qe2kvmilsdGRFJ+zlkjXp84rPEf6O/idcoOsvnIORY=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=