Diagnostic Bus: A Compiler Plugin Is an Import
Every compiler reports problems the way compilers have always reported them: formatted text on a stream. The industry made that text structured — rustc’s --error-format=json, GCC’s diagnostics-format, SARIF, LSP’s push API — but the medium stayed a stream, because a stream is the one channel that survives the crash that produced the diagnostics. A SQLite file of your compile’s refusals never shipped anywhere, because a commit boundary can’t outlive a segfault.
Koru’s answer keeps the stream and changes who it belongs to. The backend is itself Koru-compiled, and its diagnostics now cross a Koru-visible barrier: a diagnostic is an event, subscribing to it is a join, and a sink is a module file. The stderr renderer doesn’t go away — it stops being the channel and becomes a consumer.
The spelling
A consumer is a std/compiler(diagnostic) join with ! arms, one arm per sink. This is the entire jsonl sink module — the file is the subscription:
import std/compiler
std/compiler(diagnostic)
! diagnostic d |> jsonl(d, path: "koru-diagnostics.jsonl") A program that wants its diagnostics as JSON lines says one word:
import std/diag/jsonl
totally-bogus() The compile still prints the refusal on stderr — error[KORU040]: unknown tor 'probe:totally-bogus' — and the same ParseError lands on the disk as a row:
{"code":"KORU040","file":"probe2.k","line":3,"column":0,"message":"unknown tor 'probe2:totally-bogus'"} Same diagnostic, two consumers, neither written knowing about the other.
What the bus guarantees
Consumers are statically enumerable. The arm set is collected by a full AST traversal before the backend runs — the same collection pass std/channel joins already use. The subscriber list is a compile-time fact, not a runtime registration table: there is nothing to forget to call, no init order, no plugin manifest.
It’s broadcast, not competing. A channel delivers one value to one unit; the bus delivers each diagnostic to every armed sink. Two arms, two files, identical rows — verified in the same probe session.
stderr is a consumer, not the channel. The bus adds to the diagnostic surface; it never replaces the human renderer. A compile with no joins behaves exactly as before — the sink registry stays empty and dispatch costs one bounds-check per diagnostic.
Dispatch is synchronous and cannot recurse. A sink that fails reports to stderr directly — a sink failure is never itself a diagnostic on the bus, which is the only honest answer to “what if the sqlite write fails.”
The barrier is backend-scoped, and joins erase. The collector runs after program.ast.json deserializes and before coordinate resolution — join flows are stripped, so the normal pipeline never sees them. Parse-time refusals stay in the frontend, deliberately: the barrier sits where the metacircular pipeline lives, and the earliest phase keeps its plain stream.
The plugin is a package
Here is where it stops being a stdlib feature and starts being a mechanism. The SQLite sink doesn’t live in koru_std — it lives in koru-libs, the ordinary package repo, because a stdlib should carry zero external dependencies and this sink carries one. And it doesn’t squat under a diag/ mirror of the stdlib namespace: it lives under koru/compiler, the external compiler toolkit — std/compiler is the pipeline itself, koru/compiler is the shelf of things that plug into it. The path narrates the mechanism. This is the entire plugin:
// koru/compiler/sqlite — diagnostics-bus consumer in the external compiler
// toolkit: `import koru/compiler/sqlite` subscribes every backend diagnostic
// as a row in a SQLite db. The whole module is the join plus its link dep —
// `std/compiler:requires` targets the BACKEND build (build_backend.zig), the
// same requires door koru-libs packages use for user binaries via
// std/build:requires. No registration, no flags: the import IS the plugin.
//
// Sink settings are the consumer's `koru/compiler/sqlite:config` block —
// `db-file: "name.db"` (default koru-diagnostics.db) and `flush: true|false`
// (the fsync discipline: true = synchronous FULL per row, false = OFF —
// inserts still commit per row, only OS-level failure loses them).
import std/compiler
std/compiler:requires {
exe.linkLibC();
exe.linkSystemLibrary("sqlite3");
}
std/compiler(diagnostic)
! diagnostic d |> sqlite(d) And the entire user program — subscription on the import line, configuration on the module’s own namespace:
import koru/compiler/sqlite
koru/compiler/sqlite:config {
db-file: "custom-diag.db"
flush: true
}
totally-bogus() The std/compiler:requires block is the load-bearing line: it’s the same requires door every koru-libs package uses to declare what the output binary links (std/build:requires), retargeted at the backend — the compiler’s own build. The module carries its link dependency into the generated build_backend.zig, which gains, verbatim:
exe.linkLibC();
exe.linkSystemLibrary("sqlite3"); The frontend scans the program for arms that name sqlite, emits the sink’s externs only then — a backend that always linked sqlite3 would be a hard dependency wearing an opt-in costume — and synthesizes the requirement itself if a bare-code arm spelled it without the import. The arm is the intent; the dep follows from it.
Configuration is a directive, not a flag
koru/compiler/sqlite:config is a module-targeted directive block — the same grammar family as std/compiler:requires, compiler:use, and x:new. The braces carry the plugin’s own little grammar: key: value entries, one per line, commas allowed. db-file names the database, flush is the fsync discipline — true (the default) commits synchronously per row; false flips PRAGMA synchronous=OFF, which still commits every insert but only guarantees it against process death, not power loss. That second option exists because diagnostics live on the failure paths: a config that traded crash-survival for speed would betray the whole reason the bus exists, so batched transactions are deliberately not what flush means.
Two properties fall out of making it a directive rather than arm arguments. The config is statically enumerable — the backend’s diagnostic pass claims X:config flows in the same walk that collects joins and erases them before coordinate, so settings are AST data, not runtime side channels. And it’s claimed or refused, never silent — X:config on a module that doesn’t carry a diagnostic join stays in the tree and coordinate rejects it as KORU040. Configuration for a plugin that isn’t there is a compile error, not a mystery.
Then the receipt, queried out of the configured db:
KORU040|probe.k|8|0|unknown tor 'probe:totally-bogus'| No registration, no koru.json, no flag, no dlopen. import koru/compiler/sqlite resolves through the koru alias the same way import koru/sqlite3 resolves the runtime binding — a compiler plugin is a package like any other, and the plugin mechanism is just the import mechanism.
The honest edges
- Stage0 resolves arm names against builtins.
jsonlandsqliteare Zig-side implementations the emitted backend knows by name — the Koru spelling is the contract, the builtin is the implementation. Arbitrary user-authored sink bodies need the interpreter to run them mid-compile; that’s a later conversation, and the spelling doesn’t change when it happens. - Backend-scoped means backend-scoped. A
PARSE003never crosses the barrier — the frontend keeps its plain stream, because a diagnostic about parsing the join can’t subscribe a sink declared by it. - The bus sees what the reporter produces. Coordinate aborts on the first refusal, so a pathological program yields the rows the reporter reached — the bus faithfully multicasts; it doesn’t conjure.
- Minted backends don’t participate. A
compiler:usemint is a prebuilt binary; its requirements were collected when it was minted. Arms there are silently ignored — a real gap, not a footgun we intend to keep.
Where the claims are pinned
The interesting question was never where diagnostics go — it was who they’re addressed to. Text on stderr is addressed to a human; a row is addressed to anything that can query. Once diagnostics are events inside the language’s own compile, the consumer set is whatever the program imports — and “add a compiler plugin” and “import a package” turn out to be the same sentence.