Design thinking, 2026-08-28. Nothing built. The question is whether the core of tree-shaking can be a chapter that never says "zig", and whether that chapter can be tested before ZigEmitter is touched at all.
Three inputs, and only one of them is interesting.
cx_foo(. For anything else they come
from somewhere else. The chapter should not care.{name, needs, text}. The prelude, cut at a
seam that already exists: every & "..." chunk in zig-prelude is exactly
one top-level declaration or one comment block. 122 chunks, 93 named decls.cx_ll_of
calls cx_ll_empty, which calls cx_gpa.create(CxList(T)). Three levels
from one root, and nothing in the emitted program mentions CxList
directly.The third is the whole algorithm and it is not novel. It is reachability in a directed graph, plus "keep the input order".
shake : List Part, List Text -> ShakeResult
Part = record { name : Text, needs : List Text, text : Text }
ShakeResult = record { kept : List Part, unmatched : List Text }
That signature mentions no target, no syntax, no file format. It is the entire public surface.
In the chapter: the closure, the ordering rule, cycle handling, and the honest reporting of roots that matched nothing.
Stays in ZigEmitter: extracting roots from emitted text (name & "(" for
functions, bare name for vars — cx_print is a prefix of cx_print_line,
cx_print( is not), and concatenating kept back into a string.
Generated, never typed: the parts table itself. Hand-editing 122 string
literals byte-exactly is where a week would go. A ~40-line script reads
today's zig-prelude and writes the table.
The chapter is maybe sixty lines. Everything expensive is on the other side of its interface, which is the point.
Steve's framing — indirect calls are opaque, embedded in the text — is exactly
the question of who computes needs. Four answers:
(a) Scan at shake time, inside the chapter. Cheapest to write, and wrong: it makes the chapter text-aware and target-aware, which is the thing we are trying to avoid.
(b) Declare needs by hand in the table. Clean chapter, silent drift. Edit
a prelude chunk to call something new, forget the edge, and a live declaration
is dropped — discovered at zig build time, for whichever programs happen to
exercise it. This is the one failure mode that must never happen, so a
mechanism whose failure mode IS that mechanism is disqualified.
(c) Generate the edges mechanically into the table, and regenerate to verify. Precise, and drift becomes a diff rather than a silent drop.
(d) The caller supplies the edges; the chapter takes them as data.
(d) is the answer, and it subsumes (c). The chapter's contract is graph-only. ZigEmitter can start by scanning the parts' own text at shake time — always correct by construction, no table to drift — and later move to a generated table for speed, with the chapter unchanged and untouched. The decision about where edges come from stops being an architectural commitment and becomes an implementation detail on one side of a line.
1. Never drop a reachable part. Keeping too much costs bytes. Dropping too little costs a broken build, late, for a subset of programs. Every ambiguous call — is this a reference or a word in a comment? — resolves toward keeping. The failure mode is chosen, not discovered.
2. Determinism. Same inputs, same output bytes, always. The corpus byte-identity oracle is the safety net for the whole project; a shaker that reorders under set iteration would poison it.
3. Preserve the parts table's order. Not topological. Zig does not need declaration order — container-level declarations are order-independent, which is exactly what PR 95 proved by moving the entire prelude below the program. Topological sort would be gratuitous churn and would break byte-identity against today's output for no gain.
That third one buys something free and large:
Shaking with every part name as a root must produce output byte-identical to the unshaken prelude.
That is the identity gate, it needs no zig build to check, and it can be run
over the real 122-chunk prelude before any filtering is trusted. If it fails,
the parts table is wrong — a dropped \n, a mis-split chunk — and you learn
that separately from whether the closure is right. Those two bugs have the
same symptom and this separates them.
A comment block belongs to the declaration below it. The prelude's
comments are load-bearing prose — "The capacity is load-bearing, not a hint",
"Mirrors bare metal's __text_to_double, not a correctly-rounded parse". If a
part is only the decl, shaking orphans them into a header of explanations for
code that is no longer there. So a part is (leading comments + one decl),
kept or dropped together.
Unmatched roots are reported, not swallowed. A root naming no part means either the program references something the prelude does not provide (a real bug) or the extractor over-matched (a name inside a string literal, expected and harmless). The chapter cannot tell these apart and should not guess. It returns them and lets the caller rule.
zig-prelude-decls must NOT be narrowed to the kept set. It is the
shadowing-reservation list, and it stays the union over the whole prelude. The
cost of over-reserving is one spurious underscore; the cost of narrowing is
that the same Codex source emits a different spelling depending on which
builtins it happened to touch. This is already written into the emitter prose
and it is the trap shaking sets for its own author.
The algorithm can be tested to exhaustion before ZigEmitter is opened.
Because the chapter is graph-only, its fixtures are graphs. No zig, no QEMU, no plug build, no corpus:
linear A->B->C, root A all three, in table order
diamond A->{B,C}, B->D, C->D four parts, D once
cycle A->B->A, root A both, terminates
island A->B, plus unreached C,D two
no roots any table, roots [] empty, and no crash
unknown root "nope" kept unchanged, unmatched ["nope"]
identity roots = every part name every part, original order
Seven fixtures, each a handful of lines, each with an exact expected answer. That is a tier-style probe or a one-off script, seconds to run, and it settles the algorithm completely. Only then does the expensive half start, and when the emitted zig is wrong afterwards you already know the closure is not why.
The doctrine says an abstraction earns its keep on the second user, and the honest thing is that the second user is not hypothetical — it predates the first.
cite_resolve.py in the ladder resolves a chapter's cites transitively:
_walk carries a seen set, accumulates parts in order, and returns a
missing list beside them. That is this algorithm, on a different graph,
already written, and it independently arrived at the same two-output shape
— what you resolved, and what you could not. A design that converges with
working code written for another purpose is more likely to be the right shape
than one argued from first principles.
Other consumers that would fit without modification: any emitter with a fixed runtime library (the C# plug's helper set, a future C or wasm plug), and dead-definition elimination over IR, which is the same closure over a different node type.
The parts table is the fragile artifact, not the algorithm. 122 chunks generated by a script that has to get every newline right. The identity gate above is the whole defence, and it should run first and always.
A false root keeps too much and nobody notices. That is by design, but it means the measured 60% reduction could quietly become 20% without failing anything. Worth reporting the ratio, not just the correctness.
The payoff is still not compile time. Measured: zig already dead-strips this — same binary size to the byte, no compile-time signal. What it buys is legibility (a defect in an 800-byte program instead of a 38 KB one) and un-blinding the corpus byte-identity oracle, which today goes dark on every prelude edit because all 589 emitted files move at once. Do not re-argue this one; it has been measured.
The next concrete step is the seven fixtures, not the emitter.