koru/regressions: The Test Suite Is a Koru Program
The suite is a program
Every compiler’s verification suite runs on a harness written outside the language it tests. Koru’s own regression board is a half-hour bash script that knows about markers, snapshots, leak gates, and a four-stage compiler taxonomy. The harness is the authority; the language under test never gets to say anything about how it is verified.
koru-libs just inverted that. Its test suite is now a Koru program, and koruc suite.k test is a real gate — exit 0 only when everything is green, exit 1 the moment anything regresses.
A case is a label and a file
The whole suite is composition. Add a test: drop a file. Add a package: one import line.
import koru/regressions
koru/regressions:case(gzip round-trip) {
"file": "gzip/tests/roundtrip.kz"
}
koru/regressions:case(gzip use-after-finish is rejected) {
"file": "gzip/tests/deflate_use_after_finish.k",
"category": ["c-libs", "smoke"],
"tags": ["phantom-obligations"]
} Those two cases are quoted from suite.k in the koru-libs repo; the whole file is that shape — a label and a file. Thirteen cases across two packages run this way, ten of them koru/signal, and the runner collects them from the AST at compile time, drives each through a koruc subprocess, and scores it. No toolchain changes — pure external orchestration, in the language.
Expectations are declared, not annotated
The runner’s one rule: a test is self-describing. The expectation lives in the test program itself — not in a comment the runner must parse, but as calls the compiler already parsed, resolved, and typed. They are norun markers, the same mechanism case uses, and the runner finds them by walking the test’s own AST. A typo is a compile error with a location; a wrong argument label is a frontend diagnostic; an expectation that doesn’t exist cannot be written.
| Marker | Means |
|---|---|
koru/regressions:run() | compiles, runs, exits 0, no leaks |
koru/regressions:compile-fail(stage: backend) | must fail, pinned to the stage |
koru/regressions:error(id: "KORU030", msg: "…") | the failure must carry this diagnostic |
koru/regressions:expect(text: "…") / expect-not(text: "…") | produced-program output contains / lacks a substring |
koru/regressions:leaks-allow(reason: "…") | reasoned opt-out of the leak gate |
The crown jewel is the pinned negative. The resource-safety pillar of the language — phantom obligations that track whether a handle is open, fed, or done — is only as honest as its refusal cases. Here is one — the shipped deflate_use_after_finish.k, its explanatory comments elided:
import koru/regressions
koru/regressions:compile-fail(stage: backend)
koru/regressions:error(id: "KORU030", msg: "Phantom state mismatch")
import libs/gzip
import std/io
pub tor use-after-finish { }
| bytes string
| err string
use-after-finish = libs/gzip:deflate.init(level: .fast)
|> libs/gzip:deflate.push(chunk: "real chunk")
|> libs/gzip:deflate.finish
|> libs/gzip:deflate.push(chunk: "use after free")
|> libs/gzip:deflate.finish
|> libs/gzip:deflate.release
| err e => err e deflate.finish moves the Deflater from <fed!> to <done!>; pushing to it afterwards is a use-after-free, and the phantom checker rejects it at compile time. The declared expectations are the machine-checked contract: if the compiler ever stops rejecting this program — if resource safety regresses — the case goes red. An unpinned negative is itself a failure (compile-fail demands at least one error pin), a typo’d marker is KORU040: unknown tor, and the retired JSON "kind" field is rejected with teaching, never silently honored.
That chain used to carry five nested err handlers — a right-leaning pyramid of repetition. Koru’s answer to the pyramid is choking: an unhandled branch propagates to the nearest matching handler, so one dedented arm claims it at every stage of the chain — the single | err e => err e above. The arrow semantics that make it cohere: |> continues into the next call, => returns one of the event’s branches.
Choking requires the point-free shape, because propagation lives only there. A nested chain refuses every elision — | err e => err e at the outermost level dies with KORU022: branch 'err' must be handled, pinned as 220_029 in the koru suite — so the pyramid returns whenever a library’s stage shapes can’t thread. This chain used to be exactly that: finish handed back a record, release took two arguments. The collapse came from reshaping the stages, not the compiler — finish now hands the handle back as its lone survivor, and release reports bytes / err — until the pipeline threaded and the tail fell away.
Checked, not doc’d
The same discipline covers the positive path. run() is an assertion, distinct from “nobody wrote a check”. Output is asserted, not assumed:
import koru/regressions
koru/regressions:run()
koru/regressions:expect(text: "expected err:")
koru/regressions:expect-not(text: "unexpected tick success")
import koru/signal
import std/io
koru/signal:tick(Breath, pass_rate: 90.0)
| out _ |> std/io:print.ln("unexpected tick success")
| err e |> std/io:print.ln("expected err: {{ e:s }}") Ticking a model before initializing it must hit the honest error branch. The program prints expected err: koru/signal:tick — koru/signal:init(Breath) required before tick; the expect pin requires exactly that shape of output and the expect-not pin forbids the success branch from ever printing. Before these pins, this test ran, printed, and nothing asserted any of it — a pass with no claim. Now it has one. The leak gate is default-on and overrides pass on both surfaces: koruc’s own compile phase and the produced program’s allocator accounting.
Two spellings, one vocabulary
The expectations above have a sibling spelling — //~ comment directives, the inline-pin convention the Rust/Clang family converges on:
//~ run
//~ compile_fail(backend)
//~ error[KORU030]: Phantom state mismatch
//~ expect: expected err:
//~ expect-not: unexpected tick success Both spellings are live, adjacent intakes into the same vocabulary and the same scoring wall — a test uses one, never both (mixing is rejected with teaching). The comment form is the only one that can express a frontend negative: a test that must fail at the frontend — a parse error — never yields an AST, so it cannot declare its expectations as program calls. That kind is spelled with //~ compile_fail(frontend) and the runner reads it from the file text before compiling.
The two spellings differ in one way that matters: declared markers are compiler-checked. A typo is KORU040: unknown tor, a wrong label is a frontend diagnostic — the “can’t drift” guarantee lives in the type system. A //~ typo is a comment, silent until the run, and the runner must hand-parse it. Same vocabulary; the declared form is the sharper tool.
A gate that remembers
A board is a snapshot; a regression runner remembers. Each complete run writes a verdict ledger (.koru-regressions/latest.json, gitignored, written atomically), and the next run diffs against it:
──── history (vs last complete run) ────
REGRESSION gzip round-trip — was ✓, now ✗
0 new, 0 fixed, 1 regression(s) A green→red flip is named a REGRESSION, and any red board exits 1 — koruc suite.k test is an honest CI verdict, not a report. Filtered runs — --category c-libs, --tag signal-processing, --smoke — never write the ledger, so a partial board can’t clobber history, and a filter that matches nothing is refused outright rather than reported as silently green.
The first consumer caught the first drift
The runner’s first real finding was its own corpus. Ten of the thirteen cases belong to koru/signal — the named-model signal-processing package. The other three, the gzip round-trips, had drifted: they were written before string became the tor-payload spelling, and before bare returns were bound with : name instead of | tag. The language had migrated under them and nothing noticed — the files still compiled, so nobody looked. The runner flagged both stale spellings on its first run, each red naming the exact line, and the fixes were the compiler’s own suggestions. The board’s first catch was the difference between “it compiles” and “it is the program we think it is”.
The staged bet
Writing the verification harness in the language it verifies is the strongest proof a language can give that it is real tooling. The bet is staged on purpose: the instrument layer — koru-libs, whose packages exist to surface toolchain gaps — proves the runner first, where a red suite that names a compiler bug is a legitimate verdict, not a harness failure. The language’s own ground-truth suite stays on its bash board until the runner has earned the leap by demonstrated parity — and then the bash board’s accreted walls become the oracle for differential testing of the runner itself.
Why can’t the language’s own suite ride this runner today? The compiler is in flux — the surface moved several times while this runner was being built (tor-payload spellings, bare-return bindings, the diagnostic count a pinned negative expected, the AST serialization the runner walks). A verification runner must pin the compiler it scores against; a suite cannot ride a moving target. The leap becomes testable the day the compiler is minted — a fixed, reproducible koruc that the suite and the runner target, moving only in discrete, reviewable steps.
None of this is a new pattern for Koru. The production precedent already runs the live site: koruc site.k deploy bakes, embeds, pushes, and verifies every route of korulang.org. The toolchain is made for this. Now the toolchain verifies itself with it.