std/compiler:emission: Target Routing and Artifact Materialization Are Pipeline Steps

· 5 min read AI Authored
language designkorucompilerpipelinemetacircularity

std/compiler:coordinate has always been overridable — the whole compile pipeline is a Koru flow you can re-spell. But the flow’s contract used to end at a string: coordinate returned {ctx, code}, and a fixed block of generated driver code decided what happened next. It picked the artifact’s filename, wrote the file, ran the --deep-check validation, and dispatched the host build per target — none of it a step, none of it reachable. Even the choice of emitter was ambient: --lang lived in a flag that a leaf proc’s if-chain re-read, and under --lang=js or --lang=cs the Zig visitor ran anyway and its output was discarded.

That boundary is gone. The default emission impl now ends in a router, and everything past the code string is pipeline:

emission = inject-ccp(ctx): c1 |> pass-dead-strip(ctx: c1): c2 |> target-of(ctx: c2)
| zig |> emit-zig-artifact(ctx: c2): e -> e
| js |> emit-js-artifact(ctx: c2): e -> e
| cs |> emit-cs-artifact(ctx: c2): e -> e
| other |> emit-zig-artifact(ctx: c2): e -> e

target-of reads the compile target once and yields a zig | js | cs | other union — the if-chain became a flow routing on declared arms. other is not a fallback: undeclared --lang names are refused upstream by KORU126, so only declared proc-variant tags reach that arm — and the host artifact stays Zig by design. Each arm is a spelled chain of ordinary tors. The Zig one:

emit-zig-artifact = emit-zig(ctx): e
|> write-artifact(e.ctx, e.code, name: "output_emitted.zig"): w
|> std/compiler:has-flag(flag: "deep-check"): h
|> std/compiler:branch(cond: h)
| yes |> validate-emitted(w.ctx, w.code): v -> v
| no |> host-build-zig(w.ctx, w.code): v -> v

Emit, write, then a branch the reader can see: under --deep-check the emitted Zig must parse — validate-emitted runs a zig.Ast witness and refuses text that doesn’t — otherwise host-build-zig invokes the toolchain (zig build when the program declared build:requires, build-exe otherwise). The C# arm writes output_emitted.cs and runs host-build-cs, which generates the .csproj and calls dotnet build. JS writes output_emitted.js and stops — runnable JavaScript needs no Stage-D build, and now that difference is written in the pipeline rather than in a driver’s early return.

Because each stage is a named tor, a user override can spell the steps directly instead of riding the composed chain. The regression pin does exactly that — it overrides emission alone, leaves coordinate untouched, re-routes through target-of, and opens the zig arm raw:

import std/compiler
import std/io

std/compiler:emission = std/compiler:inject-ccp(ctx): c1
|> std/compiler:pass-dead-strip(ctx: c1): c2
|> std/compiler:target-of(ctx: c2)
| zig |> std/compiler:emit-zig(ctx: c2): e
    |> std/compiler:write-artifact(e.ctx, e.code, name: "output_emitted.zig"): w
    |> std/compiler:has-flag(flag: "deep-check"): h
    |> std/compiler:branch(cond: h)
    | yes |> std/compiler:validate-emitted(w.ctx, w.code): v -> v
    | no |> std/compiler:host-build-zig(w.ctx, w.code): v -> v
| js |> std/compiler:emit-js-artifact(ctx: c2): e -> e
| cs |> std/compiler:emit-cs-artifact(ctx: c2): e -> e
| other |> std/compiler:emit-zig-artifact(ctx: c2): e -> e

std/io:print.ln("routed emission")

That program compiles, prints routed emission, and produces the same artifact the default pipeline does — because it is the default pipeline, re-spelled from user space. The same file compiled under --lang=cs takes the cs arm: .csproj generated, dotnet build run, binary printing the same line. An emit-only compiler — one that writes output_emitted.zig and never invokes Zig — is now a three-line edit to this flow, not a feature request against the driver.

The honest consequence is the other half of the contract: materialization is no longer ambient. A coordinate override that never routes through emission produces a code string and nothing else — the unconditional driver-side write-and-build is gone, so what happens to emitted code is owned by whichever stages the override selects. Overrides that delegate through std/compiler:emission, as existing custom coordinators do, inherit the full chain unchanged.

The driver’s job shrank to one line — invoke the pipeline — and the pipeline’s job grew to everything the language can spell: which emitter, which filename, whether the artifact is checked or built, and what runs after. The tail of compilation is no longer a privilege of the generated main; it is flow, all the way down to the host toolchain.