writeonce/docs/plan/oop-vm/02-corpus.md
shoney.arickathil 79605cbb4f feat: milestone 1 complete — .wob emitter, conformance corpus, single binary; GC redesign specced
- `woc` now emits `.wob` that `wovm` runs: emit.ml lowers the typed,
  owner-annotated AST (scope-stack registers with a >64 WO-E401 diagnostic,
  Lua-style call windows, ICALL by slot, dedup const pool, drop maps, line
  tables, implicit terminators); disasm.ml backs `--dump-bc` goldens.
- Ownership lowering consumes the four owner tables verbatim; RESIDUAL is the
  only source of borrow ops, coalesced per operand. Review caught the emitter
  consuming only 2 of owner.ml's 4 residual producers — an assignment-anchored
  aliasing violation ran to exit 0 instead of trapping; fixed, plus a backstop
  raising WO-E404 for any residual region left unconsumed.
- Conformance harness `scripts/oop-e2e.sh` (`just oop-e2e`): four fixture
  kinds with exact outcomes — byte-exact stdout, one WO-E### anchored on
  `error CODE:`, numeric trap code, gc trace. 25 fixtures incl. pricing-demo
  logic, the ownership suite, and DB_STUB's parse-but-trap. `tests/` un-ignored
  so the corpus is actually tracked.
- `woc build` produces a self-contained binary: wovm copy + appended image +
  20-byte trailer, self-exec via /proc/self/exe. Verified relocated outside
  the repo, argless, and against adversarial trailer corruption.
- Milestone 1's five spec criteria all MET (`just oop-accept`). Criterion 3
  closed by WO-E405 — the entry must return `Int`, since program mode already
  says its return value is the exit code — which deletes the leak class
  without adding return-type metadata to the format. `gc/held-cycle` retired:
  an externally-held cycle is not expressible in a post-exit pump.
- New spec: inferred GC + incremental per-shard tri-color mark-sweep, retiring
  `@gc` and reference counting. Story gains iterations 7b (that work) and 9b
  (`@table`, relations, compiler-checked query); `.dev/reference` gains a
  sparse System.Linq checkout. Priority: 5→6→7 (log-watcher) then 7b, 8, 9, 9b.
2026-08-11 19:31:26 +02:00

11 KiB

tests/corpus/ — how to add a conformance fixture

The contribution path every later sub-project's corpus (actor/, db/, lang/, sys/, sample-logwatcher/) follows, and the one gc/ (below) already uses. Enforced by scripts/oop-e2e.sh (plan 3, Task 2), run via just oop-e2e. What a .wo fixture may actually say is 08-builtin-surface.md's contract, not this doc's — read that first, or you will write fixtures against the compiler's internals instead of its source-language contract and waste time chasing WO-E403s that were never about your fixture's intent.

Layout: one directory per fixture, fixed filenames

Every fixture is its own directory under its kind (run/, compile-fail/, trap/, gc/), named for what it exercises (kebab-case, e.g. interface-dispatch, not test3). Inside, filenames are fixed so the harness can walk every kind the same way:

tests/corpus/run/<name>/fixture.wo
tests/corpus/run/<name>/fixture.out

tests/corpus/compile-fail/<name>/fixture.wo
tests/corpus/compile-fail/<name>/fixture.code

tests/corpus/trap/<name>/fixture.wo
tests/corpus/trap/<name>/fixture.trap

tests/corpus/gc/<name>/fixture.wo
tests/corpus/gc/<name>/fixture.out
tests/corpus/gc/<name>/fixture.trace
tests/corpus/gc/<name>/fixture.gc_budget   -- optional

scripts/oop-e2e.sh globs tests/corpus/<kind>/*/, so a stray .wo file placed directly inside a kind directory (not in its own subdirectory) is never picked up — no error, no run, it just silently does not exist as a fixture. If a fixture stops appearing in the tally, check that first.

run/ — compiles, runs, exact stdout

Files: fixture.wo, fixture.out.

Rule: woc --emit fixture.wo -o <scratch>.wob must exit 0, then wovm <scratch>.wob must exit 0 with stdout byte-for-byte identical to fixture.out — trailing newline included, since print/print_int are newline-terminated (08-builtin-surface.md). No substring match, no trimming. Generate fixture.out by actually running the fixture, not by hand-typing what you expect the output to be:

just woc-build   # compiler/_build/default/bin/woc
make -C runtime wovm
compiler/_build/default/bin/woc --emit tests/corpus/run/<name>/fixture.wo -o /tmp/f.wob
runtime/wovm /tmp/f.wob > tests/corpus/run/<name>/fixture.out

Then read fixture.out back and sanity-check it says what you meant — a byte-exact copy of a wrong run is still wrong, just consistently so.

The four seed fixtures (hello, arithmetic, methods, interface) cover: print/print_int; arithmetic and control flow, including the two operators the v1 instruction set lowers rather than gives an opcode (%, !=); a direct method call (CALL by method index, receiver's declared type is a concrete class); and structural interface dispatch (ICALL by vtable slot, receiver's declared type is an interface, no implements keyword). Look at these before writing a new one — they are proof that a given construct actually round-trips through the real wovm, not just through --dump-bc.

compile-fail/ — must fail with exactly one code

Files: fixture.wo, fixture.code.

Rule: fixture.code names exactly one diagnostic code (whitespace is stripped, so WO-E215 on its own line is enough). woc --emit fixture.wo -o <scratch>.wob must exit 1 with that code appearing in stderr. Exit 0 (compiled clean), exit 2 (a usage/IO failure, not a diagnostic), or exit 1 with a different code are all failures — the harness names which.

Use --emit, not the bare woc <path> check-only form, when hand-testing a fixture: --emit runs the full pipeline including the emitter, so it also catches WO-E4xx cases (register budget, unlowerable constructs) that check-only mode never reaches. scripts/oop-e2e.sh always uses --emit for this kind for the same reason.

Every code in 01-error-catalog.md's main tables is a legitimate compile-fail/ target; the codes under "Reserved, not yet emitted" are not — there is no call site to trigger them yet.

trap/ — must compile, then trap with exactly one code

Files: fixture.wo, fixture.trap.

Rule: fixture.trap names exactly one integer trap code (again, whitespace-stripped). woc --emit must exit 0 (a trap/ fixture that fails to compile is a compile-fail/ fixture wearing the wrong hat — move it). Then wovm <scratch>.wob must exit 1, with stderr's one fixed line

trap CODE in METHOD at line L: MESSAGE

giving exactly the CODE named in fixture.trap. Exit 0 (ran to completion instead of trapping), exit 2 (a loader rejection — the image was malformed, not merely trapped at runtime), or exit 1 with a different CODE are all failures.

gc/ — must compile, run to completion, and drive the collector exactly

Files: fixture.wo, fixture.out, fixture.trace, optionally fixture.gc_budget.

Rule: woc --emit must exit 0, then wovm <scratch>.wob must exit 0 with WO_GC_TRACE=1 set (and WO_GC_BUDGET set from fixture.gc_budget if the fixture has one). Two things are then checked exactly, both generated by actually running the fixture, never hand-typed:

  • stdout, byte-for-byte against fixture.out — same rule as run/. This is the fixture proving it executed the intended shape (e.g. a container's element count) before anything is abandoned.

  • the gc pump's stderr trace, against fixture.trace. The pump (runtime/src/main.c) prints one gc: step N budget=B freed=F visited=V remaining=R line per collection step; fixture.trace names the exact total step count and the exact total freed count across every step, as two key=value lines:

    steps=1
    freed=2
    

    The harness counts ^gc: step lines in stderr for steps=, and sums every step's freed= value for freed=. A wrong count either way — an object freed that should have survived, one that should have been freed but wasn't, or a sweep that didn't slice the way the fixture's budget says it should — is a named failure, exactly like a wrong WO-E### or trap code.

Why not assert via ASan/LeakSanitizer instead: a sanitizer is how each gc/ fixture was actually verified (see below) and is the right tool for proving a freed object was genuinely freed, not recycled inside the arena's own freelist where nothing external can observe it. But LeakSanitizer's leak scan is conservative — it can find a stray bit pattern in the VM's own register file that happens to alias a live heap address and treat an object as "reachable" that the collector's own bookkeeping would not — so its exact output is not stable enough to assert byte-for-byte in an automated regression gate. The trace's steps=/freed= counts come straight from the collector's own accounting (wo_gc_step's return value and the public cycle-candidate buffer length in runtime/src/obj.h), so they are exactly reproducible; running the whole corpus under an ASan+UBSan wovm (make -C runtime wovm-asan) is a supplementary, manual check, not something scripts/oop-e2e.sh automates.

Retired: gc/held-cycle (milestone-1 criterion-3 closure). An earlier fixture returned a @gc cycle from main to model an externally held cycle — a root the pump must not collect. It could never actually prove that: the program entry's return value is the process exit code (docs/superpowers/specs/2026-08-01-systems-track-design.md:70), and runtime/src/main.c never releases it, so the fixture's "hold" was really a permanent, un-freeable refcount inflation — indistinguishable from a leak, and confirmed as exactly that: runtime/build/wovm_asan reported it as a genuine LeakSanitizer definite leak. WO-E405 (compiler/src/emit.ml, 01-error-catalog.md) now rejects a non-Int entry return type at compile time, which makes the fixture's own premise inexpressible — a post-exit pump has no live roots once the entry returns, by construction, so an externally held cycle cannot be modeled from inside a .wo program at all. The scenario this fixture meant to cover — a cycle kept alive by a real external root — is already covered properly by test_externally_held_cycle_survives_then_dies in runtime/test/test_cycle.c, which holds its root the honest way (a C local variable, not a leaked return value). Story iteration 7b (tracing GC design) schedules a proper in-flight fixture for this shape once the runtime has a way to express an external root without going through main's return. gc/abandoned-cycle and gc/budget-steps are unaffected — neither depends on an externally-held root.

Why a multi field, not a plain @gc-typed field, closes the cycle: milestone-1 has no nil literal and a constructor literal requires every field, so two classes that mandatorily reference each other can never be built — whichever is constructed first needs an instance of the other that does not exist yet. A multi field sidesteps this: it starts empty (multi_new()), so both objects can be constructed before either references the other, and push closes the cycle afterward. This is also why fixture classes carry ~130 Int filler fields alongside the one multi field that matters — an object under 1024 bytes (WO_ARENA_MAX_CLASS, runtime/src/obj.h) allocates through the arena's own bump/freelist, where a sanitizer can never observe its free; over that size, wo_arena_alloc routes to plain malloc, which is what lets ASan prove the frees runtime/test/test_cycle.c already relies on the same way (BIG = 130).

Why exact-match, not substring or "any failure"

A fixture that merely checks "did something go wrong" degrades silently the day the front end starts failing for the wrong reason — the fixture stays green while the bug it was written for comes back under a different code path. Naming the exact code (WO-E### or trap N) means a regression that changes which diagnostic fires is caught exactly as reliably as one that stops firing at all.

Running the harness

just woc-build             # builds compiler/_build/default/bin/woc
make -C runtime wovm       # builds runtime/wovm
just oop-e2e               # walks the corpus, one line per fixture, a final tally

make -C runtime wovm-asan  # optional: builds runtime/build/wovm_asan, for
                            # manually re-running gc/ fixtures under ASan+UBSan

just oop-e2e fails loudly and names the missing binary (and the command to build it) if either prerequisite above hasn't been built — it does not build them for you. wovm-asan is not one of those prerequisites: the automated harness runs every kind, gc/ included, against the plain wovm; the sanitizer build is a manual supplementary check (see gc/ above).