Zone-layered architecture. Incremental migration with zero regressions. Decision: D109. Analysis:
private/refactoring-analysis-2026-02-24.md.
Transform CW's flat module structure into a strict 4-zone layered architecture:
Layer 0: runtime/ — foundational types (no external deps)
Layer 1: engine/ — processing pipeline (depends only on runtime/)
Layer 2: lang/ — Clojure language impl (depends on runtime/ + engine/)
Layer 3: app/ — application entry points (depends on all)
Dependency rule: lower layers NEVER import from higher layers.
Measured by scripts/zone_check.sh. Total: 134 violations.
runtime(L0) -> engine(L1): 8 imports (bootstrap from value/lifecycle/stm/thread_pool/macro/ns_loader; gc→compiler)
runtime(L0) -> lang(L2): 10 imports (macro→builtins/interop; ns_loader→builtins; collections/value→builtins; lifecycle→builtins)
runtime(L0) -> app(L3): 6 imports (value→wasm/types x6)
engine(L1) -> lang(L2): 108 imports (bootstrap→builtins x7; eval_engine→builtins x~60; tree_walk→builtins x13; vm→builtins x5; analyzer→interop x2)
lang(L2) -> app(L3): 2 imports (cljw_wasm→wasm/)
Root cause: bootstrap.zig (3,624 LOC) in runtime/ orchestrates all modules.
eval_engine.zig test functions import builtins/registry.zig ~60 times.
src/
runtime/ ← Layer 0 (self-contained, no upward deps)
value.zig collections.zig env.zig
namespace.zig var.zig gc.zig
error.zig keyword_intern.zig codepoint.zig
lifecycle.zig stm.zig thread_pool.zig
dispatch.zig (NEW: callFnVal vtable)
macro.zig concurrency_test.zig
engine/ ← Layer 1 (depends on runtime/ only)
reader/ (reader.zig, tokenizer.zig, form.zig)
analyzer/ (analyzer.zig, macro_transforms.zig, node.zig)
compiler/ (compiler.zig, chunk.zig, serialize.zig, opcodes.zig)
evaluator/ (tree_walk.zig)
vm/ (vm.zig, jit.zig)
pipeline.zig (evalString — read→analyze→eval orchestration)
eval_engine.zig (dual backend compare mode)
cache.zig (bootstrap cache restore/recompile)
bootstrap.zig (thin init: registerBuiltins + loadCore calls)
embedded_sources.zig (Clojure multiline strings for VM compilation)
lang/ ← Layer 2 (depends on runtime/ + engine/)
builtins/ (arithmetic, collections, sequences, strings, ...)
core/ (defs.zig — clojure.core var registration)
interop/ (rewrites, dispatch, constructors, classes/)
lib/ (clojure_string, clojure_test, clojure_pprint, ...)
registry.zig (builtin function registry)
loader.zig (namespace lazy loading)
app/ ← Layer 3 (depends on all)
main.zig (thin entry point — parse args, dispatch)
cli.zig (CLI argument parsing, help text)
runner.zig (file execution, -e evaluation)
test_runner.zig (cljw test command)
deps.zig (deps.edn resolution)
repl/ (nrepl.zig, line_editor.zig, bencode.zig)
wasm/ (types.zig, builtins.zig, wit_parser.zig)
root.zig (public API — unchanged)
cache_gen.zig (build-time cache generator)
Create scripts/zone_check.sh that counts cross-zone violations.
Record current baseline. Add to commit gate (informational, not blocking yet).
Files: scripts/zone_check.sh (new)
Gate: Script runs, baseline recorded in this plan
Extract callFnVal from bootstrap.zig into runtime/dispatch.zig using
vtable pattern. This breaks the core circular dependency.
Before (bootstrap.zig imports TreeWalk + VM):
pub fn callFnVal(alloc, fn_val, args) !Value {
switch (fn_val.kind) {
.treewalk => TreeWalk.callBridge(...), // imports evaluator/
.bytecode => VM.callBridge(...), // imports vm/
}
}After (dispatch.zig uses function pointers):
// runtime/dispatch.zig — Layer 0, no upward imports
pub const FnDispatch = struct {
treewalkCall: *const fn(...) anyerror!Value,
bytecodeCall: *const fn(...) anyerror!Value,
};
pub var table: FnDispatch = undefined;
pub fn callFnVal(alloc, fn_val, args) !Value {
switch (fn_val.kind) {
.treewalk => table.treewalkCall(...),
.bytecode => table.bytecodeCall(...),
}
}// Initialization (in engine/ or app/ init):
dispatch.table = .{
.treewalkCall = TreeWalk.callBridge,
.bytecodeCall = VM.callBridge,
};Files: src/runtime/dispatch.zig (new), src/runtime/bootstrap.zig (extract),
all callers of bootstrap.callFnVal → dispatch.callFnVal
Gate: All tests pass. bench/run_bench.sh --quick — no regression.
Risk: High. Hot path. Benchmark carefully.
Move evalString*, readForms*, readFormsWithNs from bootstrap.zig
to a new file (initially src/runtime/pipeline.zig, moves to engine/ in R8).
Files: src/runtime/pipeline.zig (new), src/runtime/bootstrap.zig (shrink)
Gate: All tests pass.
Move registerBuiltins and related registration code from bootstrap.zig
to src/builtins/registry.zig (already exists — merge into it).
Files: src/builtins/registry.zig (extend), src/runtime/bootstrap.zig (shrink)
Gate: All tests pass.
Move loadCore, loadTest, loadRepl, loadPprint, loadReducers,
loadEmbeddedLib from bootstrap.zig to a new loader file.
Files: src/builtins/loader.zig (new), src/runtime/bootstrap.zig (shrink)
Gate: All tests pass.
Move restoreFromBootstrapCache, vmRecompileAll, cache-related functions
from bootstrap.zig to a cache module.
Files: src/runtime/cache.zig (new), src/runtime/bootstrap.zig (shrink)
Gate: All tests pass.
After R1-R5, bootstrap.zig should contain only:
bootstrapInit()— top-level init that calls other modulessyncNsVar()— may move to namespace.zig- Small helpers
Target: < 200 LOC (down from 3,624).
Gate: All tests pass. bootstrap.zig < 200 LOC.
value.zig:726 imports builtins/collections.zig. Extract the needed
function to runtime/collections.zig or use a callback pattern.
Files: src/runtime/value.zig, possibly src/runtime/collections.zig
Gate: All tests pass.
Rename directories to final zone structure. This is a single large commit
with ONLY @import path changes — zero logic changes.
Moves:
src/reader/ → src/engine/reader/
src/analyzer/ → src/engine/analyzer/
src/compiler/ → src/engine/compiler/
src/evaluator/ → src/engine/evaluator/
src/vm/ → src/engine/vm/
src/runtime/pipeline.zig → src/engine/pipeline.zig
src/runtime/eval_engine.zig → src/engine/eval_engine.zig
src/runtime/cache.zig → src/engine/cache.zig
src/runtime/bootstrap.zig → src/engine/bootstrap.zig
src/runtime/embedded_sources.zig → src/engine/embedded_sources.zig
src/builtins/ → src/lang/builtins/
src/interop/ → src/lang/interop/
src/builtins/lib/ → src/lang/lib/
src/builtins/registry.zig → src/lang/registry.zig
src/builtins/loader.zig → src/lang/loader.zig
src/main.zig → src/app/main.zig
src/deps.zig → src/app/deps.zig
src/repl/ → src/app/repl/
src/wasm/ → src/app/wasm/
Gate: zig build test compiles and passes. All e2e pass.
No logic changes in this commit — import paths only.
Note: build.zig root source path may need updating.
After R8, split src/app/main.zig (2,343 LOC) into:
main.zig— entry point, arg dispatch (< 200 LOC)cli.zig— argument parsing, help textrunner.zig— file execution, -e evaluationtest_runner.zig—cljw testcommand
Gate: All tests pass.
Make zone check a hard block in commit gate.
Update CLAUDE.md commit gate checklist.
Create .claude/rules/zone-deps.md for auto-load enforcement.
Files: scripts/zone_check.sh (update to fail on violations),
.claude/CLAUDE.md, .claude/rules/zone-deps.md
Gate: scripts/zone_check.sh exits 0 (zero violations).
- Semantic aliasing scan: Check all builtin registrations for name/semantics mismatches (e.g., sorted-set aliased to hash-set)
- Evaluator special-case scan: Check tree_walk.zig and vm.zig for library-specific symbol checks (should be in analyzer/macros, not evaluator)
- Fix any issues found.
Gate: Audit report committed. Issues fixed.
Fix issues that naturally arise during refactoring or are now easy to fix:
| Issue | Fix | Related Sub-task |
|---|---|---|
| I-011 | finally exception propagation | Standalone |
| I-012 | Watch/validator error propagation | Standalone |
| I-013 | Bootstrap refer catch {} → log warning | R6 (bootstrap slim) |
| I-023 | @intCast → @bitCast for pointers | R7 or standalone |
| I-024 | Wasm bridge range check | Standalone |
Gate: Each fix = separate commit. All tests pass.
| Metric | Before | After |
|---|---|---|
| Total zone violations | 134 | 0 |
| bootstrap.zig LOC | 3,624 | < 200 |
| main.zig LOC | 2,343 | < 200 |
| Zone check | N/A | CI hard block, 0 violations |
Silent catch {} |
5+ | 0 |
| Tests | 68/68 upstream, 6/6 e2e, 14/14 deps e2e | Same (no regression) |
| Benchmarks | baseline | Within 1.2x (no regression) |
| Binary size | 4.52MB | ≤ 4.8MB |
| Startup | 4.2ms | ≤ 6.0ms |
- One sub-task = one commit. Never combine R1+R2 in a single commit.
- Structure changes only. No logic changes in refactoring commits. If a bug is found, fix it in a SEPARATE commit before/after the move.
- Full test suite every commit.
bash test/run_all.shmust pass. - Benchmark on R1, R8. These are the highest-risk steps.
bash bench/run_bench.sh --quickfor quick check. - Import paths = the only code change in R8. Verify with
git diff --statthat only@importstrings changed.