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.
This commit is contained in:
shoney.arickathil 2026-08-11 19:31:26 +02:00
parent a55971d857
commit 79605cbb4f
96 changed files with 6545 additions and 102 deletions

View file

@ -35,6 +35,7 @@ Study-tree notes:
| Link | Points at | Why it's a reference |
| --- | --- | --- |
| `reference/llvm-project/` | `~/projects/llvm-project` (shallow clone) | Compiler-architecture study for the OCaml `woc` compiler: pass pipelines (`llvm/lib/Passes/`), IR design (`llvm/docs/LangRef.md`), Clang's lexer/parser/sema layering (`clang/lib/{Lex,Parse,Sema}/`), diagnostics machinery (`clang/include/clang/Basic/Diagnostic*.td`). Study-only — writeonce does NOT link against LLVM (zero-dep doctrine; `woc` emits `.wob` bytecode, no LLVM backend). |
| `reference/dotnet-runtime/` | `~/projects/dotnet-runtime` (shallow + **sparse**: only `src/libraries/System.Linq`, 15 MB instead of multi-GB) | Query-surface study for story iteration 9b (`@table` relations + language-integrated query). Read `src/libraries/System.Linq/src/System/Linq/` for the operator set and how each is specified (`Where.cs`, `Select.cs`, `Join.cs`, `GroupBy.cs`, `OrderBy.cs`), and the `*.SpeedOpt.cs` files for how LINQ specializes when the source's shape is known. Study-only, and note the deliberate divergence: LINQ-to-Objects is *runtime* iterator composition over `IEnumerable`, while writeonce has no function values and forbids reflection — so writeonce takes the operator vocabulary and semantics, not the delegate/expression-tree machinery. |
(The former separate `references/` directory was merged into `reference/`
on 2026-08-08 — one home for all study trees.)

18
.gitignore vendored
View file

@ -45,6 +45,12 @@
# ln -s <path-to-colibri> .dev/reference/colibri
# ln -s <path-to-llama.cpp> .dev/reference/llama-cpp
# ln -s <path-to-llvm-project> .dev/reference/llvm-project
# ln -s <path-to-dotnet-runtime> .dev/reference/dotnet-runtime
# (sparse clone -- only src/libraries/System.Linq:
# git clone --filter=blob:none --no-checkout --depth 1 \
# https://github.com/dotnet/runtime.git ~/projects/dotnet-runtime
# cd ~/projects/dotnet-runtime && git sparse-checkout init --cone \
# && git sparse-checkout set src/libraries/System.Linq && git checkout)
# Agent-orchestration scratch (SDD ledgers, briefs, review packages)
/.superpowers/
@ -65,12 +71,12 @@
prototypes/llama-moe-stream/
prototypes/wo-db/
# NOTE (2026-08-10): `tests/` stays ignored, but story iteration 4 (plan 3)
# lands the conformance corpus under `tests/corpus/` — un-ignore it in that
# change, or the corpus will be invisible to git exactly as the docs below
# were. See docs/plan/learnings.md, "check that a new document is actually
# tracked".
tests/
# `tests/` un-ignored 2026-08-11 (plan 3 Task 2): the conformance corpus
# lands under `tests/corpus/` and must be tracked, not invisible to git
# the way the docs below already were once. See docs/plan/learnings.md,
# "check that a new document is actually tracked". No build artifacts
# land under `tests/` — the harness's own scratch files use mktemp
# outside the repo — so nothing needs re-ignoring beneath it.
# Documentation is version-controlled — repo doctrine puts docs under `docs/`,
# and ignoring them there defeats the point. These directories were ignored

View file

@ -1,8 +1,8 @@
# compiler/ — the OCaml `woc` compiler
Lexer → parser → typechecker → ownership pass, for `.wo`. OCaml stdlib only (no Menhir, no ppx); dune is the build runner. Sibling of the C `wovm` bytecode VM (`runtime/`) — the two halves of the OOP track's spec (`docs/superpowers/specs/2026-08-01-oop-compiler-vm-design.md`) meet at plan 3 (`.wob` emission; not built yet).
Lexer → parser → typechecker → ownership pass → bytecode emitter, for `.wo`. OCaml stdlib only (no Menhir, no ppx); dune is the build runner. Sibling of the C `wovm` bytecode VM ([`runtime/`](../runtime/README.md)) — the two halves of the OOP track's spec (`docs/superpowers/specs/2026-08-01-oop-compiler-vm-design.md`) meet at plan 3, where `woc`'s emitted `.wob` runs on `wovm`.
**Stage: plan 2 (`docs/plan/compiler/2026-08-01-woc-compiler-front.md`) complete, Tasks 1–8.** Diagnostics, lexer, parser (declarations + statements/expressions), typechecker (symbols, field kinds, structural interfaces, `?T` nullable), and the MVS ownership pass are all implemented and wired into the `woc` executable. Bytecode emission and `.wob` output are plan 3 — not started.
**Stage: plan 3 (`docs/plan/compiler/2026-08-01-wob-emit-e2e-single-binary.md`) complete, Tasks 1–6 + 8** (Task 7, a parity harness against the Rust runtime, was deferred by explicit decision — the two stacks now diverge by design). `.wo` source compiles to `.wob` bytecode (`--emit`) and to a single self-contained executable (`build`) that runs `wovm` with no arguments and no repo-relative dependency. Milestone 1's acceptance gate — compile-time budget, the full conformance corpus under ASan, the single-binary smoke, both unit suites — is `just oop-accept`. Plan 2 (lexer through ownership pass) shipped first and is unchanged.
## Requirements
@ -23,10 +23,15 @@ just woc-test # same, from the repo root
## Running `woc`
```
woc <path> # compile (lex, parse, typecheck, ownership-check); nothing prints on success
woc --dump-tokens <path> # stdout: one line per lexed token
woc --dump-ast <path> # stdout: the declaration + body AST, indented
woc --dump-owner <path> # stdout: the ownership pass's four tables (moves, drops, rc, residual)
woc <path> # compile (lex, parse, typecheck, ownership-check); nothing prints on success
woc --dump-tokens <path> # stdout: one line per lexed token
woc --dump-ast <path> # stdout: the declaration + body AST, indented
woc --dump-owner <path> # stdout: the ownership pass's four tables (moves, drops, rc, residual)
woc --dump-bc <path> # stdout: disassembled bytecode for every emitted method
woc --emit <path> -o <out.wob> # compile through to a .wob bytecode module, runnable by wovm
woc build <dir> -o <app> [--runtime <path>]
# compile + append the .wob image to a copy of wovm (default
# runtime/wovm, or --runtime) into one self-contained <app>
```
`<path>` is a single `.wo` file or a directory. A directory is discovered recursively for every `.wo` file under it — same contract as `wo run` (`crates/rt/src/lib.rs::discover`): dot-prefixed entries and `target`/`data`/`node_modules` are skipped, results are sorted by path. Every discovered file compiles as one program (declarations in one file resolve for bodies in another, regardless of discovery order); diagnostics from every file and every stage print sorted by `(file, line, col)`. For multi-file `--dump-*` output, each file's dump is preceded by a `=== path ===` header line (`compiler/src/dump.ml`'s `file_header`) — a single-file run never prints one.
@ -35,8 +40,8 @@ Diagnostics render as `file:line:col: severity CODE: message` plus a source exce
## Layout
- `src/` — one module per stage: `diag` (diagnostics, collector, exit-code decision), `token`/`lexer`, `ast`/`parser`, `types` (typechecker), `owner` (MVS ownership pass), `dump` (stable text dumps for all of the above)
- `bin/` — the `woc` executable: CLI parsing, file discovery, the multi-file/cross-file driver
- `test/` — `runner.ml` (golden runner + CLI smoke) and `test_diag.ml` (diag.ml unit checks); `test/golden/<stage>/` holds one-file-per-fixture goldens (`tokens`, `ast`, `owner`, `owner-err`); `test/fixtures/driver/` holds the multi-file CLI-smoke fixtures (directory discovery, cross-file symbols, diagnostic ordering) that don't fit the one-`.wo`-file-per-fixture golden shape
- `src/` — one module per stage: `diag` (diagnostics, collector, exit-code decision), `token`/`lexer`, `ast`/`parser`, `types` (typechecker), `owner` (MVS ownership pass), `emit` (bytecode emitter, consumes `owner`'s four tables), `disasm` (bytecode disassembler, backs `--dump-bc`), `dump` (stable text dumps for all of the above)
- `bin/` — the `woc` executable: CLI parsing, file discovery, the multi-file/cross-file driver, `--emit`/`build` output
- `test/` — `runner.ml` (golden runner + CLI smoke) and `test_diag.ml` (diag.ml unit checks); `test/golden/<stage>/` holds one-file-per-fixture goldens (`tokens`, `ast`, `owner`, `owner-err`, `bc`); `test/fixtures/driver/` holds the multi-file CLI-smoke fixtures (directory discovery, cross-file symbols, diagnostic ordering) that don't fit the one-`.wo`-file-per-fixture golden shape
Governing docs (all under `docs/`, not here — this file stays an orientation README): spec `docs/superpowers/specs/2026-08-01-oop-compiler-vm-design.md`; plan `docs/plan/compiler/2026-08-01-woc-compiler-front.md` (+ `architecture.md`, `nullable-types-implementation.md`, `2026-08-01-haxe-parity-language.md`, `2026-08-01-wob-emit-e2e-single-binary.md` in the same directory). Format contract: `docs/plan/oop-vm/00-wob-format.md`. Error catalog: `docs/plan/oop-vm/01-error-catalog.md`.
Governing docs (all under `docs/`, not here — this file stays an orientation README): spec `docs/superpowers/specs/2026-08-01-oop-compiler-vm-design.md`; plans `docs/plan/compiler/2026-08-01-woc-compiler-front.md` and `2026-08-01-wob-emit-e2e-single-binary.md` (+ `architecture.md`, `nullable-types-implementation.md`, `2026-08-01-haxe-parity-language.md` in the same directory). Format contract: `docs/plan/oop-vm/00-wob-format.md`. Error catalog: `docs/plan/oop-vm/01-error-catalog.md`. Conformance corpus contract (fixture layout `woc`'s golden output feeds into): `docs/plan/oop-vm/02-corpus.md`; source-language builtin surface `woc` accepts: `docs/plan/oop-vm/08-builtin-surface.md`. Runtime sibling: [`runtime/README.md`](../runtime/README.md).

View file

@ -40,9 +40,12 @@
let usage_msg =
"usage: woc <path>\n\
usage: woc --emit <path> -o <out.wob>\n\
usage: woc build <dir> -o <app> [--runtime <path>]\n\
usage: woc --dump-tokens <path>\n\
usage: woc --dump-ast <path>\n\
usage: woc --dump-owner <path>\n\
usage: woc --dump-bc <path>\n\
\n\
Compiles writeonce (.wo) source. <path> is a single .wo file or a\n\
directory: a directory is discovered recursively for every .wo file\n\
@ -70,6 +73,28 @@ let usage_msg =
sites — see compiler/src/dump.ml for the format); lexing, parsing,\n\
type and ownership (WO-E3xx) diagnostics print to stderr.\n\
\n\
--emit runs the whole pipeline and writes the `.wob` v1 image named\n\
by -o (docs/plan/oop-vm/00-wob-format.md). Every discovered file\n\
contributes to one image; the entry point is the zero-argument free\n\
fn `main`, if the program declares one. Nothing is written when any\n\
diagnostic is an error — bytecode for a program that does not compile\n\
is never produced.\n\
\n\
--dump-bc emits the same image and prints its disassembly to stdout\n\
(compiler/src/disasm.ml). Unlike the other dumps it prints nothing\n\
when the compile is not clean: a disassembly of a program that failed\n\
to compile would be describing bytecode nobody may run.\n\
\n\
build compiles <dir> like --emit, then produces one self-contained\n\
executable at -o: the wovm runtime binary (--runtime <path>, or\n\
runtime/wovm relative to the current directory when omitted) with the\n\
compiled .wob image and a fixed-size trailer appended, so the result\n\
runs standalone with no separate .wob file or argument (wovm finds the\n\
embedded image via /proc/self/exe -- see docs/plan/oop-vm/00-wob-format.md's\n\
\"single-binary trailer\" section). Nothing is written when the compile\n\
has diagnostics, when the program declares no zero-argument free fn\n\
named `main`, or when the runtime binary cannot be found.\n\
\n\
For a directory (or otherwise multi-file) path, every --dump-* flag\n\
prints each file's own dump in turn, separated by a header line — see\n\
compiler/src/dump.ml's file_header doc comment.\n\
@ -311,11 +336,161 @@ let check_only path =
List.iter (fun (f, prog) -> ignore (Woc_lib.Owner.analyze ~file:f prog syms collector)) parsed;
finish collector (build_lookup sources)
(* ---- emit mode (plan 3, Task 1) --------------------------------------
The whole pipeline plus the emitter. Every discovered file feeds one
`.wob` image: class ids, interface slot ids and method indexes are
assigned in discovery-then-declaration order, and each file keeps its
own owner tables because node ids are minted per parse (unique within
a file, not across files). *)
let compile_image path =
let sources = discover_and_read path in
let collector = Woc_lib.Diag.Collector.create () in
let parsed = parse_all collector sources in
let syms = typecheck_all collector parsed in
let units =
List.map
(fun (f, prog) ->
{ Woc_lib.Emit.file = f; prog; tables = Woc_lib.Owner.analyze ~file:f prog syms collector })
parsed
in
let image = Woc_lib.Emit.emit ~syms collector units in
(collector, build_lookup sources, image)
let write_file path contents =
try
let oc = open_out_bin path in
output_string oc contents;
close_out oc
with Sys_error msg ->
Printf.eprintf "woc: %s\n" msg;
exit 2
let emit_mode path out =
let collector, lookup, image = compile_image path in
if Woc_lib.Diag.Collector.has_error collector then finish collector lookup
else begin
write_file out image;
finish collector lookup
end
let dump_bc path =
let collector, lookup, image = compile_image path in
if not (Woc_lib.Diag.Collector.has_error collector) then
print_string (Woc_lib.Disasm.dump image);
finish collector lookup
(* ---- build mode (plan 3, Task 6): the single self-contained binary ---
`woc build <dir> -o app` compiles like --emit, then glues together a
runnable executable: the wovm runtime binary, the freshly compiled
.wob image, and a fixed-size trailer so wovm's own startup
(runtime/src/main.c) can find the embedded image via /proc/self/exe
and ignore argv. Trailer layout is docs/plan/oop-vm/00-wob-format.md's
"single-binary trailer" section -- this writer and main.c's reader
must never disagree about it.
Edge cases, decided and documented alongside the trailer format:
- output path already exists: overwritten, but atomically (build to a
temp file next to -o, then rename over it) so a failed build never
clobbers a working binary with a partial one.
- a directory with no `main`: unlike --emit (where a .wob with no
entry is a legitimate artifact), `build`'s whole point is something
you can run, so this is a build-time error, not deferred to wovm's
own "module has no entry method" at run time.
- the --runtime binary is itself already a built single binary (has
its own trailer): its embedded payload is stripped before copying,
so rebuilding from a built binary doesn't chain payloads/trailers. *)
let trailer_magic = 0x31544257l (* "WBT1" read as LE u32 (mirrors WOB_MAGIC's "WOB1") *)
let trailer_size = 20 (* payload_off u64, payload_len u64, magic u32 *)
let wob_off_entry = 40 (* WOB_OFF_ENTRY, runtime/src/wob.h *)
let trailer_bytes ~(payload_off : int) ~(payload_len : int) : bytes =
let t = Bytes.create trailer_size in
Bytes.set_int64_le t 0 (Int64.of_int payload_off);
Bytes.set_int64_le t 8 (Int64.of_int payload_len);
Bytes.set_int32_le t 16 trailer_magic;
t
(* If `rt` already carries a valid trailer of our own (i.e. it's itself
the output of a previous `woc build`), its embedded payload is dead
weight for a fresh build: return just the pristine runtime prefix.
Anything that doesn't look unambiguously like our own trailer (wrong
magic, or offsets that don't exactly account for every trailing byte)
is returned untouched -- the safe default when it's not certain. *)
let strip_existing_trailer (rt : string) : string =
let n = String.length rt in
if n < trailer_size then rt
else if String.get_int32_le rt (n - 4) <> trailer_magic then rt
else
let payload_off = Int64.to_int (String.get_int64_le rt (n - trailer_size)) in
let payload_len = Int64.to_int (String.get_int64_le rt (n - trailer_size + 8)) in
if payload_off >= 0 && payload_off <= n - trailer_size
&& payload_len = n - trailer_size - payload_off
then String.sub rt 0 payload_off
else rt
let default_runtime_path = "runtime/wovm"
let build_mode ~(runtime : string option) (path : string) (out : string) : unit =
let collector, lookup, image = compile_image path in
if Woc_lib.Diag.Collector.has_error collector then finish collector lookup
else begin
if String.get_int32_le image wob_off_entry = -1l then begin
Printf.eprintf
"woc: %s: no `main` entry point found; `build` requires a zero-argument free fn named \
`main`\n"
path;
exit 2
end;
let rt_path = match runtime with Some p -> p | None -> default_runtime_path in
if (not (Sys.file_exists rt_path)) || Sys.is_directory rt_path then begin
Printf.eprintf "woc: runtime binary not found at '%s' -- build it with: make -C runtime wovm\n"
rt_path;
exit 2
end;
let rt_bytes =
match read_source rt_path with
| Ok s -> strip_existing_trailer s
| Error msg ->
Printf.eprintf "woc: %s\n" msg;
exit 2
in
let tmp = out ^ ".woc-build.tmp" in
(* stale tmp from an interrupted earlier build must not survive: its
permission bits would leak through, since Open_creat on an
existing inode does not apply the requested mode *)
(try Sys.remove tmp with Sys_error _ -> ());
(try
let oc = open_out_gen [ Open_wronly; Open_creat; Open_trunc; Open_binary ] 0o755 tmp in
output_string oc rt_bytes;
output_string oc image;
output_bytes oc
(trailer_bytes ~payload_off:(String.length rt_bytes) ~payload_len:(String.length image));
close_out oc
with Sys_error msg ->
(try Sys.remove tmp with Sys_error _ -> ());
Printf.eprintf "woc: %s\n" msg;
exit 2);
(try Sys.rename tmp out
with Sys_error msg ->
Printf.eprintf "woc: %s\n" msg;
exit 2);
finish collector lookup
end
let () =
match Sys.argv with
| [| _; "--dump-tokens"; path |] -> dump_tokens path
| [| _; "--dump-ast"; path |] -> dump_ast path
| [| _; "--dump-owner"; path |] -> dump_owner path
| [| _; "--dump-bc"; path |] -> dump_bc path
| [| _; "--emit"; path; "-o"; out |] -> emit_mode path out
| [| _; "build"; path; "-o"; out |] -> build_mode ~runtime:None path out
| [| _; "build"; path; "-o"; out; "--runtime"; rt |] -> build_mode ~runtime:(Some rt) path out
| [| _; "build"; path; "--runtime"; rt; "-o"; out |] -> build_mode ~runtime:(Some rt) path out
| [| _; path |] -> check_only path
| _ ->
prerr_string usage_msg;

View file

@ -48,6 +48,7 @@
WO-E1xx parsing (Task 4, 5)
WO-E2xx types (Task 6)
WO-E3xx ownership (Task 7)
WO-E4xx emitter (plan 3 Task 1: bytecode/format limits)
No codes are minted in this module — it only reserves the ranges.
The prefixes below are the single documented source later stages
@ -58,6 +59,7 @@ let lexing_prefix = "WO-E0"
let parsing_prefix = "WO-E1"
let types_prefix = "WO-E2"
let ownership_prefix = "WO-E3"
let emitter_prefix = "WO-E4"
let warning_prefix = "WO-W"
type severity =

282
compiler/src/disasm.ml Normal file
View file

@ -0,0 +1,282 @@
(* disasm.ml — renders a `.wob` image back to readable mnemonics.
This is what `woc --dump-bc` prints and what the golden fixtures
under compiler/test/golden/bc/ pin. Two reasons it decodes the
*bytes* rather than reading the emitter's in-memory tables:
- a pinned dump then covers serialization too, so a header offset,
a pad byte or a table count that goes wrong shows up as a golden
diff instead of surviving to the loader;
- the decoder is written against the same normative documents the
emitter is (docs/plan/oop-vm/00-wob-format.md and
runtime/src/wob.h), so the two halves disagree loudly.
Format of the dump (a stable test contract, same doctrine as
dump.ml's): fixed sections in a fixed order; one line per constant,
class, interface, vtable row and instruction; a method's line and
drop tables printed as their own lines before its code, because
those tables *are* the deliverable for the drop-map and trap-line
goldens. Registers print as rN, constants kN, classes cN, methods
mN, interface slots sN, field indexes fN; jumps print their absolute
target pc, which is what a reader wants and what stays stable when
an unrelated instruction is inserted before the jump. *)
let magic = 0x31424F57
let hdr_size = 44
let none = 0xFFFFFFFF
exception Bad of string
(* ---- little-endian readers (bounds-checked: a dump must never read
past a truncated image, however it got truncated) ---- *)
let u8 (s : string) (o : int) : int =
if o + 1 > String.length s then raise (Bad "truncated");
String.get_uint8 s o
let u16 (s : string) (o : int) : int =
if o + 2 > String.length s then raise (Bad "truncated");
String.get_uint16_le s o
let u32 (s : string) (o : int) : int =
if o + 4 > String.length s then raise (Bad "truncated");
Int32.to_int (String.get_int32_le s o) land 0xFFFFFFFF
let i64 (s : string) (o : int) : int64 =
if o + 8 > String.length s then raise (Bad "truncated");
String.get_int64_le s o
let op_of i = i land 0xFF
let a_of i = (i lsr 8) land 0xFF
let b_of i = (i lsr 16) land 0xFF
let c_of i = (i lsr 24) land 0xFF
let bx_of i = (i lsr 16) land 0xFFFF
let sbx_of i = bx_of i - 32768
let builtin_name = function
| 0 -> "now"
| 1 -> "print"
| 2 -> "print_int"
| 3 -> "words"
| 4 -> "multi_new"
| 5 -> "multi_push"
| 6 -> "multi_get"
| 7 -> "count"
| 8 -> "latest"
| 9 -> "map_new"
| 10 -> "map_set"
| 11 -> "map_get"
| 12 -> "map_has"
| n -> Printf.sprintf "builtin%d" n
let kind_name = function
| 0 -> "SCALAR"
| 1 -> "OWNED"
| 2 -> "GCREF"
| 3 -> "TEXT"
| 4 -> "MULTI"
| 5 -> "MAP"
| n -> Printf.sprintf "KIND%d" n
(* text constants render with the few escapes a one-line dump needs;
anything else would let a fixture's newline break the line format *)
let quote (s : string) : string =
let b = Buffer.create (String.length s + 2) in
Buffer.add_char b '"';
String.iter
(fun ch ->
match ch with
| '"' -> Buffer.add_string b "\\\""
| '\\' -> Buffer.add_string b "\\\\"
| '\n' -> Buffer.add_string b "\\n"
| '\t' -> Buffer.add_string b "\\t"
| c when Char.code c < 32 -> Buffer.add_string b (Printf.sprintf "\\x%02x" (Char.code c))
| c -> Buffer.add_char b c)
s;
Buffer.add_char b '"';
Buffer.contents b
let mask_str (m : int64) : string =
if m = 0L then "{}"
else begin
let regs = ref [] in
for r = 63 downto 0 do
if Int64.logand m (Int64.shift_left 1L r) <> 0L then regs := Printf.sprintf "r%d" r :: !regs
done;
"{" ^ String.concat "," !regs ^ "}"
end
let ins_str (i : int) (pc : int) : string =
let a = a_of i and b = b_of i and c = c_of i in
let bx = bx_of i in
let target = pc + 1 + sbx_of i in
match op_of i with
| 0 -> "NOP"
| 1 -> Printf.sprintf "LOADK r%d, k%d" a bx
| 2 -> Printf.sprintf "MOVE r%d, r%d" a b
| 3 -> Printf.sprintf "ADD r%d, r%d, r%d" a b c
| 4 -> Printf.sprintf "SUB r%d, r%d, r%d" a b c
| 5 -> Printf.sprintf "MUL r%d, r%d, r%d" a b c
| 6 -> Printf.sprintf "DIV r%d, r%d, r%d" a b c
| 7 -> Printf.sprintf "NEG r%d, r%d" a b
| 8 -> Printf.sprintf "CONCAT r%d, r%d, r%d" a b c
| 9 -> Printf.sprintf "EQ r%d, r%d, r%d" a b c
| 10 -> Printf.sprintf "LT r%d, r%d, r%d" a b c
| 11 -> Printf.sprintf "LE r%d, r%d, r%d" a b c
| 12 -> Printf.sprintf "EQS r%d, r%d, r%d" a b c
| 13 -> Printf.sprintf "JMP -> %04d" target
| 14 -> Printf.sprintf "JZ r%d, -> %04d" a target
| 15 -> Printf.sprintf "CALL r%d, m%d" a bx
| 16 -> Printf.sprintf "ICALL r%d, s%d" a bx
| 17 -> Printf.sprintf "RET r%d" a
| 18 -> "RET0"
| 19 -> Printf.sprintf "NEW r%d, c%d" a bx
| 20 -> Printf.sprintf "GETF r%d, r%d, f%d" a b c
| 21 -> Printf.sprintf "SETF r%d, f%d, r%d" a b c
| 22 -> Printf.sprintf "DROP r%d" a
| 23 -> Printf.sprintf "BORROW_S r%d" a
| 24 -> Printf.sprintf "BORROW_X r%d" a
| 25 -> Printf.sprintf "RELEASE_S r%d" a
| 26 -> Printf.sprintf "RELEASE_X r%d" a
| 27 -> Printf.sprintf "RC_INC r%d" a
| 28 -> Printf.sprintf "RC_DEC r%d" a
| 29 ->
if c = 4 || c = 9 then Printf.sprintf "BUILTIN r%d, kinds=0x%02x, %s" a b (builtin_name c)
else Printf.sprintf "BUILTIN r%d, r%d, %s" a b (builtin_name c)
| 30 -> "DB_STUB"
| 31 -> Printf.sprintf "TRAP %d" bx
| op -> Printf.sprintf "?OP%d" op
(* ---- the dump ---- *)
type kconst =
| KInt of int64
| KText of string
let dump (img : string) : string =
let out = Buffer.create 4096 in
let line fmt = Buffer.add_string out (fmt ^ "\n") in
if u32 img 0 <> magic then raise (Bad "bad magic");
let ver = u32 img 4 in
if ver <> 1 then raise (Bad (Printf.sprintf "unsupported version %d" ver));
let coff = u32 img 8 and ccnt = u32 img 12 in
let koff = u32 img 16 and kcnt = u32 img 20 in
let ioff = u32 img 24 and icnt = u32 img 28 in
let moff = u32 img 32 and mcnt = u32 img 36 in
let entry = u32 img 40 in
ignore hdr_size;
(* constants *)
let consts = Array.make (max ccnt 1) (KInt 0L) in
let o = ref coff in
for i = 0 to ccnt - 1 do
let tag = u8 img !o in
incr o;
if tag = 0 then begin
consts.(i) <- KInt (i64 img !o);
o := !o + 8
end
else if tag = 1 then begin
let n = u32 img !o in
o := !o + 4;
if !o + n > String.length img then raise (Bad "text constant overruns image");
consts.(i) <- KText (String.sub img !o n);
o := !o + n
end
else raise (Bad (Printf.sprintf "constant %d: unknown tag %d" i tag))
done;
let kname i =
if i >= ccnt then Printf.sprintf "<k%d?>" i
else match consts.(i) with KText s -> s | KInt n -> Int64.to_string n
in
line "== CONSTANTS ==";
for i = 0 to ccnt - 1 do
match consts.(i) with
| KInt n -> line (Printf.sprintf "k%-3d INT %Ld" i n)
| KText s -> line (Printf.sprintf "k%-3d TEXT %s" i (quote s))
done;
(* classes *)
line "== CLASSES ==";
let o = ref koff in
for i = 0 to kcnt - 1 do
let nm = u32 img !o and flags = u32 img (!o + 4) and fcnt = u32 img (!o + 8) in
o := !o + 12;
let kinds = List.init fcnt (fun j -> kind_name (u8 img (!o + j))) in
o := !o + fcnt + ((4 - (fcnt mod 4)) mod 4);
line
(Printf.sprintf "c%-3d %s flags=%s fields=[%s]" i (kname nm)
(if flags land 1 <> 0 then "gc" else "-")
(String.concat ", " kinds))
done;
(* interfaces + vtable rows *)
line "== INTERFACES ==";
let o = ref ioff in
let slot_base = Array.make (max icnt 1) 0 in
let imcnt = Array.make (max icnt 1) 0 in
let slots = ref 0 in
for i = 0 to icnt - 1 do
let nm = u32 img !o and mc = u32 img (!o + 4) in
o := !o + 8;
slot_base.(i) <- !slots;
imcnt.(i) <- mc;
line (Printf.sprintf "i%-3d %s methods=%d slots=s%d..s%d" i (kname nm) mc !slots (!slots + mc - 1));
slots := !slots + mc
done;
let vrows = u32 img !o in
o := !o + 4;
line "== VTABLES ==";
for _ = 1 to vrows do
let cid = u32 img !o and iid = u32 img (!o + 4) in
o := !o + 8;
let mc = if iid < icnt then imcnt.(iid) else 0 in
let ms = List.init mc (fun j -> Printf.sprintf "m%d" (u32 img (!o + (4 * j)))) in
o := !o + (4 * mc);
line
(Printf.sprintf "c%d i%d slots s%d.. -> [%s]" cid iid
(if iid < icnt then slot_base.(iid) else 0)
(String.concat ", " ms))
done;
(* methods *)
line "== METHODS ==";
let o = ref moff in
for i = 0 to mcnt - 1 do
let nm = u32 img !o and cid = u32 img (!o + 4) in
let argc = u8 img (!o + 8) and regc = u8 img (!o + 9) in
let reserved = u16 img (!o + 10) in
if reserved <> 0 then raise (Bad "reserved method field is not zero");
let clen = u32 img (!o + 12) in
o := !o + 16;
if clen mod 4 <> 0 then raise (Bad "code length is not a multiple of 4");
let ninstr = clen / 4 in
let code = Array.init ninstr (fun j -> u32 img (!o + (4 * j))) in
o := !o + clen;
let lcnt = u32 img !o in
o := !o + 4;
let lines = List.init lcnt (fun j -> (u32 img (!o + (8 * j)), u32 img (!o + (8 * j) + 4))) in
o := !o + (8 * lcnt);
let dcnt = u32 img !o in
o := !o + 4;
let drops =
List.init dcnt (fun j ->
let base = !o + (20 * j) in
(u32 img base, i64 img (base + 4), i64 img (base + 12)))
in
o := !o + (20 * dcnt);
line
(Printf.sprintf "m%-3d %s args=%d regs=%d %s%s" i (kname nm) argc regc
(if cid = none then "[free fn]" else Printf.sprintf "[class c%d]" cid)
(if entry = i then " [ENTRY]" else ""));
line
(Printf.sprintf " lines: %s"
(if lines = [] then "(none)"
else String.concat " " (List.map (fun (pc, l) -> Printf.sprintf "%d->%d" pc l) lines)));
if drops = [] then line " drops: (none)"
else
List.iter
(fun (pc, ow, gc) ->
line (Printf.sprintf " drops: pc %d owned=%s gc=%s" pc (mask_str ow) (mask_str gc)))
drops;
Array.iteri (fun pc ins -> line (Printf.sprintf " %04d %s" pc (ins_str ins pc))) code
done;
line "== ENTRY ==";
line (if entry = none then "(none)" else Printf.sprintf "m%d" entry);
Buffer.contents out

View file

@ -2,4 +2,4 @@
; OCaml stdlib only: no Menhir, no ppx, no opam libraries.
(library
(name woc_lib)
(modules diag token ast lexer parser types owner dump))
(modules diag token ast lexer parser types owner emit disasm dump))

2022
compiler/src/emit.ml Normal file

File diff suppressed because it is too large Load diff

View file

@ -1021,6 +1021,19 @@ and analyze_call (ctx : ctx) (call_e : Ast.expr) (callee : Ast.expr) (args : Ast
a.ac_place AExcl)
accesses;
(* transfers last *)
(* `push`'s value argument (builtin `multi_push`) stores a @gc reference
inside the container permanently — an escape exactly like a ctor
field or a `take` argument. `push` is never a resolved callee (it has
no declared params), so `conv_of` defaults it to Borrow and the
ordinary Take-gated transfer above never fires for it; without this
the container holds the reference with no matching RC_INC, and the
collector frees the value out from under the container it still sits
in. Narrow to `push`'s own value slot (index 1) and to Gc places only
— an Owned element's move-on-push is a separate, pre-existing gap
this task does not touch. *)
let is_push_gc_value i =
resolved = None && i = 1 && match callee.kind with Ident "push" -> true | _ -> false
in
List.iteri
(fun i a ->
match place_of a with
@ -1028,8 +1041,9 @@ and analyze_call (ctx : ctx) (call_e : Ast.expr) (callee : Ast.expr) (args : Ast
| Some p ->
let pname, conv = conv_of i in
if conv = Take then
if transfer ctx p ~what:(Printf.sprintf "cannot be passed to `take %s`" pname) then
record_move ctx p (MvArg pname))
(if transfer ctx p ~what:(Printf.sprintf "cannot be passed to `take %s`" pname) then
record_move ctx p (MvArg pname))
else if is_push_gc_value i && place_class ctx p = Gc then gc_escape ctx p)
args;
record_drop ctx ~node:call_e.id ~pos:call_e.pos ~kind:DLiveMask
~items:(mask_items (live_holders ctx))

View file

@ -142,11 +142,30 @@ let suggest_gc_annotation ~file (cls : class_info) (collector : Diag.Collector.t
(Diag.warning ~code:gc_suggestion_code ~file ~line:cls.pos.line ~col:cls.pos.col
~message:(Printf.sprintf "%s has recursive/shared structure that borrow checker cannot prove. Consider adding @gc if this is an ephemeral in-memory cache. If this maps to a database table, keep owned (default)." cls.name) ())
(* Ast.field_ty -> the internal resolved typ. Hoisted out of
typecheck_program (where it was a local closure) so the .wob emitter
can reach the same mapping instead of keeping a second copy of it;
the check pass still calls it under its old local name. *)
let rec typ_of_field_ty (ft : field_ty) : typ =
match ft with
| Scalar name -> TScalar name
| Ref name -> TRef name
| Multi inner_name -> TMulti (TScalar inner_name)
| Map (k_name, v_name) -> TMap (TScalar k_name, TScalar v_name)
| Nullable inner -> TNullable (typ_of_field_ty inner)
(* wob_kind_of_typ: maps internal typ to .wob field kind *)
let wob_kind_of_typ (syms : symbols) (t : typ) : wob_kind =
let kind_of = function
| TScalar name ->
if is_builtin_scalar name then WO_K_SCALAR
(* Text is NOT a plain scalar slot: a Text field holds a heap
string, and the runtime's per-kind drop plan
(runtime/src/gc.c wo_drop_kind) only frees it under
WO_K_TEXT. Emitting WO_K_SCALAR here leaked every string a
class owned. Found by the emitter, this function's first
caller. *)
if name = "Text" then WO_K_TEXT
else if is_builtin_scalar name then WO_K_SCALAR
else if is_gc_class syms name then WO_K_GCREF
else WO_K_OWNED
| TNullable _inner -> WO_K_NULLABLE
@ -177,10 +196,31 @@ let missing_nil_check_code = Diag.types_prefix ^ "13"
let unknown_type_name_code = Diag.types_prefix ^ "25" (* WO-E225 *)
(* Same-file counterpart to main.ml's cross-file WO-E214 (Task 8 review):
a duplicate class/interface/fn name declared twice *within one file*
was silently dropped by collect_declarations's StringMap.add (Task 1
review, "Known limitations" #6 -- no diagnostic at all). *)
let duplicate_decl_code = Diag.types_prefix ^ "15" (* WO-E215 *)
(* ============================================================
Pass 1: Declaration Collection
============================================================ *)
(* Reported at the *later* declaration, with the first as the related
site -- exactly WO-E214's shape. The map keeps the first declaration
(a duplicate is never added), matching WO-E214's own first-wins rule
for the merged cross-file table. *)
let report_duplicate_decl (collector : Diag.Collector.t) ~file ~(kind : string) ~(name : string)
~(pos : pos) ~(first_pos : pos) : unit =
Diag.Collector.add collector
(Diag.error ~code:duplicate_decl_code ~file ~line:pos.line ~col:pos.col
~message:(Printf.sprintf "%s `%s` already declared" kind name)
~related:
[ Diag.related_site ~file ~line:first_pos.line ~col:first_pos.col
~label:(Printf.sprintf "`%s` first declared here" name)
]
())
let collect_declarations ~file (prog : program) (collector : Diag.Collector.t) : symbols =
let classes = ref StringMap.empty in
let interfaces = ref StringMap.empty in
@ -213,8 +253,13 @@ let collect_declarations ~file (prog : program) (collector : Diag.Collector.t) :
id = c.id;
pos = c.pos;
} in
classes := StringMap.add c.name info !classes;
suggest_gc_annotation ~file info collector
(match StringMap.find_opt c.name !classes with
| Some (existing : class_info) ->
report_duplicate_decl collector ~file ~kind:"class" ~name:c.name ~pos:c.pos
~first_pos:existing.pos
| None ->
classes := StringMap.add c.name info !classes;
suggest_gc_annotation ~file info collector)
| Ast.Interface i ->
let methods = List.map (fun (m : Ast.method_sig) ->
{ name = m.name;
@ -229,7 +274,11 @@ let collect_declarations ~file (prog : program) (collector : Diag.Collector.t) :
id = i.id;
pos = i.pos;
} in
interfaces := StringMap.add i.name info !interfaces
(match StringMap.find_opt i.name !interfaces with
| Some (existing : interface_info) ->
report_duplicate_decl collector ~file ~kind:"interface" ~name:i.name ~pos:i.pos
~first_pos:existing.pos
| None -> interfaces := StringMap.add i.name info !interfaces)
| Ast.Fn f ->
let info = {
name = f.name;
@ -240,7 +289,11 @@ let collect_declarations ~file (prog : program) (collector : Diag.Collector.t) :
id = f.id;
pos = f.pos;
} in
free_fns := StringMap.add f.name info !free_fns
(match StringMap.find_opt f.name !free_fns with
| Some (existing : free_fn_info) ->
report_duplicate_decl collector ~file ~kind:"fn" ~name:f.name ~pos:f.pos
~first_pos:existing.pos
| None -> free_fns := StringMap.add f.name info !free_fns)
) prog.decls;
{ classes = !classes; interfaces = !interfaces; free_fns = !free_fns;
@ -293,14 +346,7 @@ let check_field_types ~file (syms : symbols) (collector : Diag.Collector.t)
let typecheck_program ~file (prog : program) (syms : symbols) (collector : Diag.Collector.t) : unit =
check_field_types ~file syms collector prog;
let rec resolve_field_ty (ft : field_ty) : typ =
match ft with
| Scalar name -> TScalar name
| Ref name -> TRef name
| Multi inner_name -> TMulti (TScalar inner_name)
| Map (k_name, v_name) -> TMap (TScalar k_name, TScalar v_name)
| Nullable inner -> TNullable (resolve_field_ty inner)
in
let resolve_field_ty = typ_of_field_ty in
let rec typecheck_expr (env : typ StringMap.t) (e : expr) : expr_type_result =
match e.kind with

View file

@ -0,0 +1,50 @@
== CONSTANTS ==
k0 TEXT "compute"
k1 TEXT "main"
k2 INT 0
k3 INT 7
k4 INT 3
k5 TEXT "done"
== CLASSES ==
== INTERFACES ==
== VTABLES ==
== METHODS ==
m0 compute args=2 regs=11 [free fn]
lines: 0->7 1->8 2->9 3->10 4->11 7->12 8->13 10->14 11->16 14->17 16->18 17->17 18->20 20->21 22->23
drops: (none)
0000 ADD r2, r0, r1
0001 SUB r3, r0, r1
0002 MUL r4, r2, r3
0003 DIV r5, r4, r1
0004 DIV r7, r4, r1
0005 MUL r7, r7, r1
0006 SUB r6, r4, r7
0007 NEG r7, r6
0008 EQ r8, r2, r3
0009 JZ r8, -> 0011
0010 RET r7
0011 EQ r9, r2, r3
0012 LOADK r10, k2
0013 EQ r8, r9, r10
0014 MOVE r9, r8
0015 JZ r9, -> 0018
0016 LOADK r8, k2
0017 JMP -> 0014
0018 LT r9, r3, r2
0019 JZ r9, -> 0022
0020 ADD r9, r5, r6
0021 RET r9
0022 RET r7
m1 main args=0 regs=3 [free fn] [ENTRY]
lines: 0->27 5->28 7->26
drops: (none)
0000 LOADK r1, k3
0001 LOADK r2, k4
0002 CALL r1, m0
0003 MOVE r0, r1
0004 BUILTIN r0, r0, print_int
0005 LOADK r0, k5
0006 BUILTIN r0, r0, print
0007 RET0
== ENTRY ==
m1

View file

@ -0,0 +1,29 @@
-- Arithmetic, comparison and control-flow lowering.
-- Covers the two operators the v1 instruction set has no opcode for and
-- that the emitter therefore lowers rather than inventing: `%` becomes
-- a - (a / b) * b, and `!=` becomes (a == b) == 0. `>` and `>=` reuse
-- LT/LE with the operands swapped.
fn compute(a: Int, b: Int) -> Int {
let sum = a + b
let diff = a - b
let prod = sum * diff
let quot = prod / b
let rem = prod % b
let neg = -rem
if sum == diff {
return neg
}
let changing = sum != diff
while changing {
changing = false
}
if sum > diff {
return quot + rem
}
return neg
}
fn main() {
print_int(compute(7, 3))
print("done")
}

View file

@ -0,0 +1,50 @@
== CONSTANTS ==
k0 TEXT "Cache"
k1 TEXT "Holder"
k2 TEXT "read"
k3 TEXT "proven"
k4 TEXT "main"
k5 INT 41
k6 INT 1
== CLASSES ==
c0 Cache flags=gc fields=[SCALAR]
c1 Holder flags=- fields=[GCREF]
== INTERFACES ==
== VTABLES ==
== METHODS ==
m0 read args=1 regs=2 [free fn]
lines: 0->20
drops: (none)
0000 GETF r1, r0, f0
0001 RET r1
m1 proven args=1 regs=3 [free fn]
lines: 0->24 1->25
drops: pc 1 owned={} gc={r1}
0000 GETF r1, r0, f0
0001 MOVE r2, r1
0002 CALL r2, m0
0003 RET r2
m2 main args=0 regs=6 [free fn] [ENTRY]
lines: 0->29 3->30 7->31 13->28
drops: pc 3 owned={} gc={r0}
drops: pc 7 owned={r1} gc={r0}
drops: pc 14 owned={} gc={r0}
drops: pc 15 owned={} gc={}
0000 NEW r0, c0
0001 LOADK r1, k5
0002 SETF r0, f0, r1
0003 NEW r1, c1
0004 MOVE r2, r0
0005 RC_INC r2
0006 SETF r1, f0, r2
0007 MOVE r4, r1
0008 CALL r4, m1
0009 MOVE r3, r4
0010 LOADK r5, k6
0011 ADD r2, r3, r5
0012 BUILTIN r2, r2, print_int
0013 DROP r1
0014 RC_DEC r0
0015 RET0
== ENTRY ==
m2

View file

@ -0,0 +1,32 @@
-- The zero-cost-when-provable promise, as a pinned dump.
--
-- `proven` aliases a @gc reference out of a field and hands it to a
-- function that only reads it. The owner pass proves the acquire and its
-- release balanced inside one scope (compiler/test/golden/owner/rc.wo
-- pins that as ELIDED), and nothing about the access is unprovable, so
-- the emitted body must contain NO borrow op and NO rc op at all — the
-- disassembly below is the evidence. `main` is the contrast: an escape
-- into a field is a KEPT acquire, so RC_INC does appear there.
@gc
class Cache {
hits: Int
}
class Holder {
cache: Cache
}
fn read(c: Cache) -> Int {
return c.hits
}
fn proven(h: Holder) -> Int {
let c = h.cache
return read(c)
}
fn main() {
let cache = Cache { hits: 41 }
let h = Holder { cache: cache }
print_int(proven(h) + 1)
}

View file

@ -0,0 +1,69 @@
== CONSTANTS ==
k0 TEXT "Book"
k1 TEXT "Toy"
k2 TEXT "Priced"
k3 TEXT "current_price"
k4 TEXT "quote"
k5 TEXT "main"
k6 INT 2
k7 INT 3
k8 INT 10
k9 INT 5
== CLASSES ==
c0 Book flags=- fields=[SCALAR]
c1 Toy flags=- fields=[SCALAR]
== INTERFACES ==
i0 Priced methods=1 slots=s0..s0
== VTABLES ==
c0 i0 slots s0.. -> [m0]
c1 i0 slots s0.. -> [m1]
== METHODS ==
m0 current_price args=1 regs=3 [class c0]
lines: 0->15
drops: (none)
0000 GETF r1, r0, f0
0001 LOADK r2, k6
0002 ADD r1, r1, r2
0003 RET r1
m1 current_price args=1 regs=3 [class c1]
lines: 0->23
drops: (none)
0000 GETF r1, r0, f0
0001 LOADK r2, k7
0002 MUL r1, r1, r2
0003 RET r1
m2 quote args=1 regs=2 [free fn]
lines: 0->28
drops: (none)
0000 MOVE r1, r0
0001 ICALL r1, s0
0002 RET r1
m3 main args=0 regs=4 [free fn] [ENTRY]
lines: 0->32 3->33 6->34 10->35 14->36 18->31
drops: pc 3 owned={r0} gc={}
drops: pc 6 owned={r0,r1} gc={}
drops: pc 19 owned={r0} gc={}
drops: pc 20 owned={} gc={}
0000 NEW r0, c0
0001 LOADK r1, k8
0002 SETF r0, f0, r1
0003 NEW r1, c1
0004 LOADK r2, k9
0005 SETF r1, f0, r2
0006 MOVE r3, r0
0007 CALL r3, m2
0008 MOVE r2, r3
0009 BUILTIN r2, r2, print_int
0010 MOVE r3, r1
0011 CALL r3, m2
0012 MOVE r2, r3
0013 BUILTIN r2, r2, print_int
0014 MOVE r3, r0
0015 CALL r3, m0
0016 MOVE r2, r3
0017 BUILTIN r2, r2, print_int
0018 DROP r1
0019 DROP r0
0020 RET0
== ENTRY ==
m3

View file

@ -0,0 +1,37 @@
-- Structural interface dispatch: the interface section, the global slot
-- numbering, and one vtable row per satisfying (class, interface) pair.
-- Satisfaction is structural and Go-style (no `implements` keyword by
-- doctrine), so Book and Toy each get a row purely by having the
-- method. A call through an interface-typed parameter is ICALL by global
-- slot id; a call on a known class is a direct CALL by method index.
interface Priced {
fn current_price() -> Int
}
class Book {
base: Int
fn current_price() -> Int {
return self.base + 2
}
}
class Toy {
base: Int
fn current_price() -> Int {
return self.base * 3
}
}
fn quote(p: Priced) -> Int {
return p.current_price()
}
fn main() {
let b = Book { base: 10 }
let t = Toy { base: 5 }
print_int(quote(b))
print_int(quote(t))
print_int(b.current_price())
}

View file

@ -0,0 +1,65 @@
== CONSTANTS ==
k0 TEXT "Item"
k1 TEXT "consume"
k2 TEXT "twice"
k3 TEXT "main"
k4 INT 2
k5 INT 3
k6 INT 5
k7 INT 0
== CLASSES ==
c0 Item flags=- fields=[SCALAR]
== INTERFACES ==
== VTABLES ==
== METHODS ==
m0 consume args=1 regs=2 [free fn]
lines: 0->11
drops: pc 0 owned={r0} gc={}
drops: pc 2 owned={} gc={}
0000 GETF r1, r0, f0
0001 DROP r0
0002 RET r1
m1 twice args=2 regs=6 [free fn]
lines: 0->15 3->16 5->17 8->18 13->20
drops: pc 0 owned={r0} gc={}
drops: pc 3 owned={r0,r2} gc={}
drops: pc 8 owned={r0,r2,r3} gc={}
drops: pc 10 owned={r0,r2} gc={}
drops: pc 11 owned={r0} gc={}
drops: pc 12 owned={} gc={}
drops: pc 13 owned={r0,r2} gc={}
drops: pc 14 owned={r2} gc={}
drops: pc 19 owned={} gc={}
0000 NEW r2, c0
0001 LOADK r3, k4
0002 SETF r2, f0, r3
0003 MOVE r3, r1
0004 JZ r3, -> 0013
0005 NEW r3, c0
0006 LOADK r4, k5
0007 SETF r3, f0, r4
0008 GETF r4, r3, f0
0009 DROP r3
0010 DROP r2
0011 DROP r0
0012 RET r4
0013 MOVE r4, r0
0014 CALL r4, m0
0015 MOVE r3, r4
0016 GETF r5, r2, f0
0017 ADD r3, r3, r5
0018 DROP r2
0019 RET r3
m2 main args=0 regs=4 [free fn] [ENTRY]
lines: 0->24 7->23
drops: (none)
0000 NEW r1, c0
0001 LOADK r3, k6
0002 SETF r1, f0, r3
0003 LOADK r2, k7
0004 CALL r1, m1
0005 MOVE r0, r1
0006 BUILTIN r0, r0, print_int
0007 RET0
== ENTRY ==
m2

View file

@ -0,0 +1,25 @@
-- Owned locals: the DROP placement and the drop-table masks the owner
-- pass's DROPS table dictates. `twice` has an early return out of a
-- nested scope, so its DROPs appear on both paths, and the drop table
-- shows the frame's live owned registers at every call site (which is
-- what makes a trap unwind without leaking).
class Item {
n: Int
}
fn consume(take it: Item) -> Int {
return it.n
}
fn twice(take a: Item, flag: Bool) -> Int {
let extra = Item { n: 2 }
if flag {
let inner = Item { n: 3 }
return inner.n
}
return consume(a) + extra.n
}
fn main() {
print_int(twice(Item { n: 5 }, false))
}

View file

@ -0,0 +1,156 @@
== CONSTANTS ==
k0 TEXT "Item"
k1 TEXT "Bag"
k2 TEXT "touch"
k3 TEXT "pair"
k4 TEXT "fixed"
k5 TEXT "touch3"
k6 TEXT "triple"
k7 TEXT "write_through"
k8 TEXT "main"
k9 INT 0
k10 INT 1
k11 INT 5
k12 INT 2
k13 INT 3
== CLASSES ==
c0 Item flags=- fields=[SCALAR]
c1 Bag flags=- fields=[MULTI]
== INTERFACES ==
== VTABLES ==
== METHODS ==
m0 touch args=2 regs=4 [free fn]
lines: 0->19
drops: (none)
0000 GETF r2, r0, f0
0001 GETF r3, r1, f0
0002 ADD r2, r2, r3
0003 RET r2
m1 pair args=3 regs=9 [free fn]
lines: 0->23
drops: (none)
0000 GETF r7, r0, f0
0001 MOVE r8, r1
0002 BUILTIN r5, r7, multi_get
0003 GETF r7, r0, f0
0004 MOVE r8, r2
0005 BUILTIN r6, r7, multi_get
0006 MOVE r3, r5
0007 MOVE r4, r6
0008 BORROW_X r3
0009 BORROW_X r4
0010 CALL r5, m0
0011 RELEASE_X r4
0012 RELEASE_X r3
0013 MOVE r3, r5
0014 RET r3
m2 fixed args=1 regs=5 [free fn]
lines: 0->27
drops: (none)
0000 GETF r3, r0, f0
0001 LOADK r4, k9
0002 BUILTIN r1, r3, multi_get
0003 GETF r3, r0, f0
0004 LOADK r4, k10
0005 BUILTIN r2, r3, multi_get
0006 CALL r1, m0
0007 RET r1
m3 touch3 args=3 regs=7 [free fn]
lines: 0->37
drops: (none)
0000 GETF r4, r0, f0
0001 GETF r5, r1, f0
0002 ADD r3, r4, r5
0003 GETF r6, r2, f0
0004 ADD r3, r3, r6
0005 RET r3
m4 triple args=4 regs=12 [free fn]
lines: 0->41
drops: (none)
0000 GETF r10, r0, f0
0001 MOVE r11, r1
0002 BUILTIN r7, r10, multi_get
0003 GETF r10, r0, f0
0004 MOVE r11, r2
0005 BUILTIN r8, r10, multi_get
0006 GETF r10, r0, f0
0007 MOVE r11, r3
0008 BUILTIN r9, r10, multi_get
0009 MOVE r4, r7
0010 MOVE r5, r8
0011 MOVE r6, r9
0012 BORROW_X r4
0013 BORROW_X r5
0014 BORROW_X r6
0015 CALL r7, m3
0016 RELEASE_X r6
0017 RELEASE_X r5
0018 RELEASE_X r4
0019 MOVE r4, r7
0020 RET r4
m5 write_through args=3 regs=7 [free fn]
lines: 0->49 3->50 6->51 12->52
drops: (none)
0000 GETF r4, r0, f0
0001 MOVE r5, r1
0002 BUILTIN r3, r4, multi_get
0003 GETF r5, r0, f0
0004 MOVE r6, r2
0005 BUILTIN r4, r5, multi_get
0006 LOADK r5, k11
0007 BORROW_X r4
0008 BORROW_S r3
0009 SETF r4, f0, r5
0010 RELEASE_S r3
0011 RELEASE_X r4
0012 GETF r5, r3, f0
0013 RET r5
m6 main args=0 regs=6 [free fn] [ENTRY]
lines: 0->56 3->57 8->58 13->59 18->60 24->61 28->62 35->63 41->55
drops: pc 3 owned={r0} gc={}
drops: pc 42 owned={} gc={}
0000 NEW r0, c1
0001 BUILTIN r1, kinds=0x01, multi_new
0002 SETF r0, f0, r1
0003 GETF r1, r0, f0
0004 NEW r2, c0
0005 LOADK r3, k10
0006 SETF r2, f0, r3
0007 BUILTIN r1, r1, multi_push
0008 GETF r1, r0, f0
0009 NEW r2, c0
0010 LOADK r3, k12
0011 SETF r2, f0, r3
0012 BUILTIN r1, r1, multi_push
0013 GETF r1, r0, f0
0014 NEW r2, c0
0015 LOADK r3, k13
0016 SETF r2, f0, r3
0017 BUILTIN r1, r1, multi_push
0018 MOVE r2, r0
0019 LOADK r3, k9
0020 LOADK r4, k10
0021 CALL r2, m1
0022 MOVE r1, r2
0023 BUILTIN r1, r1, print_int
0024 MOVE r2, r0
0025 CALL r2, m2
0026 MOVE r1, r2
0027 BUILTIN r1, r1, print_int
0028 MOVE r2, r0
0029 LOADK r3, k9
0030 LOADK r4, k10
0031 LOADK r5, k12
0032 CALL r2, m4
0033 MOVE r1, r2
0034 BUILTIN r1, r1, print_int
0035 MOVE r2, r0
0036 LOADK r3, k9
0037 LOADK r4, k10
0038 CALL r2, m5
0039 MOVE r1, r2
0040 BUILTIN r1, r1, print_int
0041 DROP r0
0042 RET0
== ENTRY ==
m6

View file

@ -0,0 +1,64 @@
-- The other half of the borrow story: where static proof fails, and only
-- there, the emitter wraps the region in runtime borrow ops.
--
-- `pair` takes two exclusive borrows of elements reached through runtime
-- indices, so `i == j` is unprovable (the canonical residual case from
-- the spec's section 4). One BORROW_X / RELEASE_X pair per operand —
-- coalesced per operand, never one pair per residual-table entry.
-- `fixed` is the control: literal indices are provably distinct, so it
-- gets no guards at all.
class Item {
n: Int
}
class Bag {
items: multi Item
}
fn touch(mut a: Item, mut b: Item) -> Int {
return a.n + b.n
}
fn pair(mut bag: Bag, i: Int, j: Int) -> Int {
return touch(bag.items[i], bag.items[j])
}
fn fixed(mut bag: Bag) -> Int {
return touch(bag.items[0], bag.items[1])
}
-- Three exclusive aliases in one region: the pairwise check produces
-- THREE residual entries (a-b, a-c, b-c) over THREE distinct operands.
-- Per-operand coalescing must emit 3 guard pairs; a regression to one
-- pair per table entry would emit 6 and self-trap by asking for two
-- exclusive borrows of the same object. `pair` above cannot tell those
-- two apart (one entry, two operands, 2 guards either way) — this can.
fn touch3(mut a: Item, mut b: Item, mut c: Item) -> Int {
return a.n + b.n + c.n
}
fn triple(mut bag: Bag, i: Int, j: Int, k: Int) -> Int {
return touch3(bag.items[i], bag.items[j], bag.items[k])
}
-- An assignment is its own region: owner.ml anchors the residual sites
-- it produces on the statement, not on a call. `s.n = 5` writes through
-- one alias while another is live over a runtime index, so the SETF
-- itself must be guarded.
fn write_through(mut bag: Bag, i: Int, j: Int) -> Int {
let r = bag.items[i]
let s = bag.items[j]
s.n = 5
return r.n
}
fn main() {
let bag = Bag { items: multi_new() }
push(bag.items, Item { n: 1 })
push(bag.items, Item { n: 2 })
push(bag.items, Item { n: 3 })
print_int(pair(bag, 0, 1))
print_int(fixed(bag))
print_int(triple(bag, 0, 1, 2))
print_int(write_through(bag, 0, 1))
}

View file

@ -39,6 +39,8 @@ module Parser = Woc_lib.Parser
module Dump = Woc_lib.Dump
module Types = Woc_lib.Types
module Owner = Woc_lib.Owner
module Emit = Woc_lib.Emit
module Disasm = Woc_lib.Disasm
let read_file path =
let ic = open_in_bin path in
@ -1058,6 +1060,59 @@ let () =
| [ d ] -> check "unknown type inside ?T: code is WO-E225" (d.Diag.code = "WO-E225")
| _ -> check "unknown type inside ?T: exactly one diagnostic" false
let () =
(* Same-file duplicate declarations (Task 1 review -> Task 2 fix,
WO-E215): collect_declarations folded one file's decls into a
StringMap keyed by name via a bare StringMap.add, so a second
`class`/`interface`/`fn` of the same name in the SAME file was
silently dropped -- no diagnostic at all (Task 1 report, "Known
limitations" #6). This is the front-end's own-file counterpart to
the driver's cross-file WO-E214: reported at the *later*
declaration, with the first declaration as the related site,
same WO-E2xx range, same "later primary / first related" shape. *)
let check_duplicate tag ~src ~kind ~name =
let _, collector = typecheck_str ~file:(tag ^ ".wo") src in
let diags = Diag.Collector.diagnostics collector in
check_eq (tag ^ ": exactly one diagnostic (WO-E215)") ~expected:1
~actual:(List.length diags) string_of_int;
(match diags with
| [ d ] ->
check (tag ^ ": code is WO-E215") (d.Diag.code = "WO-E215");
check (tag ^ ": severity is Error") (d.Diag.severity = Diag.Error);
check (tag ^ ": reported at the later declaration (4:1)")
(d.Diag.site.Diag.line = 4 && d.Diag.site.Diag.col = 1);
check (tag ^ ": message names the kind and the name")
(find_substring ~needle:(kind ^ " `" ^ name ^ "` already declared") d.Diag.message
<> None);
(match d.Diag.related with
| [ r ] ->
check (tag ^ ": related site points at the first declaration (1:1)")
(r.Diag.site.Diag.line = 1 && r.Diag.site.Diag.col = 1);
check (tag ^ ": related label names the first declaration")
(find_substring ~needle:"first declared here" r.Diag.label <> None)
| _ -> check (tag ^ ": exactly one related site") false)
| _ -> check (tag ^ ": exactly one diagnostic") false);
check_eq (tag ^ ": an error run exits 1") ~expected:1
~actual:(Diag.Collector.exit_code collector) string_of_int
in
check_duplicate "duplicate class" ~kind:"class" ~name:"Dup"
~src:"class Dup {\n n: Int\n}\nclass Dup {\n s: Text\n}\n";
check_duplicate "duplicate interface" ~kind:"interface" ~name:"Shape"
~src:"interface Shape {\n fn area() -> Int\n}\ninterface Shape {\n fn perimeter() -> Int\n}\n";
check_duplicate "duplicate fn" ~kind:"fn" ~name:"double"
~src:"fn double(x: Int) -> Int {\n return x + x\n}\nfn double(y: Int) -> Int {\n return y * 2\n}\n"
let () =
(* Control: a class and a fn sharing a name are different namespaces
(collect_declarations keeps them in separate StringMaps) -- must
NOT trip WO-E215. *)
let _, collector =
typecheck_str ~file:"cross-namespace.wo"
"class Widget {\n n: Int\n}\nfn Widget() -> Int {\n return 1\n}\n"
in
check_eq "class/fn name sharing across namespaces: reports nothing" ~expected:0
~actual:(List.length (Diag.Collector.diagnostics collector)) string_of_int
(* ---- direct ownership-pass assertions (Task 7) ------------------------
golden/owner-err/ already pins the *rendered* text of every must-fail
@ -1077,6 +1132,19 @@ let owner_str ~file src =
let tables = Owner.analyze ~file prog syms collector in
(tables, collector)
(* The whole pipeline through the emitter, single-file (every golden
fixture is one file). Returns the serialized image plus the collector,
so a test can assert on the bytes, on the disassembly, or on the
diagnostics. *)
let emit_str ~file src =
let collector = Diag.Collector.create () in
let toks = Lexer.tokenize collector ~file src in
let prog = Parser.parse collector ~file toks in
let syms, () = Types.typecheck ~file prog collector in
let tables = Owner.analyze ~file prog syms collector in
let image = Emit.emit ~syms collector [ { Emit.file; prog; tables } ] in
(image, collector)
let is_ownership_code (code : string) =
String.length code >= 5 && String.sub code 0 5 = Diag.ownership_prefix
@ -1469,6 +1537,681 @@ let () =
check "cli smoke: stdout still carries the tables on exit 1"
(Option.is_some (find_substring ~needle:"== RESIDUAL ==" stdout))
(* ---- the loader's validation battery, re-implemented -------------------
The plan's round-trip rule: an image `woc` produces that `wovm`'s
loader rejects is always an emitter bug. Running the C binary from
here would make this suite depend on the runtime being built, so the
rule is enforced by a third, independent decoder of the same format
(runtime/test/wob_build.c is the second: an independent encoder of the
same format).
Every check below is the OCaml twin of a BAIL in
runtime/src/loader.c's wo_load_buf, in the same order, so a divergence
between the emitter and the loader surfaces in `dune runtest` rather
than in the corpus. Returns the list of violations; [] means the
loader would accept the image. *)
let validate_image (img : string) : string list =
let bad = ref [] in
let fail fmt = bad := fmt :: !bad in
let len = String.length img in
let ok n o = o >= 0 && o + n <= len in
let u8 o = if ok 1 o then String.get_uint8 img o else -1 in
let u16 o = if ok 2 o then String.get_uint16_le img o else -1 in
let u32 o = if ok 4 o then Int32.to_int (String.get_int32_le img o) land 0xFFFFFFFF else -1 in
let u64 o = if ok 8 o then String.get_int64_le img o else 0L in
let none = 0xFFFFFFFF in
if u32 0 <> 0x31424F57 then fail "bad magic";
if u32 4 <> 1 then fail "unsupported version";
let coff = u32 8 and ccnt = u32 12 in
let koff = u32 16 and kcnt = u32 20 in
let ioff = u32 24 and icnt = u32 28 in
let moff = u32 32 and mcnt = u32 36 in
let entry = u32 40 in
List.iter
(fun o -> if o > len then fail "section offset out of range")
[ coff; koff; ioff; moff ];
(* constants *)
let ctag = Array.make (max ccnt 1) (-1) in
let o = ref coff in
for i = 0 to ccnt - 1 do
let tag = u8 !o in
incr o;
ctag.(i) <- tag;
if tag = 0 then o := !o + 8
else if tag = 1 then begin
let n = u32 !o in
o := !o + 4;
if not (ok n !o) then fail (Printf.sprintf "constant %d: text overruns" i);
o := !o + n
end
else fail (Printf.sprintf "constant %d: unknown tag %d" i tag)
done;
if !o > len then fail "constant pool overruns image";
let text_const i = i >= 0 && i < ccnt && ctag.(i) = 1 in
(* classes *)
let class_fields = Array.make (max kcnt 1) 0 in
let o = ref koff in
for i = 0 to kcnt - 1 do
let nm = u32 !o and flags = u32 (!o + 4) and fcnt = u32 (!o + 8) in
o := !o + 12;
if not (text_const nm) then fail (Printf.sprintf "class %d: bad name constant" i);
if flags land lnot 0x01 <> 0 then fail (Printf.sprintf "class %d: unknown flags" i);
if fcnt > 65535 then fail (Printf.sprintf "class %d: too many fields" i);
class_fields.(i) <- fcnt;
for j = 0 to fcnt - 1 do
if u8 (!o + j) > 5 then fail (Printf.sprintf "class %d field %d: bad kind" i j)
done;
o := !o + fcnt + ((4 - (fcnt mod 4)) mod 4);
if !o > len then fail (Printf.sprintf "class %d: truncated" i)
done;
(* interfaces + vtable rows *)
let slot_base = Array.make (max icnt 1) 0 in
let imcnt = Array.make (max icnt 1) 0 in
let slots = ref 0 in
let o = ref ioff in
for i = 0 to icnt - 1 do
let nm = u32 !o and mc = u32 (!o + 4) in
o := !o + 8;
if not (text_const nm) then fail (Printf.sprintf "interface %d: bad name constant" i);
if mc = 0 || mc > 1024 then fail (Printf.sprintf "interface %d: bad method count" i);
slot_base.(i) <- !slots;
imcnt.(i) <- mc;
slots := !slots + mc
done;
let vrows = u32 !o in
o := !o + 4;
let seen_rows = Hashtbl.create 8 in
let vmethods = ref [] in
for r = 0 to vrows - 1 do
let cid = u32 !o and iid = u32 (!o + 4) in
o := !o + 8;
if cid >= kcnt then fail (Printf.sprintf "vtable row %d: bad class" r);
if iid >= icnt then fail (Printf.sprintf "vtable row %d: bad interface" r)
else
for j = 0 to imcnt.(iid) - 1 do
let m = u32 (!o + (4 * j)) in
vmethods := m :: !vmethods;
let key = (cid, slot_base.(iid) + j) in
if Hashtbl.mem seen_rows key then
fail (Printf.sprintf "duplicate vtable entry for class %d" cid);
Hashtbl.replace seen_rows key ()
done;
if iid < icnt then o := !o + (4 * imcnt.(iid))
done;
(* methods *)
let margc = Array.make (max mcnt 1) 0 in
let mregc = Array.make (max mcnt 1) 0 in
let mclass = Array.make (max mcnt 1) 0 in
let mcode = Array.make (max mcnt 1) [||] in
let o = ref moff in
for i = 0 to mcnt - 1 do
let nm = u32 !o and cid = u32 (!o + 4) in
let argc = u8 (!o + 8) and regc = u8 (!o + 9) and reserved = u16 (!o + 10) in
let clen = u32 (!o + 12) in
o := !o + 16;
if not (text_const nm) then fail (Printf.sprintf "method %d: bad name constant" i);
if cid <> none && cid >= kcnt then fail (Printf.sprintf "method %d: bad class" i);
if reserved <> 0 then fail (Printf.sprintf "method %d: reserved field not zero" i);
if regc < 1 || regc > 64 then fail (Printf.sprintf "method %d: register count out of range" i);
if argc > regc then fail (Printf.sprintf "method %d: more args than registers" i);
if clen = 0 || clen mod 4 <> 0 then fail (Printf.sprintf "method %d: bad code length" i);
let ninstr = clen / 4 in
let code = Array.init (max ninstr 0) (fun j -> u32 (!o + (4 * j))) in
o := !o + clen;
margc.(i) <- argc;
mregc.(i) <- regc;
mclass.(i) <- cid;
mcode.(i) <- code;
let lcnt = u32 !o in
o := !o + 4;
if lcnt > ninstr then fail (Printf.sprintf "method %d: line table too long" i);
let prev = ref (-1) in
for j = 0 to lcnt - 1 do
let pc = u32 (!o + (8 * j)) in
if pc >= ninstr || pc <= !prev then
fail (Printf.sprintf "method %d: line table not ascending" i);
prev := pc
done;
o := !o + (8 * lcnt);
let dcnt = u32 !o in
o := !o + 4;
if dcnt > ninstr then fail (Printf.sprintf "method %d: drop table too long" i);
let prev = ref (-1) in
for j = 0 to dcnt - 1 do
let base = !o + (20 * j) in
let pc = u32 base in
let owned = u64 (base + 4) and gc = u64 (base + 12) in
if pc >= ninstr || pc <= !prev then
fail (Printf.sprintf "method %d: drop table not ascending" i);
prev := pc;
if regc < 64 && Int64.shift_right_logical (Int64.logor owned gc) regc <> 0L then
fail (Printf.sprintf "method %d: drop mask out of range" i)
done;
o := !o + (20 * dcnt)
done;
if !o > len then fail "method table overruns image";
(* static instruction validation *)
for i = 0 to mcnt - 1 do
let regc = mregc.(i) in
let code = mcode.(i) in
let ninstr = Array.length code in
let rchk pc r =
if r < 0 || r >= regc then
fail (Printf.sprintf "method %d pc %d: register out of range" i pc)
in
Array.iteri
(fun pc ins ->
let op = ins land 0xFF in
let a = (ins lsr 8) land 0xFF in
let b = (ins lsr 16) land 0xFF in
let c = (ins lsr 24) land 0xFF in
let bx = (ins lsr 16) land 0xFFFF in
let sbx = bx - 32768 in
match op with
| 0 -> ()
| 1 ->
rchk pc a;
if bx >= ccnt then fail (Printf.sprintf "method %d pc %d: constant out of range" i pc)
| 2 | 7 ->
rchk pc a;
rchk pc b
| 3 | 4 | 5 | 6 | 8 | 9 | 10 | 11 | 12 ->
rchk pc a;
rchk pc b;
rchk pc c
| 13 | 14 ->
if op = 14 then rchk pc a;
let tgt = pc + 1 + sbx in
if tgt < 0 || tgt >= ninstr then
fail (Printf.sprintf "method %d pc %d: jump out of code" i pc)
| 15 ->
rchk pc a;
if bx >= mcnt then fail (Printf.sprintf "method %d pc %d: callee out of range" i pc)
else if a + margc.(bx) > regc then
fail (Printf.sprintf "method %d pc %d: call window exceeds frame" i pc)
| 16 ->
rchk pc a;
if bx >= !slots then
fail (Printf.sprintf "method %d pc %d: interface slot out of range" i pc)
| 17 -> rchk pc a
| 18 -> ()
| 19 ->
rchk pc a;
if bx >= kcnt then fail (Printf.sprintf "method %d pc %d: class out of range" i pc)
| 20 ->
rchk pc a;
rchk pc b
| 21 ->
rchk pc a;
rchk pc c
| 22 | 23 | 24 | 25 | 26 | 27 | 28 -> rchk pc a
| 29 ->
rchk pc a;
if c > 12 then fail (Printf.sprintf "method %d pc %d: builtin out of range" i pc)
else if c = 4 then begin
if b > 5 then fail (Printf.sprintf "method %d pc %d: bad element kind" i pc)
end
else if c = 9 then begin
if b land 0x0F > 5 || b lsr 4 > 5 then
fail (Printf.sprintf "method %d pc %d: bad key/value kind" i pc)
end
else begin
let arity =
match c with
| 0 -> 0
| 1 | 2 | 3 | 7 | 8 -> 1
| 5 | 6 | 11 | 12 -> 2
| 10 -> 3
| _ -> 0
in
if arity > 0 then begin
rchk pc b;
rchk pc (b + arity - 1)
end
end
| 30 | 31 -> ()
| _ -> fail (Printf.sprintf "method %d pc %d: unknown opcode %d" i pc op))
code;
if ninstr > 0 then begin
let last = code.(ninstr - 1) land 0xFF in
if not (last = 17 || last = 18 || last = 31 || last = 30 || last = 13) then
fail (Printf.sprintf "method %d: last instruction is not a terminator" i)
end
done;
List.iter
(fun m -> if m >= mcnt then fail "vtable entry: method out of range")
!vmethods;
if entry <> none then begin
if entry >= mcnt then fail "entry method out of range"
else if margc.(entry) <> 0 || mclass.(entry) <> none then
fail "entry must be a zero-arg free fn"
end;
List.rev !bad
(* ---- emitter assertions (Task 1, not golden-diffed) --------------------
golden/bc/*.wo pin the disassembly; these pin what a dump cannot show:
that every emitted image satisfies the loader's contract, that the
elision fixture really contains no borrow/rc op at all, that a
residual guard never lives in a call window, and that an over-budget
method diagnoses instead of truncating. *)
let bc_fixtures () =
let dir = "golden/bc" in
Sys.readdir dir |> Array.to_list
|> List.filter (fun n -> Filename.check_suffix n ".wo")
|> List.sort compare
|> List.map (fun n -> (dir ^ "/" ^ n, read_file (Filename.concat dir n)))
let () =
List.iter
(fun (path, src) ->
let image, collector = emit_str ~file:path src in
check_eq (Printf.sprintf "emit %s: compiles clean" path) ~expected:0
~actual:(List.length (Diag.Collector.diagnostics collector))
string_of_int;
let violations = validate_image image in
check_eq
(Printf.sprintf "round trip %s: the loader's battery accepts the image (%s)" path
(String.concat "; " violations))
~expected:0 ~actual:(List.length violations) string_of_int)
(bc_fixtures ())
(* Every method's block of a disassembly, keyed by the method name as the
dump writes it ("m3 pair args=..."). *)
let method_block (dump : string) (name : string) : string =
let lines = String.split_on_char '\n' dump in
let is_header l =
String.length l > 1 && l.[0] = 'm' && l.[1] >= '0' && l.[1] <= '9'
in
let wanted l = is_header l && find_substring ~needle:(" " ^ name ^ " args=") l <> None in
let rec collect acc inside = function
| [] -> List.rev acc
| l :: tl ->
if wanted l then collect (l :: acc) true tl
else if inside && (is_header l || (String.length l > 1 && l.[0] = '=')) then List.rev acc
else if inside then collect (l :: acc) true tl
else collect acc false tl
in
String.concat "\n" (collect [] false lines)
let () =
(* The spec's zero-cost promise, as an assertion and not only a pinned
dump: a method whose ownership is fully proven contains no borrow op
and no rc op. golden/bc/elision.wo's `proven` aliases a @gc
reference and passes it to a reader; owner.ml marks the pair ELIDED
(golden/owner/rc.wo pins that), so nothing may be emitted for it. *)
let path = "golden/bc/elision.wo" in
let image, _ = emit_str ~file:path (read_file path) in
let block = method_block (Disasm.dump image) "proven" in
check "elision: `proven` was found in the disassembly" (block <> "");
List.iter
(fun op ->
check
(Printf.sprintf "elision: `proven` emits no %s (zero-cost when provable)" op)
(find_substring ~needle:op block = None))
[ "BORROW_S"; "BORROW_X"; "RELEASE_S"; "RELEASE_X"; "RC_INC"; "RC_DEC" ];
(* the contrast, so the fixture cannot pass by emitting nothing anywhere:
main stores the @gc value into a field, which is a KEPT acquire *)
let main_block = method_block (Disasm.dump image) "main" in
check "elision: the escaping acquire in `main` is still emitted (fixture is not vacuous)"
(find_substring ~needle:"RC_INC" main_block <> None)
let () =
(* Residual guards: one coalesced pair per operand, and — the
regression this pins — never on a register inside the call window.
The callee's frame overlaps that window (it may assign to its own
parameters) and the call's return value lands on the window base, so
releasing a window register hands wo_release_excl whatever now sits
there. *)
let path = "golden/bc/residual.wo" in
let image, _ = emit_str ~file:path (read_file path) in
let dump = Disasm.dump image in
let block = method_block dump "pair" in
let count needle s =
let rec go i n =
if i >= String.length s then n
else
match find_substring ~needle (String.sub s i (String.length s - i)) with
| None -> n
| Some k -> go (i + k + String.length needle) (n + 1)
in
go 0 0
in
check_eq "residual: `pair` acquires exactly two exclusive guards" ~expected:2
~actual:(count "BORROW_X" block) string_of_int;
check_eq "residual: and releases exactly two" ~expected:2
~actual:(count "RELEASE_X" block) string_of_int;
check "residual: `fixed` (literal indexes, provably distinct) gets no guard at all"
(find_substring ~needle:"BORROW" (method_block dump "fixed") = None);
(* the guard registers and the CALL's window base must be disjoint *)
let regs_of prefix =
String.split_on_char '\n' block
|> List.filter_map (fun l ->
match find_substring ~needle:prefix l with
| None -> None
| Some _ -> (
match find_substring ~needle:"r" (String.trim l) with
| None -> None
| Some _ ->
let l = String.trim l in
let i = ref 0 in
while !i < String.length l && l.[!i] <> 'r' do
incr i
done;
(* skip the mnemonic's own letters up to the operand *)
let rec next_reg j =
if j >= String.length l then None
else if l.[j] = 'r' && j + 1 < String.length l && l.[j + 1] >= '0'
&& l.[j + 1] <= '9' then begin
let k = ref (j + 1) in
while !k < String.length l && l.[!k] >= '0' && l.[!k] <= '9' do
incr k
done;
Some (int_of_string (String.sub l (j + 1) (!k - j - 1)))
end
else next_reg (j + 1)
in
next_reg (String.length prefix)))
in
let guards = regs_of "RELEASE_X" and windows = regs_of "CALL" in
check "residual: no guard register is the call window's base register"
(List.for_all (fun g -> not (List.mem g windows)) guards)
let () =
(* Over-budget: 70 owned locals cannot fit the VM's 64-register window,
so the method must diagnose WO-E401 rather than emit a truncated
frame. Generated rather than a fixture file: the point is the count,
and 70 hand-written lines would pin nothing extra. *)
let buf = Buffer.create 1024 in
Buffer.add_string buf "class Item {\n n: Int\n}\n\nfn wide() -> Int {\n";
for i = 0 to 69 do
Buffer.add_string buf (Printf.sprintf " let v%d = Item { n: %d }\n" i i)
done;
Buffer.add_string buf " return 0\n}\n";
let _, collector = emit_str ~file:"wide.wo" (Buffer.contents buf) in
let diags = Diag.Collector.diagnostics collector in
check_eq "register budget: exactly one diagnostic (reported once per method)" ~expected:1
~actual:(List.length diags) string_of_int;
match diags with
| [ d ] ->
check "register budget: the code is WO-E401" (d.Diag.code = "WO-E401");
check "register budget: it is an error, not a warning" (d.Diag.severity = Diag.Error)
| _ -> check "register budget: diagnostic shape" false
let () =
(* The emitter's own view of liveness must agree with the owner pass's
LIVE-MASK entries: for every call site the table names, the drop
table must carry an entry at some pc whose owned/gc masks hold
exactly as many registers as the table listed items. A drift here
means the emitter stopped consuming the table it is contracted to. *)
let path = "golden/bc/owned.wo" in
let src = read_file path in
let tables, _ = owner_str ~file:path src in
let masks =
List.filter_map
(fun (d : Owner.drop_site) ->
match d.Owner.dr_kind with
| Owner.DLiveMask -> Some (List.length d.Owner.dr_items)
| _ -> None)
tables.Owner.drops
in
let image, _ = emit_str ~file:path src in
let dump = Disasm.dump image in
let popcounts =
String.split_on_char '\n' dump
|> List.filter_map (fun l ->
if find_substring ~needle:" drops: pc" l = None then None
else
Some
(List.length
(List.filter
(fun part -> String.length part > 0 && part.[0] = 'r')
(String.split_on_char ','
(String.concat ""
(String.split_on_char '{'
(String.concat "" (String.split_on_char '}' l))))))))
in
check "live masks: the owner table lists at least one call-site mask for owned.wo"
(masks <> []);
check "live masks: the emitted drop table carries entries whose widest mask matches the \
table's widest LIVE-MASK"
(List.fold_left max 0 masks <= List.fold_left max 0 popcounts)
(* The ownership tables are a contract, not a hint: every DROP the DROPS
table asks for, and every rc op the RC table does not mark ELIDED, has
to appear in the emitted code exactly once — and nothing else may. A
count identity over a whole file is the cheapest way to state that, and
it is what caught a missing constructor-field @gc acquire (the escape
increment is anchored on the value's expression node, so lowering it
per statement kind silently skipped one of the four escapes). *)
let () =
let count_op needle dump =
String.split_on_char '\n' dump
|> List.filter (fun l -> find_substring ~needle:(" " ^ needle) l <> None)
|> List.length
in
List.iter
(fun path ->
let src = read_file path in
let tables, coll = owner_str ~file:path src in
if not (Diag.Collector.has_error coll) then begin
let want_drops =
List.fold_left
(fun n (d : Owner.drop_site) ->
match d.Owner.dr_kind with
| Owner.DLiveMask -> n
| Owner.DScope _ | Owner.DReturn | Owner.DOverwrite | Owner.DBranchJoin _ ->
n
+ List.length
(List.filter
(fun (i : Owner.drop_item) -> i.Owner.di_kind = Owner.LOwned)
d.Owner.dr_items))
0 tables.Owner.drops
in
let kept op =
List.length
(List.filter
(fun (r : Owner.rc_site) -> r.Owner.rc_op = op && not r.Owner.rc_elided)
tables.Owner.rcs)
in
let image, _ = emit_str ~file:path src in
let dump = Disasm.dump image in
check_eq
(Printf.sprintf "table contract %s: one DROP per owned drop-table item" path)
~expected:want_drops ~actual:(count_op "DROP " dump) string_of_int;
check_eq
(Printf.sprintf "table contract %s: one RC_INC per KEPT acquire" path)
~expected:(kept Owner.RcAcquire) ~actual:(count_op "RC_INC" dump) string_of_int;
check_eq
(Printf.sprintf "table contract %s: one RC_DEC per KEPT release" path)
~expected:(kept Owner.RcRelease) ~actual:(count_op "RC_DEC" dump) string_of_int;
(* The residual table is both the only licence to emit a borrow
op and an obligation to emit one per *operand*: guards are
coalesced per operand, never per entry (asking twice for an
exclusive borrow of one object self-traps on legal code). The
identity is computed here from the raw table, independently of
emit.ml's own coalescing — a region the emitter forgot to
consume, or one it expanded per entry, both fail it. *)
let operands =
let by_region = Hashtbl.create 8 in
List.iter
(fun (r : Owner.residual_site) ->
let cur = try Hashtbl.find by_region r.Owner.rs_node with Not_found -> [] in
let cur =
List.sort_uniq compare (r.Owner.rs_a_node :: r.Owner.rs_b_node :: cur)
in
Hashtbl.replace by_region r.Owner.rs_node cur)
tables.Owner.residuals;
Hashtbl.fold (fun _ ops n -> n + List.length ops) by_region 0
in
check_eq
(Printf.sprintf "table contract %s: one borrow acquire per coalesced residual operand"
path)
~expected:operands
~actual:(count_op "BORROW_S" dump + count_op "BORROW_X" dump)
string_of_int;
check_eq
(Printf.sprintf "table contract %s: one release per coalesced residual operand" path)
~expected:operands
~actual:(count_op "RELEASE_S" dump + count_op "RELEASE_X" dump)
string_of_int
end)
[ "golden/bc/owned.wo"; "golden/bc/elision.wo"; "golden/bc/residual.wo";
"golden/bc/iface.wo"; "golden/owner/moves.wo"; "golden/owner/drops.wo";
"golden/owner/rc.wo"; "golden/owner/residual.wo" ]
(* Shapes that produce a loadable image, one per lowering the goldens do
not already cover, plus the two round-trip regressions found while
building this task: a forward jump out of the *last* `if`/`while` of a
body targets the position after the final instruction (the loader
reads that as "jump out of code", so the implicit return has to be
appended for that reason too), and a call whose argument count differs
from the callee's reserves the wrong window (the loader's "call window
exceeds frame"). Each case is emitted and run through the loader's
battery — the cheap way to keep the round-trip rule honest for
lowerings no fixture file happens to exercise. *)
let () =
let cases =
[ ( "jump target at end of code",
"fn f(flag: Bool) {\n if flag {\n return\n }\n}\n" );
("while at end of body", "fn f(flag: Bool) {\n while flag {\n flag = false\n }\n}\n");
( "for over a multi",
"class Item {\n n: Int\n}\n\nclass Bag {\n items: multi Item\n}\n\n\
fn total(bag: Bag) -> Int {\n let sum = 0\n for it in bag.items {\n\
\ sum = sum + it.n\n }\n return sum\n}\n" );
( "map builtins",
"class Index {\n by_name: map<Text, Int>\n}\n\nfn f() -> Int {\n\
\ let idx = Index { by_name: map_new() }\n set(idx.by_name, \"a\", 1)\n\
\ if has(idx.by_name, \"a\") {\n return get(idx.by_name, \"a\")\n }\n\
\ return 0\n}\n" );
( "text: concat, equality, words",
"fn f(a: Text, b: Text) -> Int {\n let joined = a .. b\n\
\ if joined == a {\n return 1\n }\n return words(joined)\n}\n" );
("db stub statement", "fn f() -> Int {\n insert into rows values (1)\n return 0\n}\n");
( "nested calls in arguments",
"fn one() -> Int {\n return 1\n}\n\nfn add(a: Int, b: Int) -> Int {\n\
\ return a + b\n}\n\nfn f() -> Int {\n return add(add(one(), one()), one())\n}\n" );
( "method call on a class instance",
"class Counter {\n n: Int\n\n fn bump(by: Int) -> Int {\n\
\ return self.n + by\n }\n}\n\nfn f() -> Int {\n\
\ let c = Counter { n: 1 }\n return c.bump(2)\n}\n" )
]
in
List.iter
(fun (name, src) ->
let file = name ^ ".wo" in
let image, collector = emit_str ~file src in
let diags = Diag.Collector.diagnostics collector in
check
(Printf.sprintf "lowering %s: compiles clean (%s)" name
(String.concat ", " (List.map (fun (d : Diag.t) -> d.Diag.code ^ ": " ^ d.Diag.message) diags)))
(diags = []);
let violations = validate_image image in
check
(Printf.sprintf "lowering %s: the loader's battery accepts the image (%s)" name
(String.concat "; " violations))
(violations = []))
cases
let () =
(* Arity is the emitter's business because nothing upstream checks it:
types.ml declares WO-E203 and never raises it. An unchecked call
would reserve a window the callee does not read — the loader rejects
it, which by the round-trip rule would be an emitter bug. *)
let _, collector =
emit_str ~file:"arity.wo"
"fn add3(a: Int, b: Int, c: Int) -> Int {\n return a + b + c\n}\n\n\
fn main() {\n print_int(add3(1))\n}\n"
in
let diags = Diag.Collector.diagnostics collector in
check_eq "arity: exactly one diagnostic" ~expected:1 ~actual:(List.length diags) string_of_int;
match diags with
| [ d ] ->
check "arity: reported as WO-E403 (a call the emitter cannot lower)" (d.Diag.code = "WO-E403");
check "arity: the message names both counts"
(find_substring ~needle:"takes 3 argument(s), given 1" d.Diag.message <> None)
| _ -> check "arity: diagnostic shape" false
let () =
(* WO-E405: the entry (`fn main()`, zero args) must declare `Int` or
nothing at all -- the systems-track spec makes its return value the
process exit code. A `@gc` return escaping through it is exactly
the leak this diagnostic exists to close (docs/plan/oop-vm's
error-catalog entry): the driver has no way to release a pointer
it receives with no return-kind metadata to consult. *)
let _, collector =
emit_str ~file:"entry-not-int.wo"
"class Widget {\n n: Int\n}\n\nfn main() -> Widget {\n return Widget { n: 1 }\n}\n"
in
let diags = Diag.Collector.diagnostics collector in
check_eq "entry return type: exactly one diagnostic" ~expected:1 ~actual:(List.length diags)
string_of_int;
(match diags with
| [ d ] ->
check "entry return type: reported as WO-E405" (d.Diag.code = "WO-E405");
check "entry return type: the message names the declared type and the exit-code contract"
(find_substring ~needle:"`Widget`" d.Diag.message <> None
&& find_substring ~needle:"process exit code" d.Diag.message <> None
&& find_substring ~needle:"must return `Int`" d.Diag.message <> None)
| _ -> check "entry return type: diagnostic shape" false);
(* control: no return annotation at all is not "anything other than
Int" -- it is the shape every other fixture in this suite uses,
and must stay clean. *)
let _, clean_collector =
emit_str ~file:"entry-no-annotation.wo" "fn main() {\n print_int(0)\n}\n"
in
check "entry return type: `main` with no return annotation compiles clean"
(Diag.Collector.diagnostics clean_collector = [])
(* ---- CLI smoke: emit mode and --dump-bc ------------------------------ *)
let () =
let path = "golden/bc/arith.wo" in
let exit_code, stdout, stderr = run_cli [ "--dump-bc"; path ] in
check "cli smoke: --dump-bc on a clean file exits 0" (exit_code = 0);
check "cli smoke: clean-file --dump-bc writes nothing to stderr" (stderr = "");
let image, _ = emit_str ~file:path (read_file path) in
check "cli smoke: --dump-bc stdout matches the in-process disassembly exactly"
(stdout = Disasm.dump image)
let () =
(* Unlike the other dumps, --dump-bc prints nothing when the compile is
not clean: a disassembly of a program that failed to compile
describes bytecode nobody is allowed to run. *)
let exit_code, stdout, stderr = run_cli [ "--dump-bc"; "golden/owner-err/use-after-move.wo" ] in
check "cli smoke: --dump-bc on a failing compile exits 1" (exit_code = 1);
check "cli smoke: the WO-E301 diagnostic still goes to stderr"
(find_substring ~needle:"WO-E301" stderr <> None);
check "cli smoke: --dump-bc prints no bytecode for a program that did not compile"
(stdout = "")
let () =
let out = Filename.temp_file "woc_emit" ".wob" in
let exit_code, stdout, stderr = run_cli [ "--emit"; "golden/bc/iface.wo"; "-o"; out ] in
check "cli smoke: --emit exits 0 on a clean program" (exit_code = 0);
check "cli smoke: --emit prints nothing on stdout" (stdout = "");
check "cli smoke: --emit prints nothing on stderr" (stderr = "");
let image = read_file out in
check "cli smoke: the written image is a WOB1 v1 file"
(String.length image > 44 && String.sub image 0 4 = "WOB1");
check_eq "cli smoke: the written image passes the loader's battery" ~expected:0
~actual:(List.length (validate_image image)) string_of_int;
(try Sys.remove out with Sys_error _ -> ());
(* a failing compile must leave no image behind *)
let out2 = Filename.temp_file "woc_emit" ".wob" in
Sys.remove out2;
let exit_code, _, _ = run_cli [ "--emit"; "golden/owner-err/use-after-move.wo"; "-o"; out2 ] in
check "cli smoke: --emit on a failing compile exits 1" (exit_code = 1);
check "cli smoke: and writes no image at all" (not (Sys.file_exists out2));
(try Sys.remove out2 with Sys_error _ -> ())
let () =
let exit_code, _, stderr = run_cli [ "--emit"; "golden/bc/arith.wo" ] in
check "cli smoke: --emit without -o is a usage error (exit 2)" (exit_code = 2);
check "cli smoke: usage goes to stderr" (stderr <> "")
(* ---- golden-directory walk ------------------------------------------ *)
(* Each stage directory under golden/ names one `woc --dump-*` flag.
@ -1499,6 +2242,17 @@ let run_stage ~stage ~file ~src : string =
let _, collector = owner_str ~file src in
let lookup f = if f = file then Some src else None in
Diag.Collector.render_all collector lookup ^ "\n"
| "bc" ->
(* the emitter's own stage: the whole front end, then the `.wob`
image, then its disassembly. The dump is produced from the
serialized bytes (compiler/src/disasm.ml), so a golden here pins
the emitted layout, not just the emitter's intentions. *)
let image, collector = emit_str ~file src in
if Diag.Collector.has_error collector then begin
let lookup f = if f = file then Some src else None in
"EMIT FAILED\n" ^ Diag.Collector.render_all collector lookup ^ "\n"
end
else Disasm.dump image
| other ->
failwith (Printf.sprintf "runner: unknown golden stage directory %S" other)

View file

@ -15,22 +15,19 @@ Statuses: ✅ **done** · 🔄 **in progress** · ⬜ **pending** · ⏸ **parke
## ▶ NEXT PLAN
**Story iteration 4 — single binary end-to-end.**
Plan: [`compiler/plan/2026-08-01-wob-emit-e2e-single-binary.md`](plan/compiler/2026-08-01-wob-emit-e2e-single-binary.md) ·
Story slice: [`docs/stories/language-runtime-database/04-single-binary-e2e.md`](stories/language-runtime-database/04-single-binary-e2e.md)
**Story iteration 5 — language surface (Haxe-parity adoptions).**
Plan: [`plan/compiler/2026-08-01-haxe-parity-language.md`](plan/compiler/2026-08-01-haxe-parity-language.md) ·
Story slice: [`docs/stories/language-runtime-database/05-language-surface.md`](stories/language-runtime-database/05-language-surface.md)
The bytecode emitter, the three-kind conformance corpus, and `woc build`. This
is the milestone where `.wo` source becomes a running self-contained binary —
compiler front (iteration 3) and VM core (iteration 2) both shipped, so it is
unblocked. Its inputs are the four ownership tables `owner.ml` now produces;
`dump.ml`'s format-contract comments are normative for it, **including the
requirement to coalesce borrow guards per operand**.
Iteration 4 proves the pipeline on the **milestone grammar only** — the
emitter, corpus, and `woc build` exercise the grammar iteration 3 already
compiles, not the log-watcher sample, which still needs ~200 constructs the
front end cannot yet parse (iterations 5–6 work). Iteration 4 must not be
judged against the sample (gap-closure spec, §6).
The language grows from milestone grammar to a daily-driver surface: every
**adopt** row of the systems-track verdict table (switch expressions, typedef
records, `?T` optionals, enum payloads, try/catch, statics, `using`, modules,
`is`, `pub(read)`, `#if`) lands with a golden + must-fail fixture pair; every
**reject** row refuses with a doctrine-citing diagnostic. **First task: `?T`
forced handling (plan 8 Task 6)** — plumbed since iteration 3 but unenforced
(`WO-E211`–`E213` dead), and the log-watcher port (iterations 6–7) uses
optionals throughout in place of the Haxe original's sentinel values, so
nothing else in this plan can land ahead of it.
Two tracks run in this repo. The critical path is the **language track**:
iterations 3 → 4 → 5 → 6 → 7, ending at *compile and run log-watcher*. The
@ -50,12 +47,14 @@ that sequences its tasks. Read one, approve, then the next starts.
| 1 | [Principles doc](stories/language-runtime-database/01-principles-doc.md) | ✅ |
| 2 | [VM core (`wovm`)](stories/language-runtime-database/02-vm-core.md) | ✅ |
| 3 | [Compiler front (`woc`)](stories/language-runtime-database/03-compiler-front.md) | ✅ (known gaps below) |
| 4 | [Single binary end-to-end](stories/language-runtime-database/04-single-binary-e2e.md) | 🔄 **next** |
| 5 | [Language surface](stories/language-runtime-database/05-language-surface.md) | ⬜ |
| 4 | [Single binary end-to-end](stories/language-runtime-database/04-single-binary-e2e.md) | ✅ (known gaps below) |
| 5 | [Language surface](stories/language-runtime-database/05-language-surface.md) | 🔄 **next** |
| 6 | [Program mode + stdlib](stories/language-runtime-database/06-program-mode-stdlib.md) | ⬜ |
| 7 | [log-watcher proof](stories/language-runtime-database/07-logwatcher-proof.md) | ⬜ acceptance |
| 7b | [Inferred GC + mark-sweep](stories/language-runtime-database/07b-inferred-gc-mark-sweep.md) | ⬜ closes iteration 4's gate |
| 8 | [Shard-actor runtime](stories/language-runtime-database/08-shard-actor-runtime.md) | ⬜ |
| 9 | [Database engine](stories/language-runtime-database/09-database-engine.md) | ⬜ |
| 9b | [`@table`, relations, query](stories/language-runtime-database/09b-table-relations-query.md) | ⬜ needs a spec first |
| 10 | [HTTP service layer](stories/language-runtime-database/10-http-service.md) | ⬜ |
| 11 | [Fibers](stories/language-runtime-database/11-fibers.md) | ⬜ |
| 12 | [Blue-green deploy](stories/language-runtime-database/12-blue-green-deploy.md) | ⬜ |
@ -66,9 +65,9 @@ that sequences its tasks. Read one, approve, then the next starts.
| Track | Item | Where |
| --- | --- | --- |
| Language | Iteration 4 — emitter, conformance corpus, `woc build` | [plan 3](plan/compiler/2026-08-01-wob-emit-e2e-single-binary.md) |
| Language | Iteration 5 — Haxe-parity language surface, `?T` forced handling first | [plan 8](plan/compiler/2026-08-01-haxe-parity-language.md) |
Nothing else should be started until iteration 4 lands. Off-critical-path work
Nothing else should be started until iteration 5 lands. Off-critical-path work
is parked by explicit scope directive (2026-08-08).
---
@ -86,6 +85,7 @@ is parked by explicit scope directive (2026-08-08).
| ✅ | Error catalog | [`oop-vm/01-error-catalog.md`](plan/oop-vm/01-error-catalog.md) | 14 emitted codes + 10 reserved, each with the reason it is not yet emitted |
| ✅ | log-watcher `.wo` sample | [`../examples/log-watcher/`](examples/log-watcher/README.md) | Eight-file port authored docs-first with its `.hx` mapping table; compiles for real in iteration 7 |
| ✅ | Scalar cleanup | [`discarded.md`](plan/discarded.md) | `Money`/`SKU`/`Float` and the abstract allowlist removed; `abstract` flipped adopt → reject |
| ✅ | `woc` emitter, corpus, single binary | [plan 3](plan/compiler/2026-08-01-wob-emit-e2e-single-binary.md) | Tasks 1–6 + 8 (Task 7, a parity harness against the Rust runtime, **deferred by explicit user decision** — the two stacks diverge by design). Bytecode emitter (`emit.ml`) + disassembler (`disasm.ml`, `--dump-bc`); three-kind conformance harness (`scripts/oop-e2e.sh`, `just oop-e2e`) over `tests/corpus/{run,compile-fail,trap,gc}`; pricing-demo + ownership/trap corpora (19 fixtures); `@gc` cycle collector's post-exit pump (`WO_GC_BUDGET`/`WO_GC_TRACE`) + 2 gc fixtures (`gc/held-cycle` retired — see criterion-3 closure below); `woc build` single-binary output + relocation/corrupt-trailer smoke; `WO-E405` closing criterion 3's ASan leak (entry must return `Int`); `just oop-accept` wiring all five spec criteria + both unit gates into one command. 14 + 399 compiler checks; `oop-e2e` 25/25 against the release `wovm`. **Milestone-1 acceptance gate is fully green — all five criteria met** (see the dated acceptance note in `docs/superpowers/specs/2026-08-01-oop-compiler-vm-design.md`) |
**Known gaps carried out of iteration 3** — recorded, not silently owed:
@ -102,6 +102,46 @@ is parked by explicit scope directive (2026-08-08).
unresolved-callee drops, RC table ordering, residual b-side role) are listed
in the plan-2 SDD ledger and in the affected files' own comments.
**Known gaps carried out of iteration 4** — recorded, not silently owed:
- **`WO-E205` (unsatisfied interface) is reachable but unenforced — a real
hybrid-boundary inversion, not just a dead code path.** A class that does
not structurally satisfy an interface it's passed as compiles clean (exit
0, zero diagnostics) even though the violation is statically provable, and
the mismatched call reaches `wovm` as an `ICALL` with no matching vtable
entry, trapping `WO_T_BOUNDS` (6) at runtime instead of failing at compile
time. Pinned by `tests/corpus/trap/unsatisfied-interface/`; when `WO-E205`
is wired, that fixture must move to `compile-fail/` in the same change.
- **`set(m, k, v)`'s `@gc` retention gap on map keys/values is open** — the
twin of the `push` bug Task 5 fixed for `multi`. `set` has no equivalent
special case in `owner.ml`'s `analyze_call`, so a `@gc` key or value handed
to `set` is under-counted and the collector can free it while the map still
points at it. Nothing in the corpus exercises this yet. See
[`oop-vm/08-builtin-surface.md`](plan/oop-vm/08-builtin-surface.md).
- **E201/E203 and seven other `WO-E2xx` codes remain declared but unemitted**
— see [`oop-vm/01-error-catalog.md`](plan/oop-vm/01-error-catalog.md).
- **CLOSED — milestone-1's ASan gate (`just oop-accept`) failing on
`gc/held-cycle`.** Root cause (Task 8's finding, restated): `main.c`'s
entry-method return value (`uint64_t ret`, `src/main.c:158`) is stored
but never released, so `gc/held-cycle`'s "permanent external hold" was
actually a permanent refcount inflation — LeakSanitizer's "definite
leak" (1184 bytes / 3 allocations) was correctly reporting exactly
that, not a false positive. Fixing it by releasing `ret` was rejected:
the `.wob` method table carries no return-type/kind metadata, so
`main.c` has no way to know `ret` is a pointer rather than a scalar,
and adding that metadata is a format change out of scope here. Fixed
instead at the source: the systems-track spec already requires the
entry to return `Int` (its return value is the process exit code), so
a class-returning `main` was never legal — `WO-E405`
(`compiler/src/emit.ml`, `01-error-catalog.md`) now rejects it at
compile time, and `gc/held-cycle` is retired because its premise (an
externally-held cycle survives a *post-exit* pump) is no longer
expressible — see `oop-vm/02-corpus.md`'s "Retired" note for why, and
for where the scenario it meant to cover is actually proven
(`runtime/test/test_cycle.c`, plus a proper in-flight fixture scheduled
for story iteration 7b). Spec success criterion 3 is now **MET**;
`just oop-accept` passes all five criteria.
### Rust runtime track — Stage 2 shipped, maintained
| Status | Phase | Doc | Notes |
@ -135,8 +175,10 @@ Ecommerce sample (verified 2026-06-13): `api.rest` 17/17 expected statuses pass.
| 5 | Haxe-parity language surface — **`?T` forced handling first**, then switch expressions, records, enum payloads, try/catch, statics, `using`, modules, `is`, `pub(read)`, `#if` | [plan 8](plan/compiler/2026-08-01-haxe-parity-language.md) |
| 6 | Program mode + systems stdlib — `fn main`, exit codes, `fs`/`proc`/`net`/`time`/`json` | [plan 9](superpowers/plans/2026-08-01-program-mode-stdlib.md) |
| 7 | log-watcher proof — the sample compiles and detects a silent death live | [plan 10](superpowers/plans/2026-08-01-log-watcher-sample.md) |
| 7b | Inferred GC + incremental mark-sweep — `@gc` removed, GC-ness inferred, RC retired | [spec](superpowers/specs/2026-08-11-inferred-gc-mark-sweep-design.md) — plan to be written |
| 8 | Shard-actor runtime | [plan 4](superpowers/plans/2026-08-01-shard-actor-vm-runtime.md) |
| 9 | Database engine binding | [plan 5](superpowers/plans/2026-08-01-db-engine-binding.md) |
| 9b | `@table` + relations + language-integrated query | **no spec yet** — three open forks recorded in the iteration; brainstorm before planning |
| 10 | HTTP service layer | [plan 6](superpowers/plans/2026-08-01-http-service-layer.md) |
| 11 | Fibers | vision §3, [blue-green exploration](plan/exploration/blue-green-vm/00-vision.md) |
| 12 | Blue-green deploy | [spec](superpowers/specs/2026-08-03-blue-green-vm-design.md) — plan authored after iterations 9–10 |

View file

@ -47,3 +47,80 @@ All integers little-endian; offsets are absolute file offsets.
**Builtins:** now (ms), print (text), print_int, words (whitespace token count), multi_new/multi_push/multi_get/count/latest, map_new/map_set/map_get/map_has.
**Trap codes:** DIV0, BORROW, STACK, OOM, DB, BOUNDS, KEY, EXPLICIT.
## Single-binary trailer (`woc build`, plan 3 Task 6)
This section is **not part of the `.wob` format above** — `.wob` v1 is unchanged.
It documents the wrapper a *deployable executable* carries: `woc build <dir> -o
app` makes `app` by copying the `wovm` runtime binary and appending the
compiled `.wob` image plus a small fixed-size trailer. `wovm`'s own startup
(`runtime/src/main.c`) looks for this trailer in its own executable
(`/proc/self/exe`) before falling back to the classic `wovm file.wob` argv
contract, so the result runs standalone with no separate `.wob` file. Writer:
`compiler/bin/main.ml`. Reader: `runtime/src/main.c`'s `load_self_embedded`.
Append-based only, deliberately — no ELF section manipulation.
**Layout** — the trailer is the fixed **last 20 bytes** of the file, all
integers little-endian, found by seeking from the end (no scanning):
```
byte offset from EOF size field
-20 8 payload_off -- absolute file offset where the embedded .wob image starts
-12 8 payload_len -- length in bytes of the embedded .wob image
-4 4 magic -- 0x31544257 ("WBT1" read as LE u32, mirrors WOB_MAGIC's "WOB1")
[ wovm runtime bytes (payload_off bytes) ][ .wob image (payload_len bytes) ][ trailer: payload_off | payload_len | magic ]
^ byte 0 ^ byte payload_off ^ byte payload_off+payload_len == file_size-20
file_size ^
```
**Reader algorithm** (`load_self_embedded`): open `/proc/self/exe`; if the
file is shorter than 20 bytes, or its last 4 bytes don't equal the magic,
there is no trailer — fall back to the argv `.wob` path unchanged. If the
magic matches, `payload_off` and `payload_len` are validated to account for
*every* trailing byte exactly (`payload_off + payload_len == file_size -
20`, checked via a bounds-safe subtraction so a corrupt/huge value can't
wrap the arithmetic and slip past); any mismatch is reported as a clear
"corrupt trailer" error (exit 2) rather than a crash or silent
misbehavior. On success, the executable is mmap'd and `wo_load_buf` parses
the embedded region exactly as `wo_load_file` parses a standalone `.wob`
today — argv is never consulted.
**Runtime location (writer side):** `--runtime <path>` wins when given;
otherwise the default is `runtime/wovm` resolved relative to the current
working directory (the same repo-root-relative assumption every other
`just`/build-tooling entry point in this repo already makes). A missing
runtime binary is a build-time error naming the recipe: `make -C runtime
wovm`. `woc build` never invokes or inspects the runtime binary beyond
reading its bytes — it does not need to be executable *as run by woc*, only
as run by whoever runs the produced artifact.
**Edge cases decided for `woc build`** (each implemented deliberately, not
left to fall out accidentally):
- **Output path already exists:** overwritten, but atomically — the new
binary is assembled in a temp file (`<out>.woc-build.tmp`, freshly
created with mode `0755` each time so a stale temp file's permissions
can never leak through) next to `-o`, then renamed over it. A failed
build (bad compile, missing runtime, disk-full mid-write) never
clobbers a previously-working binary with a partial one.
- **A directory with no `main`:** unlike `--emit` (where a `.wob` with no
entry method is a legitimate, already-specified artifact), `build`'s
entire purpose is something runnable, so a clean compile with no
zero-argument free fn named `main` is a **build-time error, no output
written** — not deferred to `wovm`'s own "module has no entry method"
message at run time. Detected by reading the compiled image's own
entry field (`WOB_OFF_ENTRY`, offset 40) rather than plumbing a new
return value through the emitter.
- **`--runtime` itself already carries a trailer** (rebuilding from a
previously-built single binary): its embedded payload is *stripped*
before copying — the writer recognizes its own trailer on the input
runtime binary the same way the C reader does, and keeps only the
pristine runtime prefix (`payload_off` bytes). This makes `woc build
... --runtime already-built-app -o new-app` produce a binary
byte-identical in size to building fresh from `runtime/wovm` directly,
instead of chaining stale payloads and bloating on every rebuild. Any
input that doesn't unambiguously look like our own trailer (wrong
magic, or offsets that don't exactly account for every trailing byte)
is left untouched and copied as-is — the safe default when it's not
certain.

View file

@ -1,14 +1,16 @@
# The `woc` diagnostic catalog — normative reference
Every `WO-E###`/`WO-W###` code the `woc` front end (`compiler/`) actually
emits, as of plan 2 tasks 2–8. Code ranges are reserved per stage
(`compiler/src/diag.ml`): `WO-E0xx` lexing, `WO-E1xx` parsing, `WO-E2xx`
types, `WO-E3xx` ownership, `WO-W2xx` warnings from the types stage. This
emits, as of plan 2 tasks 2–8 and plan 3 tasks 1–2. Code ranges are reserved
per stage (`compiler/src/diag.ml`): `WO-E0xx` lexing, `WO-E1xx` parsing,
`WO-E2xx` types, `WO-E3xx` ownership, `WO-E4xx` the bytecode emitter,
`WO-W2xx` warnings from the types stage. This
is an enumeration of codes already in use, not an archaeology dig — see
"Completeness method" below for how that was verified, "Reserved,
not yet emitted" for codes the source declares but no check yet raises,
and "Unreachable by design" for the one code (WO-E205) that isn't merely
unimplemented — it has no legal call site in the milestone grammar.
and "Reachable but unenforced" for the one code (WO-E205) whose check
site the milestone grammar *does* exercise, unlike the codes above it —
see that section for why this is a live gap, not a scope boundary.
One code (WO-E214) is emitted by the driver (`compiler/bin/main.ml`),
not one of the four stage modules — a Task 8 review finding — see its
row in the types table below for why it still uses that range.
@ -32,7 +34,7 @@ half of the story ("moved here" / "borrowed here" / etc.).
| WO-E101 | generic syntax error: an unexpected token where the grammar expected something else, including running off the end of the file inside an unclosed block/type/interface body. Declaration-level recovery syncs to the next top-level keyword so one bad declaration yields one diagnostic, not a cascade. | `expected ')' or ',', got NEWLINE` |
| WO-E102 | an invalid `@table(...)` configuration: `name` given twice, an `index` with no columns, or an argument key other than `name`/`index`. | `@table(name: ...) given twice` |
## WO-E2xx / WO-W2xx — types (Task 6, `compiler/src/types.ml`; WO-E214 Task 8, `compiler/bin/main.ml`)
## WO-E2xx / WO-W2xx — types (Task 6, `compiler/src/types.ml`; WO-E214 Task 8, `compiler/bin/main.ml`; WO-E215 plan 3 Task 2, `compiler/src/types.ml`)
| code | meaning | example message |
| --- | --- | --- |
@ -41,6 +43,7 @@ half of the story ("moved here" / "borrowed here" / etc.).
| WO-E206 | a constructor literal (`ClassName { ... }`) omits a field the class declares (no default). | `missing field \`sku\` in constructor of \`Product\`` |
| WO-E207 | a constructor literal names a class that isn't declared anywhere in the (possibly multi-file) program. | `unknown type \`Widget\` in constructor` |
| WO-E214 | a class or interface name is declared more than once across the files a directory discovers (one program, multiple files — Task 8). Reported at the *later*-discovered declaration (sorted by path), with the first declaration as the related site; the merged symbol table keeps the first one, so this is what stops that silent keep from also hiding a real shape conflict. Driver-level, not `types.ml` — reuses the `types_prefix` range because it's a symbol-table concern, not a lexing/parsing/ownership one. | `class \`Dup\` already declared in \`a_first.wo\`` |
| WO-E215 | a class, interface, or free `fn` name is declared more than once in the *same file* (`collect_declarations`'s own `StringMap.add` silently dropped the earlier one — Task 1 review, found while building the plan-3 emitter, fixed in Task 2). Reported at the later declaration, with the first as the related site — the same shape as WO-E214, one file instead of two; the symbol table keeps the first declaration. Class/interface names and free-fn names are separate namespaces, so a class and a fn sharing a name never collide here. | `class \`Dup\` already declared` |
| WO-E225 | a field's declared type name isn't a builtin scalar, a declared class, or a declared interface. Checked once per field declaration, at the field's own position. | `unknown type \`Wdiget\`` |
### Reserved, not yet emitted
@ -59,20 +62,40 @@ conformance fixture (plan 3) or a future reader doesn't assume one of
these codes is reachable today; move a code up into the table above in
the same commit that wires its first real emission site.
### Unreachable by design
### Reachable but unenforced
`unsatisfied_interface_code` (WO-E205) is declared in `types.ml` but does not
belong in the list above — it is not a pending implementation, it is
unreachable by design given the milestone grammar. Structural interface
satisfaction has exactly one legal home: a site where a value is used at an
interface-typed position (a field, parameter, or return typed as an
interface). There is no `implements` keyword by doctrine — satisfaction is
structural, checked where the value is used, not declared — and the
milestone grammar declares no interfaces and exercises no interface-typed
positions, so the check has nowhere to fire. This is not a gap in shipped
work; it costs the milestone nothing. The check starts firing the moment a
future milestone introduces an interface-typed position — no interim
workaround is owed before then.
belong in the "Reserved, not yet emitted" list above either. An earlier
revision of this doc claimed WO-E205 was *unreachable by design* — that
was wrong, caught and corrected in the plan-3 Task 4 review (2026-08-11).
Structural interface satisfaction's one legal check site is where a value
is used at an interface-typed position (a field, parameter, or return
typed as an interface) — and the milestone grammar does exercise that
position today. This compiles with exit 0 and zero diagnostics:
```wo
interface Priced { fn current_price() -> Int }
class Rock { n: Int }
fn quote(p: Priced) -> Int { return p.current_price() }
fn main() { let r = Rock { n: 1 }
print_int(quote(r)) }
```
`Rock` has no `current_price` method, so it does not structurally satisfy
`Priced` passed to `quote`'s interface-typed parameter — and `Rock`'s
method set is fully known at compile time, so this is a *statically
provable* violation, exactly the shape WO-E205 exists to catch. `woc
--emit` accepts it anyway. The unchecked call reaches `wovm` as an
`ICALL` with no matching vtable slot, which traps `WO_T_BOUNDS` (6, "no
vtable entry for receiver class") at runtime instead of failing to
compile. That inverts the hybrid boundary WO-E3xx pins elsewhere
(provable violation → compile-time diagnostic, unprovable → runtime
trap): here a provable violation resolves as a trap. This is an owed
gap, not a design decision, currently pinned as the known-gap fixture
`tests/corpus/trap/unsatisfied-interface/` (plan 3, Task 4) — its own
comment says it must move to `compile-fail/` with `fixture.code
WO-E205` in the same change that implements this check, rather than
silently going stale.
## WO-E3xx — ownership / MVS (Task 7, `compiler/src/owner.ml`)
@ -88,6 +111,24 @@ renders indented beneath it.
| WO-E303 | two exclusive (`mut`) accesses of the same place, or two accesses the analysis can *prove* overlap, conflict in one region (e.g. two `mut` element accesses through the same provable index, or the same place borrowed and then mutated). Cases the analysis can't prove either way become a residual site for the VM to guard at runtime, not this diagnostic. | `cannot borrow \`bag.items[i]\` as \`mut\` twice in the same call` |
| WO-E304 | a borrow is returned or stored somewhere that outlives the scope it borrowed from. `@gc`-typed values are exempt (freely aliased by design). | `borrow of \`x\` returned — borrows cannot outlive their scope` |
## WO-E4xx — emitter (plan 3 Task 1, `compiler/src/emit.ml`)
The emitter's range covers the two boundaries nothing upstream can see:
the `.wob` format's own encoding limits, and the milestone-1 instruction
set's edge — surface the front end accepts but the VM has no operation
for. Both are reported, never worked around: an over-budget method is a
diagnostic rather than a truncated frame, and a construct with no
lowering is a diagnostic rather than invented bytecode. No image is
written when any of these fire (`woc --emit` writes nothing on exit 1).
| code | meaning | example message |
| --- | --- | --- |
| WO-E401 | the method needs more than 64 registers — the VM's register window (`runtime/src/wob.h` `WO_MAX_REGS`, enforced by the loader). Reported once per method, at the method's own position. | `` `wide` needs more than 64 registers — the VM's register window is 64 slots; split the method or reduce the number of live locals `` |
| WO-E402 | a value that does not fit an instruction field: a constant/class/method/interface-slot index above 65535 (`LOADK`/`NEW`/`CALL`/`ICALL` carry a 16-bit operand), a field index above 255 (`GETF`/`SETF` carry a byte), or a jump farther than the signed 16-bit displacement. | `field index 300 exceeds the 8-bit GETF/SETF field` |
| WO-E403 | a construct the v1 instruction set cannot express, or a call the emitter cannot lower correctly. The full source-surface contract is [`08-builtin-surface.md`](08-builtin-surface.md); the cases raised here are: an unresolved name; a call to something that is neither a declared `fn` nor a builtin; a wrong argument count (nothing upstream checks arity — WO-E203 is declared and never raised — and a mismatched call reserves a window the callee does not read, which the loader rejects); a field/method on a type that is not a declared class; `multi_new()`/`map_new()` with no destination of declared type (the element kinds are the container's runtime drop plan and cannot be guessed); an element write into a `multi` (v1 has `multi_push`/`multi_get`, no element store); a `for` over a `map` (v1 exposes no key enumeration). Several of these are cases the typechecker's placeholder types let through — the emitter is the first stage that must be exact. | `` `for` can only iterate a `multi` — the v1 builtins expose no key enumeration for a `map` `` |
| WO-E404 | an ownership-table entry the emitter could not honor: a residual borrow site whose operand has no register at the guarded region, a residual region no lowering wrapped at all (checked at the end of every compilation unit — owner.ml anchors regions on several different node kinds, and one nobody consumed would ship the aliasing check silently disabled), or a drop/rc site naming a local that has no register. Emitting such a region unguarded would drop the single enforcement a residual site exists for, so it fails instead. | `` residual borrow site in `shuffle` names an operand with no live register — the runtime guard cannot be placed `` |
| WO-E405 | the program entry (the zero-arg free fn `main`, selected by name) declares a return type other than `Int`. The systems-track spec (`docs/superpowers/specs/2026-08-01-systems-track-design.md:70`) makes the entry's return value the process exit code, so any other declared return type was never legal — this is the check that finally says so. `main` with no return annotation at all is unaffected (nothing declared to contradict `Int`); every other milestone-1 fixture uses that form. Reported once, at `main`'s own position. | `` entry `main` declares return type `Node` — the entry's return value is the process exit code, so it must return `Int` `` |
## Completeness method
Every code in this catalog was found the same way: grep every
@ -100,7 +141,9 @@ constant it names. Every constant with at least one such call site is
in the table above; every constant with zero call sites is listed under
"Reserved, not yet emitted" instead of silently omitted. `parser.ml`'s
`fail`/`unexpected`, `owner.ml`'s `report`/`escape`/`check_against_borrows`,
and `main.ml`'s `report_collision` are the only indirection layers
`emit.ml`'s `err`/`over_budget`/`check_bx`/`check_field_idx`, `main.ml`'s
`report_collision`, and `types.ml`'s `report_duplicate_decl` (plan 3 Task 2)
are the only indirection layers
between a bare `~code:` argument and the `Diag.error` call — each was
read to confirm which named constant ultimately reaches the collector,
not just the arity-generic wrapper name. This is why the catalog is an

View file

@ -0,0 +1,222 @@
# `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`](../../../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`](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-E403`s 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:
```sh
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`](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
```sh
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).

View file

@ -0,0 +1,136 @@
# Milestone-1 source surface the emitter lowers — normative reference
> What a `.wo` program may say and have `woc` produce bytecode for.
> The `.wob` format doc ([`00-wob-format.md`](00-wob-format.md)) names the
> BUILTIN *ids*; this names their **source spellings** and the handful of
> rules that have no other home. Landed with the emitter
> (`compiler/src/emit.ml`, plan 3 task 1). Diagnostics referenced here are
> catalogued in [`01-error-catalog.md`](01-error-catalog.md).
>
> Anything on this page is a contract for corpus fixtures and for every
> later sub-project's `.wo` code — not an emitter implementation detail.
## Builtins
Containers and runtime services are free functions, never methods. Each
maps to one `BUILTIN` id of the format doc.
| source | `.wob` builtin | arity | meaning |
| --- | --- | --- | --- |
| `now()` | `now` | 0 | wall-clock milliseconds (`Int`) |
| `print(t)` | `print` | 1 | a `Text`, newline-terminated |
| `print_int(n)` | `print_int` | 1 | an `Int`, newline-terminated |
| `words(t)` | `words` | 1 | whitespace token count of a `Text` |
| `multi_new()` | `multi_new` | 0 | a fresh `multi T` — see the destination rule below |
| `map_new()` | `map_new` | 0 | a fresh `map<K, V>` — see the destination rule below |
| `push(m, v)` | `multi_push` | 2 | append to a `multi` |
| `count(c)` | `count` | 1 | length of a `multi` or a `map` |
| `latest(m)` | `latest` | 1 | last element of a `multi` (traps `BOUNDS` when empty) |
| `get(c, k)` | `multi_get` / `map_get` | 2 | element by index, or value by key (a missing key traps `KEY`) |
| `set(m, k, v)` | `map_set` | 3 | insert or replace in a `map` |
| `has(m, k)` | `map_has` | 2 | `1`/`0` |
`get`, `set`, `push`, `count` and `has` resolve on the container they are
given, so one source name covers the `multi` and `map` ids the runtime
keeps apart.
**Sugar.** `c[i]` is exactly `get(c, i)` and `m[k] = v` is exactly
`set(m, k, v)`. There is no element *write* into a `multi` — v1 has
`multi_push` and `multi_get` and no element store — so `m[i] = v` on a
`multi` is `WO-E403`.
**Shadowing.** A user-declared free `fn` of the same name always wins. A
declared name is never silently replaced by a builtin.
**`push` and `@gc` elements.** `push(m, v)`'s value argument is never a
resolved callee parameter (`push` has no declared signature), so the
owner pass's ordinary Take-gated transfer never reaches it; a `@gc` value
pushed into a `multi` is special-cased in `owner.ml`'s `analyze_call`
(the value escapes into the container exactly like a ctor field, RC_INC
included) specifically so a `multi`-mediated `@gc` cycle can be built
and later collected (`tests/corpus/gc/`, plan 3 task 5). **`set(m, k, v)`
has no equivalent special case** — a `@gc` key or value handed to `set`
is not retained, so a `map<_, SomeGcClass>` (or a `@gc`-keyed map) built
this way will under-count its element's refcount and the collector will
free it while the map still points at it. Nothing in the corpus
exercises this yet; treat it as an open gap, not a proven-safe pattern,
until `set` gets the same fix `push` did.
## A fresh container needs a destination of declared type
`multi_new()` and `map_new()` carry their element (and key/value) kinds as
an instruction immediate, and those kinds **are** the container's drop
plan at runtime (`runtime/src/gc.c`). They cannot be guessed: assuming
`SCALAR` for a `multi Item` leaks every element, and for a
`map<Text, _>` leaks every key. Milestone-1 `let` has no container type
annotation — its optional annotation is a bare identifier — so a fresh
container must be created where its type is declared:
```wo
class Store {
items: multi Item
by_name: map<Text, Int>
}
fn main() {
let s = Store { items: multi_new(), by_name: map_new() } -- kinds from the fields
push(s.items, Item { n: 7 })
set(s.by_name, "seven", 7)
}
```
A bare `let m = map_new()` is `WO-E403`, reported at the creation site.
The same rule applies to a `take`/`mut` parameter of declared container
type, which is also a typed destination.
## Calls
- Argument count must match the callee's parameter count (`WO-E403`).
Nothing upstream checks arity — `types.ml` declares `WO-E203` and never
raises it — and a mismatched call reserves a register window the callee
does not read, which the loader rejects outright.
- A method is called as `receiver.method(args)`. When the receiver's
declared type is an **interface**, the call is dispatched by vtable
(`ICALL`); satisfaction is structural (same method name, same parameter
count), Go-style, with no `implements` keyword.
- `self` occupies the callee's `r0`, so a method's argument count is
`1 + parameters`.
## Program entry
The entry point is the **zero-argument free `fn main`**. A `main` that
takes parameters is not an entry (the format's own rule is a zero-argument
free fn), and `wovm` will report `module has no entry method`.
## Operators with no dedicated opcode
Lowered by the emitter, not added to the format:
| source | lowering |
| --- | --- |
| `a % b` | `a - (a / b) * b` — exact for the VM's truncating `DIV`, which traps on `0` and on `INT64_MIN / -1`, both correct for `%` too |
| `a != b` | `(a == b) == 0` |
| `a > b`, `a >= b` | `LT` / `LE` with the operands swapped |
| `a == b` on `Text` | `EQS` (content equality); `EQ` otherwise |
| `a .. b` | `CONCAT` — `+` is arithmetic only, never string addition |
## Not lowerable in milestone 1
Each is `WO-E403` at the offending site, never invented bytecode:
- an element write into a `multi` (no element-store instruction);
- `for` over a `map` (v1 exposes no key enumeration);
- a name that is neither a local, a parameter, `self`, a declared `fn`,
nor a builtin;
- a field or method on a type that is not a declared class — including a
class named only inside `multi T` / `ref T`, which `types.ml`'s
unknown-type check (`WO-E225`) does not look inside.
## `?T`
A nullable field stores exactly what `T` stores and spells nil as `0`.
The v1 format has no kind byte for it (field kinds run `0..5`; the loader
rejects `6`), and it needs none: every per-kind drop plan already ignores
a zero slot. `?T`'s field kind is therefore `T`'s. Note the consequence
for `@gc`: `?SomeGcClass` is a `GCREF` field like any other, so it
participates in refcounting and cycle detection normally.

View file

@ -12,5 +12,6 @@ The normative contract documents both stacks cite. Landed by their named plan ta
| `05-http-service.md` | route section, trap→HTTP table, JSON subset | 6 |
| `06-ui-live.md` | delta frames, subscribe protocol, wo:live | 7 |
| `07-systems-stdlib.md` | per-function nil-vs-trap contracts | 9 |
| `08-builtin-surface.md` | builtin source names, container/call/entry rules the emitter enforces | 3 |
Specs and plans: `docs/superpowers/{specs,plans}/`. Repo map: `docs/08-project-structure.md`.

View file

@ -5,7 +5,7 @@
**AS** a developer building and operating my own products end to end
**I WANT** a new programming language — with arithmetic, ownership-based memory safety, and garbage collection where I opt in — whose compiler, runtime, and database ship as a single never-stopping Linux binary that can update its own code in place
**I WANT** a new programming language — with arithmetic, ownership-based memory safety, and garbage collection applied automatically wherever ownership alone cannot express the shape — whose compiler, runtime, and database ship as a single never-stopping Linux binary that can update its own code in place
**TO** write an application once and run it forever: no external stack to assemble, no database server to operate, and deployments that swap code inside the running process with instant rollback.
@ -15,8 +15,10 @@
serves the API, and carries its own source — the "which commit is prod
running?" class of questions disappears.
- Memory safety without a GC tax: Rust-shaped borrowing (single owner,
second-class borrows) checked mostly at compile time, with per-class `@gc`
opt-in collected per shard — no global pause exists by construction.
second-class borrows) checked mostly at compile time, and where ownership
cannot express the shape the compiler decides — no annotation to write, and
collection stays per-shard so no global pause exists by construction
(iteration 7b; iterations 1–7 shipped a per-class `@gc` opt-in instead).
- Updates are blue-green **inside** the runtime: propose, approve, compile
in-process, atomic switch, previous version resident for instant rollback.
- The runtime is a recipe box: once language + runtime + database exist, a
@ -47,8 +49,10 @@ iterations); no commits by agents — drafts go to `.dev/commit.md`.
| 5 | [Language surface](05-language-surface.md) | Haxe-parity adoptions: switch, records, optionals, try/catch, statics, modules… |
| 6 | [Program mode + stdlib](06-program-mode-stdlib.md) | `fn main`, exit codes, `fs`/`proc`/`net`/`time`/`json` builtins |
| 7 | [log-watcher proof](07-logwatcher-proof.md) | the driving workload compiled and detecting silent deaths live |
| 7b | [Inferred GC + mark-sweep](07b-inferred-gc-mark-sweep.md) | `@gc` removed from the language; compiler infers GC-ness; RC replaced by incremental per-shard tri-color mark-sweep |
| 8 | [Shard-actor runtime](08-shard-actor-runtime.md) | thread-per-core shards, per-shard heaps, ownership-move messaging |
| 9 | [Database engine](09-database-engine.md) | class-shaped tables, typed WAL + recovery, `insert`/`select` execute |
| 9b | [`@table`, relations, query](09b-table-relations-query.md) | `@table` becomes real storage; typed `ref`/`backlink`/`multi` relations; compiler-checked LINQ-shaped queries lowered to engine ops |
| 10 | [HTTP service layer](10-http-service.md) | `service` blocks route to VM methods; REST parity with Stage 2 |
| 11 | [Fibers](11-fibers.md) | green threads on the shard scheduler: reduction-budget preemption, park on I/O |
| 12 | [Blue-green deploy](12-blue-green-deploy.md) | two VM slots, in-runtime compile, atomic switch, resident rollback |
@ -74,11 +78,17 @@ list, and a pointer to the plan document that already sequences its tasks.
outcome.
- **Critical path (locked 2026-08-08): compile and run log-watcher.**
Iterations 3 → 4 → 5 → 6 → 7 are the committed line; nothing off that
line lands before iteration 7's acceptance. Iterations 8–11 follow.
line lands before iteration 7's acceptance. Then 7b, then 8–12 (with 9b after the database engine).
- **Iteration 7b (inserted 2026-08-11)** sits after the critical path
deliberately: it delays nothing on the log-watcher line, and it must precede
iteration 8 because the collector should be settled before shards multiply.
It also closes iteration 4's one open gate clause and supersedes part of
iteration 2's memory model — neither is renumbered; both point here.
- **Future iterations, after iteration 11** (parked 2026-08-08 — recorded,
not scheduled):
- WO-W201 `@gc`-suggestion diagnostic refinement (self-reference-only
heuristic shipped; shared-structure analysis deferred).
- ~~WO-W201 `@gc`-suggestion diagnostic refinement~~ — **superseded by
iteration 7b**: the diagnostic is retired outright, because inference
replaces the suggestion it existed to make.
- WO-E225 unknown-type validation broadened to `ref`/`multi`/`map`
element types and method/fn signatures.
- ADT container roster adoption (Stack, Queue, Set, Tree, Graph, … —

View file

@ -0,0 +1,108 @@
# Iteration 7b — inferred GC + incremental mark-sweep
> Format: `product/story-iteration-template`. Part of
> [Story — one language, one runtime, one database, one binary](00-story.md).
>
> **Inserted 2026-08-11**, after the plan was first drawn — hence `7b` rather
> than a renumber. It sits here because the log-watcher critical path
> (iterations 3–7) must not be delayed, and because the collector should be
> settled before iteration 8 multiplies shards.
## Goals
- **The developer stops deciding which types are garbage collected.** `@gc`
disappears from the language; the compiler infers GC-ness and reports every
decision with its reason.
- Reference counting is replaced by an incremental per-shard tri-color
mark-sweep collector, so the compiler no longer has to emit a balanced
acquire/release at every alias site — the source of every recorded `@gc`
defect.
- Milestone 1's acceptance criterion 3 (ASan-clean across the corpus) closes,
because the leak blocking it is one of the defects this deletes.
## Acceptance Criteria
- What to achieve?
- **Given** a class whose declaration can form a reference cycle, and a
separate class that is only ever shared through a long-lived alias,
- **when** the program is compiled,
- **then** both are classified GC-managed without any annotation, and
`--dump-gc` names the reason for each — a cycle path for the first, the
escaping alias site for the second.
- What to achieve?
- **Given** any `.wo` source containing `@gc`,
- **when** it is compiled,
- **then** it is a diagnostic pointing at inference and `--dump-gc`, not a
silently accepted no-op.
- What to achieve?
- **Given** a program that hides a traced object from the collector —
storing it into an already-blackened object between marking slices and
dropping the original reference,
- **when** the collector completes,
- **then** the object is still alive; and the same fixture fails loudly if
the write barrier is compiled out.
- What to achieve?
- **Given** an abandoned cycle and a cycle still rooted from a live frame,
- **when** collection runs,
- **then** the abandoned one is freed within budgeted slices with no slice
exceeding the configured budget, and the rooted one survives.
- What to achieve?
- **Given** the whole conformance corpus, run repeatedly so several
collection cycles occur,
- **when** it runs under ASan,
- **then** zero leaks and zero errors — the clause that currently fails.
- What to achieve?
- **Given** any emitted `.wob` image,
- **when** it is disassembled,
- **then** no `RC_INC` or `RC_DEC` appears, and the format doc records
opcodes 27–28 as reserved behind a version bump.
## Out Of Scope
- Cross-shard tracing — ownership moves mean no traced object spans shards.
- Generational collection and compaction. Non-moving is load-bearing: no
forwarding pointers, no read barrier. Go's collector is not generational
either.
- Scheduler-integrated pacing beyond the heap-goal trigger; that stays
iteration 8's concern, which is part of why this lands first.
- `ref T` semantics, unchanged — it is an id, not a pointer, and creates no
edge in the inference graph.
## Info
- Governing spec: [`docs/superpowers/specs/2026-08-11-inferred-gc-mark-sweep-design.md`](../../superpowers/specs/2026-08-11-inferred-gc-mark-sweep-design.md).
- **Why the annotation was insufficient, not merely inconvenient:** the OOP
spec's own example, `@gc class PriceCache { entries: map<SKU, Money> }`, is
acyclic. It needs GC because it is shared, and second-class borrows cannot
be stored or returned. So the developer was being asked to reason about type
shape *and* whole-program aliasing at once — hence the hybrid rule
(structural SCC plus reported demand promotion).
- **Why tracing rather than better reference counting:** all four recorded
`@gc` defects are RC bookkeeping failures — `push` missing an increment,
`set` still missing one, the `mut`-`@gc` clobber, and the held-cycle leak.
Inferring GC-ness would widen that population and so widen that bug class.
Tracing emits no per-alias bookkeeping at all.
- **Most of what tracing needs already exists.** The emitter already produces
precise per-pc pointer masks (the drop table's gc mask) and the class table
already carries per-field kinds — the two pieces Go gets from stack maps and
type maps. Go's dependence on OS threads is incidental; the algorithm needs
only per-frame PC→map lookup and the ability to suspend one stack.
- **The one real runtime addition:** the arena cannot enumerate objects — bump
allocation plus size-class free lists, with large objects on bare `malloc`
and no size headers anywhere. Sweep needs its own list. Retiring `rc`, plus
the `borrow` word that traced objects never use, frees exactly eight
contiguous bytes for an intrusive link, so the 16-byte header survives.
- This iteration **supersedes part of iteration 2's memory model** (§4 of the
OOP spec) and closes iteration 4's open gate clause. Neither is renumbered;
both carry pointers here.
## Proposed Solution
- Write the implementation plan from the approved spec, then execute it: the
`gcinfer.ml` pass (Tarjan SCC over the class-reference graph, then demand
promotion to a fixpoint, with a note per decision), the `@gc` removal and
its diagnostic, retiring `RC_INC`/`RC_DEC` and the rc machinery from
`owner.ml`/`emit.ml`, the per-shard traced list and sweep, incremental
tri-color marking with roots read from the existing pc masks, the Yuasa
deletion barrier inside the VM's store paths, and the doc/golden migration
the spec's §8 table enumerates.

View file

@ -0,0 +1,132 @@
# Iteration 9b — `@table`, relations, and language-integrated query
> Format: `product/story-iteration-template`. Part of
> [Story — one language, one runtime, one database, one binary](00-story.md).
>
> **Inserted 2026-08-11**, hence `9b` rather than a renumber. It follows
> iteration 9 because a query surface needs tables that actually execute, and
> precedes iteration 10 because `service` blocks will want to return query
> results.
>
> **No spec exists yet.** This iteration frames the outcome and records the
> open questions; the design must be brainstormed before a plan is written.
> The three questions in *Info* are genuine forks, not details.
## Goals
- `@table` graduates from a parsed-but-inert annotation into the declaration
that makes a class persistent: named storage, declared indexes, and a
primary identity.
- Relations become first-class and typed — `ref T` foreign keys, `backlink`
inverses, and `multi` collections — so a developer navigates their data by
following fields rather than by hand-writing joins.
- Queries are **written in the language, checked by the compiler**: a
LINQ-shaped operator vocabulary (filter, project, join, group, order,
aggregate) over tables and relations, with the result's type inferred and
every column reference resolved at compile time. A typo in a field name is
a compile error, not a runtime one.
## Acceptance Criteria
- What to achieve?
- **Given** a class annotated `@table` with a declared index,
- **when** the program is compiled and run,
- **then** its instances persist through the engine, the index is built,
and a query that could use the index does use it — demonstrated, not
assumed.
- What to achieve?
- **Given** two classes related by `ref` with a `backlink` inverse,
- **when** a query navigates the relation in either direction,
- **then** it typechecks with the related class's field set in scope, and
navigating a field that does not exist is a compile error naming it.
- What to achieve?
- **Given** a query whose result shape is a projection rather than a whole
row,
- **when** it is assigned or returned,
- **then** its type is the projected shape — so a later use of a column
the projection dropped is a compile error.
- What to achieve?
- **Given** a query written against tables,
- **when** the compiler lowers it,
- **then** it becomes engine operations, **not** a string handed to a
parser at runtime — provable by disassembly, and by the absence of any
SQL-text construction in the emitted image.
- What to achieve?
- **Given** the ecommerce sample's existing relational shapes
(`Order.user: ref User`, `User.orders: backlink Order.user`, line-item
collections),
- **when** they are expressed as queries in this surface,
- **then** each produces the same results as the equivalent hand-written
query, and the sample's README records anything that could not be
expressed.
## Out Of Scope
- Cross-shard queries and distributed joins — iteration 8 owns ownership
movement, and a query spanning shards is a 2PC concern recorded with the
database track.
- `LIVE` subscriptions over queries. The subscription registry is the
HTTP/UI track's; a query that pushes updates is a later composition of the
two.
- Migrations. Changing a `@table` class's shape is the blue-green spec's
additive-only differ (iteration 12), not this iteration's problem.
- Query optimisation beyond index selection. A cost-based planner is a
separate, much later concern; this iteration must only prove that declared
indexes are used.
## Info
Three open forks the spec has to settle. Each is a real decision, and I have a
leaning on all three but no mandate.
**1. Where does this leave the existing SQL + Cypher query layer?**
`docs/runtime/database/02-wo-language.md` specifies a two-layer design — a
schema layer plus a query layer of literal SQL and Cypher with five
"fixed-glue" rules. A language-integrated surface either replaces that layer,
sits beside it, or becomes the only surface with SQL retained purely as an
export format. Replacing it is the coherent choice and also the most
disruptive, because that document is normative and the `wo-db` C++ prototype
implements the SQL/Cypher grammar it describes.
**2. There are no function values, so what is the syntax?**
LINQ-to-Objects is built on delegates: `.Where(x => x.Age > 18)` passes a
lambda. writeonce has **no function-value type**, and the systems-track spec
deliberately rejected closure builtins (`map`/`filter`/`reduce`) for exactly
this reason. So the surface cannot be method-chaining-with-lambdas as written
in C#. The realistic options are a comprehension syntax the compiler desugars
(`from o in orders where o.total > 100 select o.id`), or method chaining whose
"lambda" argument is a compiler-recognised expression form rather than a
value. Either way the predicate is **compile-time syntax, never a runtime
closure** — which is also what lets the whole query lower to engine ops.
**3. What does the reference actually contribute?**
`.dev/reference/dotnet-runtime/src/libraries/System.Linq/src/System/Linq/`
(sparse checkout, added with this iteration) is the operator catalogue: read
`Where.cs`, `Select.cs`, `Join.cs`, `GroupBy.cs`, `OrderBy.cs` for what each
operator means and which edge cases it has to answer, and the `*.SpeedOpt.cs`
files for how LINQ specialises when the source's shape is known statically —
directly relevant, since writeonce knows every shape statically. What does
**not** transfer is the machinery: `IEnumerable` iterator composition,
delegates, and `IQueryable`'s runtime expression trees, the last of which
depends on reflection that principle 13 forbids outright. Take the vocabulary
and the semantics; leave the plumbing.
Also relevant: `@table(name:, index:)` already parses today with known-key
validation (`WO-E102`), the Rust runtime already ships secondary indexes and
`find_by` behind that annotation, and `ref T` already classifies as a scalar
id rather than a pointer — so the relational vocabulary partly exists and this
iteration makes it mean something in the C stack.
## Proposed Solution
- **Brainstorm a spec first**, settling the three forks above; only then write
the plan. This iteration deliberately ships no plan pointer, because
choosing between "replace the SQL layer" and "sit beside it" changes what
the plan contains.
- Study `.dev/reference/dotnet-runtime`'s `System.Linq` operator set for the
vocabulary, and `docs/runtime/database/02-wo-language.md` plus
`prototypes/wo-db/` for the semantics already committed to.
- Expect the work to span the front end (query syntax, relation typing,
projection types), the emitter (lowering to engine operations rather than
text), and the engine (index selection, relation traversal) — which is why
it follows iteration 9 rather than preceding it.

View file

@ -230,3 +230,35 @@ Milestone 1 is done when:
3. The ownership corpus passes: every must-fail program fails with the expected `WO-E###`; every must-trap program traps with the expected code; ASan/Valgrind report zero leaks and zero errors across the suite.
4. `@gc` cycle test: a cyclic `@gc` graph is collected within budgeted ticks with no pause longer than the configured slice.
5. `woc build` produces a single self-contained binary that runs with no arguments.
## Milestone 1 acceptance — 2026-08-11
Gate: `just oop-accept` (plan 3, Task 8), run for real against this working tree. Full output archived in `.superpowers/sdd/2026-08-01-wob-emit-e2e-single-binary/task-8-report.md`. Per-criterion result:
- [x] **1. Compile time.** `woc --emit` over the pricing-demo logic subset (`tests/corpus/{run/pricing-containers,run/pricing-current-price,run/pricing-discounted,run/pricing-text,trap/pricing-set-price-db-stub}/fixture.wo` — the five fixtures Task 3 derived from `docs/examples/pricing/`; the demo's original `.wo` files use surface milestone 1 doesn't have, so these are milestone 1's "logic subset" in fact, not the directory named in the spec's prose), measured over 20 runs: min 10.8 ms, avg 13.1–13.8 ms, max 16.9–17.6 ms. Worst observed run is under a fifth of the 100 ms budget. **MET.**
- [x] **2. Pricing output.** All four `run/pricing-*` fixtures compile and run under `wovm`, stdout byte-exact against `fixture.out`, under both the release binary and `runtime/build/wovm_asan`. **MET.**
- [ ] **3. Ownership corpus + ASan/Valgrind zero leaks.** The error-code half is fully met: all 7 `compile-fail/` fixtures fail with exactly their named `WO-E###`, all 5 `trap/` fixtures trap with exactly their named code. The ASan half is **not met**: running the full corpus (`run/`, `compile-fail/`, `trap/`, `gc/`, single-binary smoke — 26 checks) against `runtime/build/wovm_asan` (`make -C runtime wovm-asan`, Task 5's target), `gc/held-cycle` fails — LeakSanitizer reports a definite leak (1184 bytes / 3 allocations, `wo_multi_push`/`wo_obj_new` via `main.c:160`'s `wo_vm_call`) instead of exit 0. This is not a false positive to suppress: `runtime/src/main.c`'s entry-method return value (`uint64_t ret`, line 158) is stored and never used again, so the "permanent external hold" the fixture's own comment claims is not actually realized in the C driver — nothing keeps that pointer live for a precise scanner to find. The other 25 checks, including the other two `gc/` fixtures, are ASan-clean. **NOT MET** — tracked in `docs/00-status.md`'s "Known gaps carried out of iteration 4" and the SDD ledger; a `runtime/src/main.c` fix, out of scope for the task that found it.
- [x] **4. `@gc` cycle collection.** `gc/abandoned-cycle` (unreachable 2-cycle, collected step 1, `freed=2`) and `gc/budget-steps` (budget-sliced collection, `freed=4`) both pass byte-exact stdout plus exact `WO_GC_TRACE` step/freed counts, clean under ASan. `gc/held-cycle` demonstrates the complementary correctness property — an externally-held cycle is correctly *not* collected (`freed=0`, matching `fixture.trace`) — which is the GC decision criterion 4 asks about; its ASan failure is a criterion-3 (leak-detector) concern, not a collection-correctness one. **MET.**
- [x] **5. Single-binary.** `scripts/single-binary-smoke.sh` against the release `runtime/wovm`: `woc build` produces an executable; copied to a directory outside the repo and run with no arguments, stdout is byte-exact; a corrupted trailer fails clearly on exit 2 (never a crash or hang). All 3 checks pass. **MET.**
**4 of 5 criteria met; criterion 3 is not**, specifically its ASan-zero-leaks clause. `just oop-accept` fails loudly at that stage and does not proceed to the remaining stages (single-binary smoke, both unit gates) in the same run by design — those were verified to pass independently (see the Task 8 report) but are gated behind fixing this finding in a real `oop-accept` run.
### Update — 2026-08-11: criterion 3 closed, all five criteria MET
Root cause was already pinned above and did not change on inspection: `gc/held-cycle`'s "permanent external hold" was `runtime/src/main.c`'s entry-return value never being released — a genuine refcount leak, not a false positive. Adding release-on-return to `main.c` was rejected as the fix: the `.wob` method table carries no return-type/kind metadata, so the driver has no way to tell a pointer return from a scalar one, and adding that metadata is a format change out of scope for this closure.
Fixed at the source instead: this milestone's own spec (Section 4/Success criteria; the systems-track spec, `2026-08-01-systems-track-design.md:70`) makes the program entry's return value the process exit code, so an entry declaring a class return type was never legal — it just went unchecked. `compiler/src/emit.ml` now raises `WO-E405` for a free-fn entry (`main`, zero args) whose declared return type is anything but `Int`; a `main` with no return annotation is unaffected. Catalog entry in `01-error-catalog.md`; a `compiler/test/runner.ml` case (`entry return type: ...`, 4 checks) pins both the positive (fires on `-> Widget`) and negative (silent on no annotation) cases so it cannot regress silently.
With the entry contract enforced at compile time, `gc/held-cycle`'s premise — an externally-held cycle surviving a *post-exit* pump — is no longer expressible: nothing can hold a root past the point `main` returns, by construction. The fixture is retired (`oop-vm/02-corpus.md`, "Retired" note has the full reasoning); the scenario it meant to demonstrate remains covered, just one layer down, by `test_externally_held_cycle_survives_then_dies` in `runtime/test/test_cycle.c`, which holds its root the honest way (a real C local, not a leaked VM return value). This also means **criterion 4's evidence above is now partly stale**: `gc/held-cycle` no longer exists to "demonstrate the complementary correctness property" it's credited with — criterion 4 remains MET on the strength of `gc/abandoned-cycle` and `gc/budget-steps` alone (both untouched, both still ASan-clean), with the externally-held-cycle property now proven at the runtime-test layer instead of the corpus layer. Story iteration 7b (tracing GC) is scheduled to give the corpus a proper in-flight externally-held fixture once there is a root that doesn't route through `main`'s return.
Fresh `just oop-accept` run against this working tree, full output archived alongside this note in `.superpowers/sdd/2026-08-01-wob-emit-e2e-single-binary/m1-criterion3-closure-report.md`:
- [x] **1. Compile time.** 20 runs over the same 5-fixture pricing subset: min 6.464 ms, avg 6.825 ms, max 7.590 ms — under 8% of the 100 ms budget. **MET.**
- [x] **2. Pricing output.** Unchanged from the Task 8 report; still byte-exact under both `wovm` and `wovm_asan`. **MET.**
- [x] **3. Ownership corpus + ASan/Valgrind zero leaks.** Full corpus (`run/`×8, `compile-fail/`×7, `trap/`×5, `gc/`×2, single-binary-smoke×3 = 25 checks, one fewer than the Task 8 report's 26 because `gc/held-cycle` is retired) against `runtime/build/wovm_asan`: `oop-e2e: 25 checks, 0 failures`. Zero LeakSanitizer reports. **MET.**
- [x] **4. `@gc` cycle collection.** `gc/abandoned-cycle` (`freed=2`) and `gc/budget-steps` (`freed=4`) both pass byte-exact stdout and exact `WO_GC_TRACE` counts, clean under ASan — see the note above on `gc/held-cycle`'s retirement and where its property now lives. **MET.**
- [x] **5. Single-binary.** `scripts/single-binary-smoke.sh` against the release `runtime/wovm`: 3/3 checks pass. **MET.**
Both unit gates also ran green in the same `oop-accept` invocation: runtime (`make -C runtime test` + `test-iso` + `cli_smoke.sh`, 13 suites × 2 dispatch flavors, all ASan/UBSan-clean) and compiler (`dune runtest --root compiler`: 14 + 399 checks, up from 14 + 395 — the 4 new `WO-E405` cases). `oop-accept` printed `oop-accept: ALL CRITERIA MET`.
**5 of 5 criteria met.** Milestone 1's acceptance gate is fully green.

View file

@ -120,18 +120,23 @@ gains an `env` row and the count becomes six.
Both were asserted in the review doc; both are corrected at their real source
rather than in the retired file.
**Structural interface satisfaction is not implemented — and is unreachable by
design, not owed.** `WO-E205` is declared and never emitted. The review listed
satisfaction checking as *implemented*, which is false; but calling it a gap in
shipped work is equally wrong. Satisfaction is structural — there is no
`implements` keyword by doctrine — so the check has exactly one home: sites
where a value is used at an interface-typed position. The milestone grammar has
no such positions (no interface-typed fields, parameters, or returns are
exercised), so the check cannot fire yet and its absence costs nothing. The
error catalog re-files `WO-E205` as **unreachable until interface-typed
positions exist**, and `types.ml`'s module header stops claiming it produces a
satisfaction set. The log-watcher sample declares no interfaces, so this stays
off the critical path.
**Structural interface satisfaction is not implemented.** `WO-E205` is declared
and never emitted. The review listed satisfaction checking as *implemented*,
which is false. This amendment originally also called it *unreachable by
design, not owed* — that half is wrong, and corrected here (plan 3 Task 4
review, 2026-08-11): the check's one legal home, a site where a value is used
at an interface-typed position, **is** exercised by the milestone grammar. An
interface-typed parameter accepting a concrete class that doesn't structurally
satisfy it compiles clean today, then reaches `wovm` as an `ICALL` with no
matching vtable slot, which traps `WO_T_BOUNDS` at runtime instead of failing
to compile — even though the violation is statically provable (the class's
method set is fully known). See
[`01-error-catalog.md`](../../plan/oop-vm/01-error-catalog.md)'s "Reachable but
unenforced" section for the repro and
`tests/corpus/trap/unsatisfied-interface/` for the pinned current behavior.
This is an owed gap, not a design decision — it stays off the log-watcher
critical path only because the sample declares no interfaces, not because the
gap doesn't exist.
**`?T` is plumbed, not enforced.** The review listed "Nullable types `?T`" as
implemented — the same conflation already corrected in
@ -160,7 +165,7 @@ against the sample.
| [plan 9](../plans/2026-08-01-program-mode-stdlib.md) | Gains a core-builtins task covering the 22 bare globals plus `print_err`. `time` task gains `iso` and `local`, loses `mono`. `env` is named as a module rather than loose Part-2 prose. |
| [plan 3](../../plan/compiler/2026-08-01-wob-emit-e2e-single-binary.md) | Unchanged. |
| [plan 10](../plans/2026-08-01-log-watcher-sample.md) | Acceptance gains the diagnostic-count gate: 307 → 0. |
| [`01-error-catalog.md`](../../plan/oop-vm/01-error-catalog.md) | `WO-E205` re-filed as unreachable-by-design with its reason; `WO-E208`/`E210`/`E211`–`E213` keep their existing reserved entries. |
| [`01-error-catalog.md`](../../plan/oop-vm/01-error-catalog.md) | `WO-E205` re-filed as **reachable but unenforced** — corrected 2026-08-11, see § 5 — with its repro; `WO-E208`/`E210`/`E211`–`E213` keep their existing reserved entries. |
| [`docs/00-status.md`](../../00-status.md) | NEXT PLAN gains the milestone-grammar-only note; pending list gains the three cuts under the parked section. |
| `docs/00-code-review.md` | Reduced to a stub: one paragraph saying its findings landed here and in the plans, pointing at `docs/00-status.md`. |
@ -191,8 +196,9 @@ uncaught-trap surface exactly as it is today.
1. The systems-track spec's Part 1 has a boolean-operator row and its Part 3
lists six modules plus a core-builtins section covering all 22 names.
2. Plans 8 and 9 reflect every addition and cut; plan 8 Task 7 is gone.
3. `WO-E205` is documented as unreachable-by-design, and `types.ml`'s header no
longer claims a satisfaction set is produced.
3. `WO-E205` is documented as reachable but unenforced (corrected 2026-08-11,
see § 5 — an earlier "unreachable-by-design" claim here was wrong), and
`types.ml`'s header no longer claims a satisfaction set is produced.
4. `docs/00-code-review.md` is a stub; no second roadmap exists in the repo.
5. The 307-diagnostic baseline is recorded in plan 10 as its acceptance gate.

View file

@ -0,0 +1,275 @@
# Inferred GC + incremental mark-sweep — design spec
**Date:** 2026-08-11
**Status:** approved design, pre-implementation
**Scope:** removing `@gc` as a developer-facing annotation, inferring GC-ness in the
compiler, and replacing reference counting with an incremental per-shard
tri-color mark-sweep collector
**Amends:** [`2026-08-01-oop-compiler-vm-design.md`](2026-08-01-oop-compiler-vm-design.md)
(decision table's GC-granularity row, §3 rule 5, §4 memory model),
[`../../00-principles.md`](../../00-principles.md) (principle 3),
[`../../plan/oop-vm/00-wob-format.md`](../../plan/oop-vm/00-wob-format.md) (opcodes 27–28, drop-table
contract, class flags), [`../../plan/oop-vm/01-error-catalog.md`](../../plan/oop-vm/01-error-catalog.md)
(WO-W201, WO-E304)
**Reference studied:** Go's collector, `.dev/reference/go/src/runtime/mgc.go` and neighbours
## Motivation
`@gc` asks the developer to answer a question the compiler is better placed to
answer: does this type need tracing? Worse, the annotation is not even
sufficient — the OOP spec's own example, `@gc class PriceCache { entries:
map<SKU, Money> }`, is acyclic. It needs GC because it is a shared cache
aliased from many places, and second-class borrows cannot be stored or
returned, so a long-lived shared alias has nowhere else to live. So the
developer is being asked to reason about two different things at once: the
shape of their types *and* how those values will be aliased across the whole
program.
The reference-counting implementation makes this worse rather than better.
RC requires the compiler to emit a balanced acquire/release at **every** alias
site, and every recorded `@gc` defect in this repo is exactly that failure:
| Defect | Cause |
| --- | --- |
| `push(multi, gcVal)` emitted no `RC_INC` | unresolved builtin skipped the transfer path — use-after-free |
| `set(m, k, v)` still emits none (open) | same gap, never closed |
| `mut`-`@gc` argument didn't clobber its root | stale rc elision — use-after-free |
| `gc/held-cycle` leaks 1184 bytes | the entry return value's reference is never released, inflating rc forever |
Inferring GC-ness would *widen* that population and therefore widen that bug
class. Tracing deletes the category outright: the compiler emits no per-alias
bookkeeping at all.
And most of what tracing needs already exists. Go's collector depends on nine
pieces of compiler metadata; the load-bearing ones are precise per-PC pointer
maps and per-frame unwinding. The emitter already produces exactly that — the
drop table carries an owned-register mask *and* a gc-register mask at every
trap-capable pc, and the class table carries per-field kinds. Go's dependence
on OS threads is incidental, not load-bearing: what the algorithm actually
requires is the ability to suspend one stack and look up a PC→pointer map per
frame, which a bytecode VM with an explicit value stack satisfies without
native stacks. Non-moving matches the arena. The per-P coordination machinery
(ragged barrier, write-barrier buffers, work stealing, assist credit) exists
to run N mutators over one heap and is deletable in a per-shard design.
## Decisions locked during brainstorming
| Question | Decision |
| --- | --- |
| Scope | **One spec: infer *and* replace the collector.** Dropping `@gc` without replacing RC would widen the exact bug class that has already bitten four times. |
| What decides GC-ness | **Hybrid: structural + reported promotion.** Cycles from a whole-program SCC over the class-reference graph; long-lived aliasing promotes on demand at the site that would otherwise error. Every promotion is reported. |
| Collector cadence | **Incremental tri-color, budgeted slices, with a Yuasa deletion barrier.** Bounded pause regardless of heap size — which principle 3 already promises and the `budget-steps` fixture already asserts. |
| Barrier scope | Pointer stores into traced objects only, and only while marking is active. Owned objects, scalars and text pay nothing. |
| `@gc` in source | **Errors**, with a diagnostic pointing at inference. Accepting a now-meaningless annotation would be a lie surface. |
Rejected: structural-only inference (leaves `PriceCache`-shaped types still
needing an annotation); demand-only inference (a distant edit silently flips a
type's memory strategy with nothing reporting it); stop-the-shard full trace
(no barrier, but the pause grows with the live set and principle 3's
"budgeted" wording would have to weaken); post-exit-only collection
(contradicts principle 6 — a service that never stops never collects);
sequencing the collector before inference (defensible, and rejected only
because the coupling argument above makes one coherent landing cheaper than
two).
## 1. Inference — `compiler/src/gcinfer.ml`
A new pass between `types` and `owner`.
**Structural half.** Build a class-reference graph: an edge from A to B when A
has a field whose type is B, `multi B`, `map<B, _>`, `map<_, B>`, or any of
those under `?`. **`ref B` creates no edge** — it is an id, not a pointer, and
already classifies as `Copy`. Run Tarjan's SCC. Every class in a non-trivial
SCC, or with a self-loop, is traced. This is decidable from declarations
alone, so it is stable under edits elsewhere in the program.
**Demand half.** Run the ownership analysis in a collect-promotions mode: at
each site where it would report an escape or aliasing error (today's
`WO-E304` and the long-lived-alias cases), record the class rather than the
error. Promote all recorded classes, then re-run ownership. The promoted set
only grows and is bounded by the class count, so this terminates; two owner
passes is the worst case in practice because promotion removes errors and
never creates them.
**Everything is reported.** Each promotion emits a note stating the reason —
the cycle path for a structural promotion, the escape site for a demand
promotion. `--dump-gc` renders the whole classification, which is the
golden-testable artifact:
```
Cache gc (alias escape, cache.wo:12)
Node gc (cycle Node -> Node)
Product owned
Price owned
```
**Field kinds follow.** A field whose type is a traced class derives
`WO_K_GCREF` automatically; `types.ml`'s existing derivation reads the
inferred set instead of `is_gc_class`.
**The annotation is removed.** The parser's `| "gc" -> is_gc := true` arm
becomes a diagnostic in the WO-E1xx range naming the inference pass and
`--dump-gc`. `class_info.is_gc` and `Types.is_gc_class` are replaced by the
inferred set; `dump.ml` stops rendering ` @gc`.
## 2. What the compiler emits
- **`RC_INC` / `RC_DEC` are no longer emitted.** Opcodes 27–28 become
reserved. This is a `.wob` version bump, recorded in the format doc.
- **No barrier opcode and no emitter barrier.** `SETF` already resolves the
field kind from the class table, so the barrier lives inside the VM's store
paths (`SETF`, `map_set`, `push`). Zero new opcodes, zero emitter change for
the barrier — a deliberate contrast with Go, which must insert barrier calls
because it compiles to machine code.
- **The drop table's gc mask keeps its bits and changes contract.** It stops
meaning "`rc_dec` these registers while unwinding" and starts meaning
"these registers are GC roots at this pc". The owned mask is unchanged, and
`DROP` placement for owned values is unchanged.
- **`wo_drop_kind` for `WO_K_GCREF` becomes a no-op** — tracing owns the
lifetime of traced objects, so an owned object dying never frees them.
- The class table's `@gc` bit survives with the same encoding; only its
*source* changes from annotation to inference.
Everything else the emitter does — register allocation, window calls, moves,
owned drops, residual borrow guards, line tables — is untouched. Inference
changes which classes are traced, not how anything is lowered.
## 3. Runtime — header and the sweep list
**The arena cannot enumerate objects.** It is bump allocation plus 16-byte
size-class free lists, with anything over 1024 bytes falling through to bare
`malloc`; `wo_arena_free` requires the caller to pass the size back, and no
size headers exist anywhere. Sweep therefore needs its own object list. This
is the single largest runtime addition in this spec.
**The header stays exactly 16 bytes.** Retiring `rc` frees four bytes, and
traced objects are exempt from borrow rules so their `borrow` word is dead
too. Those two adjacent words give exactly eight contiguous bytes — one
64-bit intrusive list link. The `_Static_assert(sizeof(wo_hdr) == 16)` and the
format doc's layout size both survive unchanged.
**A per-shard traced list.** `wo_rt` gains a list head; every traced
allocation links itself in. Sweep walks the list, recovers each object's size
from the class table via `wo_obj_size`, frees the unmarked, and unlinks. The
existing cycle-candidate buffer (`cycbuf`) and the `WO_F_BUF` flag are
retired — they exist only to serve trial deletion.
## 4. Runtime — incremental marking and the barrier
**Colors.** The two existing color bits (`WO_F_COLOR`) carry white/grey/black.
No header growth.
**Roots.** The VM's value stack and frame stack, read through the per-pc gc
masks the emitter already emits — one mask lookup per live frame, exactly the
mechanism Go gets from `FUNCDATA_LocalsPointerMaps` but already present here.
**Owned objects are traversed, never freed.** An owned object can hold a
`GCREF` field, so tracing must walk through owned subtrees to find traced
objects. To stop that costing the whole owned graph, the class table gains a
precomputed **"transitively contains a gcref"** bit, so owned subtrees that
cannot reach a traced object are skipped outright. This bit is derivable in
the same pass that computes the SCCs.
**Safepoints** go at loop back-edges and calls — the pcs that already carry
drop-table entries, so no new metadata is needed.
**Barrier.** Yuasa deletion barrier, active only while marking: on a pointer
store into a traced slot, shade the *old* value before overwriting it. This is
the half of Go's hybrid barrier that eliminates stack rescanning; the
Dijkstra insertion half is unnecessary because a shard's own stack is
re-read from its masks at each slice rather than being scanned once and
trusted.
**Budget and trigger.** `WO_GC_BUDGET` keeps its name and meaning — objects
marked per slice. The cycle starts on a heap goal over the shard's
traced-bytes since the last cycle. `WO_GC_TRACE` keeps its stderr trace, with
freed/marked counts per slice.
## 5. Error handling
No new error mechanism. The barrier and the collector cannot fail: allocation
failure already traps `WO_T_OOM`, and a sweep-list allocation failure does not
exist because the link is inside the object. Inference reports notes, never
traps. The one genuinely new failure mode is a **barrier bug**, which
manifests as silent corruption rather than a diagnostic — §7 addresses it with
a dedicated adversarial test rather than a runtime check.
## 6. What this deletes
Recording this plainly, because it is the design's main argument:
- `gc.c`'s trial deletion — `mark_gray`, `scan_black`, `scan_`,
`collect_white`, `white_free`, the candidate buffer, the zombie guard.
- `owner.ml`'s rc machinery — the rc table, elision groups, `rc_escaped`,
`gc_escape`, `resolve_rc`, `release_gc`, and the clobber rule that exists
only to invalidate elision.
- `emit.ml`'s `emit_rc` and the escape-acquire anchor.
- The `RC_INC`/`RC_DEC` interpreter cases.
- **All four recorded `@gc` defects**, by construction: the `set` gap has no
`RC_INC` to omit; the held-cycle leak was rc inflation from an unreleased
return value, and with tracing that value simply is not a root; the
`mut`-`@gc` clobber protected an elision that no longer exists; the `push`
bug cannot recur.
## 7. Testing
- **The barrier is the load-bearing test.** An adversarial fixture where the
mutator hides a traced object between marking slices — store it into an
already-blackened object and drop the original reference — must not free it.
A barrier bug is silent corruption, so this test is the design's safety net
and must fail loudly if the barrier is compiled out.
- **Inference:** unit tests for SCC classification (self-loop, mutual
recursion, `multi Self`, `map<_, Self>`, and the `ref T`-creates-no-edge
case), promotion cases, and golden `--dump-gc` output including the note
text.
- **Collector:** `runtime/test/test_cycle.c` and `test_rc.c` are rewritten —
they currently assert rc values, which cease to exist. New assertions: an
abandoned cycle is freed, a rooted cycle survives, slices are bounded, and
the sweep list has no leak after N cycles.
- **Corpus:** `tests/corpus/gc/abandoned-cycle` and `budget-steps` survive
with re-blessed traces. `held-cycle` is **redefined** — with tracing, a
post-exit heap has no roots at all, so the honest fixture is "a cycle rooted
from a live frame survives a slice", tested from inside a running program
rather than after the entry returns.
- **ASan across the corpus**, as today, plus a leak-free assertion after
repeated collection cycles.
- The milestone-1 acceptance gate's criterion 4 is restated in terms of
tracing; criterion 3's ASan clause is expected to go green, since the
held-cycle leak is one of the defects this deletes.
## 8. Migration — normative claims to amend
| Where | Change |
| --- | --- |
| `docs/00-principles.md` principle 3 | "`@gc` is a per-class opt-in" → GC-ness is inferred; keep "no global pause exists by construction" (still true — per-shard, and other shards never stop) |
| OOP spec decision table, GC-granularity row | per-class annotation → inferred, with the hybrid rule named |
| OOP spec §3 rule 5 | "`@gc` class instances alias freely" → traced classes alias freely, and which classes those are is inferred |
| OOP spec §4 memory model | replace the RC + Bacon–Rajan paragraph with tracing; header `rc` → sweep-list link; drop `IN_CYCLE_BUF` |
| `00-wob-format.md` | opcodes 27–28 reserved; version bump; drop-table gc-mask contract restated as GC roots; class-flag provenance |
| `01-error-catalog.md` | WO-W201 (`@gc` suggestion) retired — inference supersedes it; WO-E304's `@gc`-exemption wording updated; new WO-E1xx for `@gc` in source |
| `08-builtin-surface.md` | the `push` special case and the `set` gap both deleted — neither exists without RC |
| Goldens | every fixture rendering ` @gc`, `flags=gc`, `gc={rN}`, `RC_INC`/`RC_DEC`, or the `== RC ==` table section re-blessed |
| `docs/00-status.md` | records this as the iteration that supersedes part of iteration 2's memory model |
## Success criteria
1. No `.wo` file in the repo contains `@gc`, and using it is a diagnostic.
2. `--dump-gc` classifies every class in the pricing and corpus samples, and
every traced class's reason is either a cycle path or a named escape site.
3. `RC_INC`/`RC_DEC` appear in no emitted image; the opcodes are reserved in
the format doc.
4. The adversarial barrier fixture fails when the barrier is compiled out and
passes when it is in.
5. An abandoned cycle is collected within budgeted slices with no slice
exceeding the configured budget; a rooted cycle survives.
6. The whole corpus is ASan-clean, including after repeated collection cycles
— closing milestone-1 criterion 3.
## Out of scope
Cross-shard tracing (ownership moves mean no traced object spans shards);
generational collection (no remembered set, no age bits — Go's isn't
generational either); compaction (non-moving is load-bearing: no forwarding
pointers, no read barrier); scheduler-integrated pacing beyond the heap-goal
trigger, which remains sub-project 2's concern; and `ref T` semantics, which
are unchanged.

View file

@ -34,6 +34,95 @@ woc-build:
woc-test:
dune runtest --root compiler
# wovm (runtime/): build the plain, optimized binary — the release build,
# no sanitizer (wovm-test is the ASan-clean gate; `make -C runtime wovm-asan`
# builds a separate sanitized binary for the corpus, see oop-accept)
wovm-build:
make -C runtime wovm
# wovm gate: unit suites (both dispatch flavors: computed-goto + ISO switch
# under -DWO_ISO_C, so neither rots) + CLI smoke, all ASan+UBSan
wovm-test:
make -C runtime test
make -C runtime test-iso
bash runtime/test/cli_smoke.sh
# conformance harness (plan 3): walks tests/corpus/{run,compile-fail,trap},
# exact outcome per fixture kind — see docs/plan/oop-vm/02-corpus.md.
# Fails loudly (and names the recipe to run) if woc or wovm isn't built.
oop-e2e:
./scripts/oop-e2e.sh
# milestone-1 acceptance gate (docs/superpowers/specs/2026-08-01-oop-compiler-vm-design.md
# "Success criteria"): the five spec criteria plus both unit gates, one
# command, in the spec's order. Fails loudly on the first failing stage,
# names the criterion, exits nonzero — a measurement that only prints is
# not a gate.
oop-accept:
#!/usr/bin/env bash
set -uo pipefail
ROOT="$(pwd)"
fail() { echo "oop-accept: FAILED -- $1" >&2; exit 1; }
WORK="$(mktemp -d "${TMPDIR:-/tmp}/oop-accept.XXXXXX")"
trap 'rm -rf "$WORK"' EXIT
echo "=== criterion 1: woc compile time, pricing subset (budget: under 100ms) ==="
dune build --root compiler || fail "criterion 1: dune build --root compiler"
WOC="$ROOT/compiler/_build/default/bin/woc"
PRICING="tests/corpus/run/pricing-containers/fixture.wo tests/corpus/run/pricing-current-price/fixture.wo tests/corpus/run/pricing-discounted/fixture.wo tests/corpus/run/pricing-text/fixture.wo tests/corpus/trap/pricing-set-price-db-stub/fixture.wo"
N=20
total_ns=0; max_ns=0; min_ns=""
for i in $(seq 1 "$N"); do
start=$(date +%s%N)
for f in $PRICING; do
"$WOC" --emit "$f" -o "$WORK/pricing.wob" || fail "criterion 1: woc --emit $f"
done
end=$(date +%s%N)
elapsed=$((end - start))
total_ns=$((total_ns + elapsed))
[[ -z "$min_ns" || elapsed -lt min_ns ]] && min_ns=$elapsed
[[ elapsed -gt max_ns ]] && max_ns=$elapsed
done
avg_ms=$(awk -v t="$total_ns" -v n="$N" 'BEGIN{printf "%.3f", t/n/1000000}')
min_ms=$(awk -v t="$min_ns" 'BEGIN{printf "%.3f", t/1000000}')
max_ms=$(awk -v t="$max_ns" 'BEGIN{printf "%.3f", t/1000000}')
echo " woc --emit over all 5 pricing-subset fixtures, $N runs: min ${min_ms}ms avg ${avg_ms}ms max ${max_ms}ms"
[[ "$max_ns" -lt 100000000 ]] || fail "criterion 1: worst case ${max_ms}ms meets/exceeds the 100ms budget"
echo " criterion 1: MET"
echo "=== criteria 2-4: full conformance corpus under ASan (runtime/build/wovm_asan) ==="
make -C runtime wovm || fail "criteria 2-4: make -C runtime wovm"
make -C runtime wovm-asan || fail "criteria 2-4: make -C runtime wovm-asan"
cp "$ROOT/runtime/wovm" "$WORK/wovm.release"
restore_wovm() { cp "$WORK/wovm.release" "$ROOT/runtime/wovm"; }
echo " swapping runtime/wovm -> runtime/build/wovm_asan for this stage only (oop-e2e.sh has no --wovm override)"
cp "$ROOT/runtime/build/wovm_asan" "$ROOT/runtime/wovm"
if ./scripts/oop-e2e.sh; then
restore_wovm
else
restore_wovm
fail "criteria 2-4: conformance corpus failed under ASan (see output above)"
fi
echo " runtime/wovm restored to the release binary"
echo " criteria 2-4: MET"
echo "=== criterion 5: single-binary smoke (runtime/wovm, release binary) ==="
./scripts/single-binary-smoke.sh || fail "criterion 5: single-binary smoke"
echo " criterion 5: MET"
echo "=== unit gate: runtime (both dispatch flavors + cli_smoke) ==="
make -C runtime test || fail "runtime unit gate: make -C runtime test"
make -C runtime test-iso || fail "runtime unit gate: make -C runtime test-iso"
bash runtime/test/cli_smoke.sh || fail "runtime unit gate: cli_smoke"
echo " runtime unit gate: MET"
echo "=== unit gate: compiler ==="
dune runtest --root compiler || fail "compiler unit gate: dune runtest --root compiler"
echo " compiler unit gate: MET"
echo
echo "oop-accept: ALL CRITERIA MET"
# phase-F benchmark: reads, durable writes, 10k idle conns (scaled geometry)
rt-c-bench port="8085" threads="8" conns="64":
#!/usr/bin/env bash

View file

@ -40,6 +40,14 @@ test-iso: $(ISOBIN)
wovm: src/main.c $(VMSRC) $(VMHDR)
$(CC) $(CFLAGS) -Isrc -o $@ src/main.c $(VMSRC)
# ASan+UBSan wovm, same flags as the unit tests, for corpus fixtures that
# need a sanitizer to prove a free actually happened (gc/ cycle fixtures) —
# tasks 3/4 hand-built this each time because it didn't exist yet
build/wovm_asan: src/main.c $(VMSRC) $(VMHDR) | build
$(CC) $(TCFLAGS) -Isrc -o $@ src/main.c $(VMSRC)
wovm-asan: build/wovm_asan
# fixture generator for the CLI smoke test
build/mkwob: test/mkwob.c test/wob_build.c $(VMHDR) | build
$(CC) $(CFLAGS) -Isrc -Itest -o $@ test/mkwob.c test/wob_build.c
@ -56,4 +64,4 @@ clean:
rm -f wo-rt bench/bench wovm
rm -rf build
.PHONY: bench run clean test test-iso
.PHONY: bench run clean test test-iso wovm-asan

View file

@ -82,19 +82,19 @@ Single-shot RECV re-armed per request (multishot recv + buffer rings are a phase
## The wovm bytecode VM (runtime/src/)
Milestone 1 of the OOP track (spec: [`docs/superpowers/specs/2026-08-01-oop-compiler-vm-design.md`](../docs/superpowers/specs/2026-08-01-oop-compiler-vm-design.md), plan 1: [`docs/superpowers/plans/2026-08-01-wob-format-and-vm-core.md`](../docs/superpowers/plans/2026-08-01-wob-format-and-vm-core.md)) — a register VM that executes `.wob` bytecode (format: [`docs/plan/oop-vm/00-wob-format.md`](../docs/plan/oop-vm/00-wob-format.md)) with the full milestone-1 memory model. C11, libc only, same doctrine as `wo-rt.c`.
Milestone 1 of the OOP track (spec: [`docs/superpowers/specs/2026-08-01-oop-compiler-vm-design.md`](../docs/superpowers/specs/2026-08-01-oop-compiler-vm-design.md), plan 1: [`docs/superpowers/plans/2026-08-01-wob-format-and-vm-core.md`](../docs/superpowers/plans/2026-08-01-wob-format-and-vm-core.md)) — a register VM that executes `.wob` bytecode (format: [`docs/plan/oop-vm/00-wob-format.md`](../docs/plan/oop-vm/00-wob-format.md)) with the full milestone-1 memory model. C11, libc only, same doctrine as `wo-rt.c`. `wovm` doesn't produce `.wob` itself — that's the OCaml `woc` front end's job ([`compiler/README.md`](../compiler/README.md), plan 3); this directory is the VM, not the compiler.
**Shipped features:**
- **Register interpreter** — fixed 32-bit instructions, Lua-style window-overlap calls (callee r0 = caller slot A), dual dispatch: computed goto under GNU C, `switch` under `-DWO_ISO_C` (both flavors gated in CI so neither rots).
- **Owned objects with a runtime borrow word** — shared-reader count / exclusive sentinel in every 16-byte header; violations trap `T_BORROW`. The compiler elides provable sites; the VM enforces the residual ones (hybrid model, spec §4).
- **`@gc` reference counting + budgeted cycle collection** — rc at zero frees immediately; possible cycles buffer as candidates (Bacon–Rajan trial deletion), collected in budgeted epochs per shard — no stop-the-world by construction.
- **`@gc` reference counting + budgeted cycle collection** — rc at zero frees immediately; possible cycles buffer as candidates (Bacon–Rajan trial deletion), collected in budgeted epochs per shard — no stop-the-world by construction. The `wovm` CLI pumps the collector to quiescence after the entry method returns (`WO_GC_BUDGET` steps per call, default 64; `WO_GC_TRACE=1` prints one stderr line per step) — scheduler-paced stepping between requests is sub-project 2, not this milestone.
- **Deterministic drops** — kind-directed drop plans (scalar/owned/gcref/text/multi/map), recursive over class fields and container elements.
- **Trap unwinding that never leaks** — per-method drop tables (pc → owned/gc register masks); a trap walks every frame and frees what was live; structured error `{code, method, line, message}` via line tables.
- **Validating loader** — bounds-checked parse, aligned copies, const-string interning, full static validation (opcodes, registers, indexes, jump targets, terminators, builtin arity, call windows, sorted vtables); what the loader accepts, the interpreter trusts — no UB on any input.
- **Structural interfaces** — `ICALL` binary-searches sorted (class, slot, method) vtable triples by receiver class.
- **Native containers + builtins** — `multi`/`map` with element-kind tags; `now/print/print_int/words/multi_*/map_*`; `DB_STUB` traps "engine not linked" until the DB engine binds (plan 5).
- **CLI contract** — `wovm app.wob`: exit 0 = ran; exit 1 = trap, one stderr line `trap CODE in METHOD at line N: MESSAGE`; exit 2 = usage/load failure. `WO_HEAP_MB` overrides the 64 MiB arena.
- **CLI contract** — `wovm app.wob`: exit 0 = ran; exit 1 = trap, one stderr line `trap CODE in METHOD at line N: MESSAGE`; exit 2 = usage/load failure. `WO_HEAP_MB` overrides the 64 MiB arena. Run with no `.wob` argument, `wovm` also checks its own trailer for an appended image (`woc build`'s single-binary output, [`docs/plan/oop-vm/00-wob-format.md`](../docs/plan/oop-vm/00-wob-format.md)'s "single-binary trailer" section) — a recognized-but-corrupt trailer fails clearly on exit 2, never a crash.
**File map:**
@ -108,14 +108,14 @@ Milestone 1 of the OOP track (spec: [`docs/superpowers/specs/2026-08-01-oop-comp
| `src/loader.h/.c` | `.wob` parse + full static validation + mmap file path |
| `src/vm.h/.c` | the interpreter: dispatch, frames, traps, drop-map unwinding, `ICALL` |
| `src/builtin.h/.c` | builtin dispatcher |
| `src/main.c` | the `wovm` CLI |
| `src/main.c` | the `wovm` CLI: arg parsing, self-embedded-trailer detection, the post-exit gc pump |
| `test/t.h` | 20-line assert harness (no framework) |
| `test/wob_build.h/.c` | in-memory `.wob` assembler — the second, independent encoding of the format; builder/loader disagreements fail tests |
| `test/test_*.c` | 13 suites, one binary each, ASan+UBSan |
| `test/mkwob.c` | fixture generator for the CLI smoke |
| `test/cli_smoke.sh` | end-to-end exit-code/stderr-shape check |
**Gates:** `just wovm-build` · `just wovm-test` (unit suites + ISO flavor + CLI smoke, all ASan-clean) · in `runtime/`: `make test`, `make test-iso`, `make wovm`.
**Gates:** `just wovm-build` · `just wovm-test` (unit suites + ISO flavor + CLI smoke, all ASan-clean) · in `runtime/`: `make test`, `make test-iso`, `make wovm`, `make wovm-asan` (sanitized binary for corpus fixtures that need a leak/UB proof, e.g. `tests/corpus/gc/`). Across both halves: `just oop-e2e` (the `woc` + `wovm` conformance corpus, [`compiler/README.md`](../compiler/README.md)) and `just oop-accept` (milestone 1's full acceptance gate — spec success criteria + both unit suites, one command).
## Debugging

View file

@ -3,24 +3,141 @@
* 1 = trap; one stderr line: "trap CODE in METHOD at line N: MESSAGE"
* 2 = usage or load failure (loader's message on stderr)
* Heap cap defaults to 64 MiB, overridable via WO_HEAP_MB. */
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <unistd.h>
#include "gc.h"
#include "vm.h"
static wo_vm VM; /* 32K value stack: keep it off the C stack */
int main(int argc, char **argv) {
if (argc != 2) {
fprintf(stderr, "usage: wovm <file.wob>\n");
return 2;
/* ---- self-exec detection (Task 6, plan 3) -------------------------------
* `woc build` makes a single executable by copying wovm and appending the
* .wob image plus a fixed-size trailer; docs/plan/oop-vm/00-wob-format.md's
* "single-binary trailer" section is the normative layout (writer:
* compiler/bin/main.ml) -- keep this reader in lock-step with it. Reading
* this executable's own path via /proc/self/exe is Linux-only, matching
* wob.h's own platform note. */
#define WO_TRAILER_MAGIC 0x31544257u /* "WBT1" read as LE u32 */
#define WO_TRAILER_SIZE 20u /* payload_off u64, payload_len u64, magic u32 */
/* 1 = embedded image found and loaded into *mod (caller must ignore argv);
* 0 = no trailer (plain wovm binary; caller falls back to argv[1] as
* today); -1 = a trailer is present but corrupt (err filled; caller must
* report and exit -- never guess or run something unintended). */
static int load_self_embedded(wo_module *mod, char *err, size_t errlen) {
int fd = open("/proc/self/exe", O_RDONLY);
if (fd < 0) return 0;
struct stat st;
if (fstat(fd, &st) != 0 || st.st_size < 0) {
close(fd);
return 0;
}
size_t size = (size_t)st.st_size;
if (size < WO_TRAILER_SIZE) {
close(fd);
return 0;
}
uint8_t tail[WO_TRAILER_SIZE];
if (lseek(fd, (off_t)(size - WO_TRAILER_SIZE), SEEK_SET) < 0 ||
read(fd, tail, WO_TRAILER_SIZE) != (ssize_t)WO_TRAILER_SIZE) {
close(fd);
return 0;
}
uint32_t magic;
memcpy(&magic, tail + 16, 4);
if (magic != WO_TRAILER_MAGIC) {
close(fd);
return 0; /* plain wovm binary, nothing embedded */
}
uint64_t payload_off, payload_len;
memcpy(&payload_off, tail + 0, 8);
memcpy(&payload_len, tail + 8, 8);
/* payload must exactly fill everything between its offset and the
* trailer -- no gap, no overlap. Bounding payload_off first makes the
* subtraction below safe (no unsigned wraparound on a corrupt value). */
if (payload_off > size - WO_TRAILER_SIZE) {
close(fd);
snprintf(err, errlen, "corrupt trailer (bad payload offset)");
return -1;
}
if (payload_len != size - WO_TRAILER_SIZE - payload_off) {
close(fd);
snprintf(err, errlen, "corrupt trailer (bad payload length)");
return -1;
}
void *p = mmap(NULL, size, PROT_READ, MAP_PRIVATE, fd, 0);
close(fd);
if (p == MAP_FAILED) {
snprintf(err, errlen, "cannot mmap self");
return -1;
}
int rc = wo_load_buf(mod, (const uint8_t *)p + payload_off, (size_t)payload_len, err, errlen);
munmap(p, size);
return rc == 0 ? 1 : -1;
}
/* ---- gc pump -----------------------------------------------------------
* Deliberately the simplest possible driver over the plan-1 collector's
* already-budgeted step interface (wo_gc_step, gc.h): after the entry
* method returns, drain the cycle-candidate buffer in bounded slices until
* it is empty. This is post-exit-only pacing and nothing more — real
* scheduler-integrated pacing (stepping between turns of live work while
* the program keeps running) is sub-project 2's job; this milestone only
* proves the budgeted-step interface end to end and makes it observable.
* WO_GC_TRACE prints one stderr line per step (never stdout — the corpus
* harness diffs stdout byte-for-byte) so a fixture can assert "collection
* happened in bounded slices", not just "the leak is gone". */
static void gc_pump(wo_vm *vm) {
size_t budget = 64; /* candidates per step: no prior art to size this
against (post-exit draining is new), so picked
to mirror WO_HEAP_MB's default 64 — small
enough that a deliberately oversized abandoned
structure visibly takes more than one step,
large enough that ordinary programs clear in
one or two */
const char *benv = getenv("WO_GC_BUDGET");
if (benv && benv[0]) {
char *end = NULL;
unsigned long v = strtoul(benv, &end, 10);
if (end && *end == '\0' && v >= 1 && v <= 1000000) budget = v;
}
int trace = getenv("WO_GC_TRACE") != NULL;
size_t step = 0;
while (vm->rt.cycbuf.len > 0) {
size_t before = vm->rt.cycbuf.len;
size_t freed = wo_gc_step(&vm->rt, budget);
step++;
if (trace)
fprintf(stderr, "gc: step %zu budget=%zu freed=%zu visited=%zu remaining=%zu\n", step,
budget, freed, before - vm->rt.cycbuf.len, vm->rt.cycbuf.len);
}
}
int main(int argc, char **argv) {
wo_module mod;
char err[256];
if (wo_load_file(&mod, argv[1], err, sizeof err) != 0) {
int self_rc = load_self_embedded(&mod, err, sizeof err);
if (self_rc < 0) {
fprintf(stderr, "wovm: %s\n", err);
return 2;
}
if (self_rc == 0) {
/* no embedded image: today's contract, unchanged */
if (argc != 2) {
fprintf(stderr, "usage: wovm <file.wob>\n");
return 2;
}
if (wo_load_file(&mod, argv[1], err, sizeof err) != 0) {
fprintf(stderr, "wovm: %s\n", err);
return 2;
}
}
if (mod.entry == WOB_NONE) {
fprintf(stderr, "wovm: module has no entry method\n");
wo_module_free(&mod);
@ -44,6 +161,7 @@ int main(int argc, char **argv) {
if (rc != 0)
fprintf(stderr, "trap %u in %s at line %u: %s\n", (unsigned)terr.code,
terr.method, (unsigned)terr.line, terr.msg);
gc_pump(&VM);
wo_vm_destroy(&VM);
wo_module_free(&mod);
return rc == 0 ? 0 : 1;

334
scripts/oop-e2e.sh Executable file
View file

@ -0,0 +1,334 @@
#!/usr/bin/env bash
# scripts/oop-e2e.sh — the conformance harness (plan 3, Task 2).
#
# Walks tests/corpus/{run,compile-fail,trap,gc}/*/ and enforces one exact
# outcome per fixture kind (docs/plan/oop-vm/02-corpus.md has the
# contribution contract this script is the enforcement of):
#
# run/ fixture.wo compiles with woc and runs with wovm;
# stdout must equal fixture.out BYTE-FOR-BYTE.
# compile-fail/ fixture.wo must fail to compile with exactly the
# WO-E### named in fixture.code.
# trap/ fixture.wo must compile, then wovm must trap with
# exactly the code named in fixture.trap, parsed from
# wovm's fixed stderr line
# ("trap N in METHOD at line L: MESSAGE").
# gc/ fixture.wo compiles and runs with wovm under
# WO_GC_TRACE=1 (and WO_GC_BUDGET from the optional
# fixture.gc_budget); stdout must equal fixture.out
# byte-for-byte, and the gc pump's stderr trace must
# match fixture.trace's step count and total freed
# count exactly.
#
# Any other outcome — wrong code, unexpected success, a loader
# rejection, a crash, a hang — fails and names the fixture. One line
# per fixture, a final tally, nonzero exit if anything failed.
set -uo pipefail # no -e: a failing fixture is handled explicitly, one at a time
shopt -s nullglob
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
WOC="$ROOT/compiler/_build/default/bin/woc"
WOVM="$ROOT/runtime/wovm"
CORPUS="$ROOT/tests/corpus"
# A hang in either binary must not block `just oop-e2e`/CI forever with no
# diagnostic — every invocation below runs under `timeout`, and a kill
# (exit 124, timeout(1)'s own signal for "I killed it") is reported as its
# own named failure, never folded into "exited nonzero". Overridable for a
# slower box; fixtures in this corpus are small enough that the default is
# generous, not tight.
TIMEOUT="${OOP_E2E_TIMEOUT:-10}"
if [[ ! -x "$WOC" ]]; then
echo "oop-e2e: woc is not built ($WOC) — run: just woc-build" >&2
exit 1
fi
if [[ ! -x "$WOVM" ]]; then
echo "oop-e2e: wovm is not built ($WOVM) — run: make -C runtime wovm" >&2
exit 1
fi
WORK="$(mktemp -d "${TMPDIR:-/tmp}/oop-e2e.XXXXXX")"
trap 'rm -rf "$WORK"' EXIT
pass=0
fail=0
ok() {
echo "ok $1"
pass=$((pass + 1))
}
bad() {
echo "FAIL $1 -- $2"
fail=$((fail + 1))
}
# One scratch-file prefix per fixture (kind+name is unique across the
# whole corpus), so no per-file mktemp calls are needed inside the loop.
tmp_prefix() {
echo "$WORK/$(echo "$1" | tr '/' '-')"
}
# Every WO-E### a run actually reported, anchored on the diagnostic
# renderer's own text (diag.ml's `render`: "file:line:col: error CODE:
# message") -- not a bare substring search. This is what makes matching
# exact both ways: an expected "WO-E21" cannot match a reported "WO-E215"
# (Critical 1, review round 1), and an empty/garbled fixture.code cannot
# match a stray occurrence of the right digits inside a message body.
extract_error_codes() {
grep -oE 'error (WO-E[0-9]+):' "$1" | sed -E 's/error (WO-E[0-9]+):/\1/'
}
run_fixture() {
local dir="$1" name="run/$(basename "$1")"
local prefix wob out err rc
if [[ ! -f "$dir/fixture.wo" ]]; then bad "$name" "missing fixture.wo"; return; fi
if [[ ! -f "$dir/fixture.out" ]]; then bad "$name" "missing fixture.out"; return; fi
prefix="$(tmp_prefix "$name")"
wob="$prefix.wob" out="$prefix.out" err="$prefix.err"
timeout "$TIMEOUT" "$WOC" --emit "$dir/fixture.wo" -o "$wob" >/dev/null 2>"$err"
rc=$?
if [[ $rc -eq 124 ]]; then
bad "$name" "woc timed out after ${TIMEOUT}s"
return
elif [[ $rc -gt 128 ]]; then
bad "$name" "woc crashed (signal $((rc - 128)))"
return
elif [[ $rc -ne 0 ]]; then
bad "$name" "compile failed (exit $rc): $(head -1 "$err")"
return
fi
timeout "$TIMEOUT" "$WOVM" "$wob" >"$out" 2>"$err"
rc=$?
if [[ $rc -eq 124 ]]; then
bad "$name" "wovm timed out after ${TIMEOUT}s"
return
elif [[ $rc -eq 1 ]]; then
bad "$name" "unexpected trap: $(head -1 "$err")"
return
elif [[ $rc -eq 2 ]]; then
bad "$name" "loader rejection: $(head -1 "$err")"
return
elif [[ $rc -gt 128 ]]; then
bad "$name" "wovm crashed (signal $((rc - 128)))"
return
elif [[ $rc -ne 0 ]]; then
bad "$name" "wovm exited $rc: $(head -1 "$err")"
return
fi
if ! diff -q "$dir/fixture.out" "$out" >/dev/null 2>&1; then
bad "$name" "stdout mismatch"
diff -u "$dir/fixture.out" "$out" | sed 's/^/ /'
return
fi
ok "$name"
}
gc_fixture() {
local dir="$1" name="gc/$(basename "$1")"
local prefix wob out err rc exp_steps exp_freed got_steps got_freed budget
if [[ ! -f "$dir/fixture.wo" ]]; then bad "$name" "missing fixture.wo"; return; fi
if [[ ! -f "$dir/fixture.out" ]]; then bad "$name" "missing fixture.out"; return; fi
if [[ ! -f "$dir/fixture.trace" ]]; then bad "$name" "missing fixture.trace"; return; fi
prefix="$(tmp_prefix "$name")"
wob="$prefix.wob" out="$prefix.out" err="$prefix.err"
timeout "$TIMEOUT" "$WOC" --emit "$dir/fixture.wo" -o "$wob" >/dev/null 2>"$err"
rc=$?
if [[ $rc -eq 124 ]]; then
bad "$name" "woc timed out after ${TIMEOUT}s"
return
elif [[ $rc -gt 128 ]]; then
bad "$name" "woc crashed (signal $((rc - 128)))"
return
elif [[ $rc -ne 0 ]]; then
bad "$name" "compile failed (exit $rc): $(head -1 "$err")"
return
fi
exp_steps="$(grep -oE 'steps=[0-9]+' "$dir/fixture.trace" | head -1 | cut -d= -f2)"
exp_freed="$(grep -oE 'freed=[0-9]+' "$dir/fixture.trace" | head -1 | cut -d= -f2)"
if [[ -z "$exp_steps" || -z "$exp_freed" ]]; then
bad "$name" "fixture.trace missing steps=/freed="
return
fi
# WO_GC_TRACE is always on so the pump's stderr trace can be checked;
# WO_GC_BUDGET is only set when the fixture names one (fixture.gc_budget
# is optional — absent means "use the pump's own default").
if [[ -f "$dir/fixture.gc_budget" ]]; then
budget="$(tr -d '[:space:]' <"$dir/fixture.gc_budget")"
timeout "$TIMEOUT" env WO_GC_TRACE=1 WO_GC_BUDGET="$budget" "$WOVM" "$wob" >"$out" 2>"$err"
else
timeout "$TIMEOUT" env WO_GC_TRACE=1 "$WOVM" "$wob" >"$out" 2>"$err"
fi
rc=$?
if [[ $rc -eq 124 ]]; then
bad "$name" "wovm timed out after ${TIMEOUT}s"
return
elif [[ $rc -eq 1 ]]; then
bad "$name" "unexpected trap: $(head -1 "$err")"
return
elif [[ $rc -eq 2 ]]; then
bad "$name" "loader rejection: $(head -1 "$err")"
return
elif [[ $rc -gt 128 ]]; then
bad "$name" "wovm crashed (signal $((rc - 128)))"
return
elif [[ $rc -ne 0 ]]; then
bad "$name" "wovm exited $rc: $(head -1 "$err")"
return
fi
if ! diff -q "$dir/fixture.out" "$out" >/dev/null 2>&1; then
bad "$name" "stdout mismatch"
diff -u "$dir/fixture.out" "$out" | sed 's/^/ /'
return
fi
got_steps="$(grep -c '^gc: step ' "$err")"
got_freed="$(grep -oE 'freed=[0-9]+' "$err" | cut -d= -f2 | awk '{s += $1} END {print s + 0}')"
if [[ "$got_steps" != "$exp_steps" || "$got_freed" != "$exp_freed" ]]; then
bad "$name" "gc trace mismatch: expected steps=$exp_steps freed=$exp_freed, got steps=$got_steps freed=$got_freed"
return
fi
ok "$name"
}
compile_fail_fixture() {
local dir="$1" name="compile-fail/$(basename "$1")"
local prefix wob err rc expected
if [[ ! -f "$dir/fixture.wo" ]]; then bad "$name" "missing fixture.wo"; return; fi
if [[ ! -f "$dir/fixture.code" ]]; then bad "$name" "missing fixture.code"; return; fi
prefix="$(tmp_prefix "$name")"
wob="$prefix.wob" err="$prefix.err"
expected="$(tr -d '[:space:]' <"$dir/fixture.code")"
if [[ -z "$expected" ]]; then
bad "$name" "fixture.code is empty"
return
fi
timeout "$TIMEOUT" "$WOC" --emit "$dir/fixture.wo" -o "$wob" >/dev/null 2>"$err"
rc=$?
if [[ $rc -eq 124 ]]; then
bad "$name" "expected $expected, woc timed out after ${TIMEOUT}s"
elif [[ $rc -eq 0 ]]; then
bad "$name" "expected $expected, compiled clean (unexpected success)"
elif [[ $rc -gt 128 ]]; then
bad "$name" "expected $expected, woc crashed (signal $((rc - 128)))"
elif [[ $rc -eq 2 ]]; then
bad "$name" "expected $expected, got a usage/IO error: $(head -1 "$err")"
elif [[ $rc -ne 1 ]]; then
bad "$name" "expected $expected, woc exited $rc"
elif ! extract_error_codes "$err" | grep -qxF "$expected"; then
bad "$name" "expected $expected, got: $(head -1 "$err")"
else
ok "$name"
fi
}
trap_fixture() {
local dir="$1" name="trap/$(basename "$1")"
local prefix wob out err rc expected got
if [[ ! -f "$dir/fixture.wo" ]]; then bad "$name" "missing fixture.wo"; return; fi
if [[ ! -f "$dir/fixture.trap" ]]; then bad "$name" "missing fixture.trap"; return; fi
prefix="$(tmp_prefix "$name")"
wob="$prefix.wob" out="$prefix.out" err="$prefix.err"
expected="$(tr -d '[:space:]' <"$dir/fixture.trap")"
if [[ -z "$expected" ]]; then
bad "$name" "fixture.trap is empty"
return
fi
timeout "$TIMEOUT" "$WOC" --emit "$dir/fixture.wo" -o "$wob" >/dev/null 2>"$err"
rc=$?
if [[ $rc -eq 124 ]]; then
bad "$name" "expected trap $expected, woc timed out after ${TIMEOUT}s"
return
elif [[ $rc -gt 128 ]]; then
bad "$name" "expected trap $expected, woc crashed (signal $((rc - 128)))"
return
elif [[ $rc -ne 0 ]]; then
bad "$name" "expected trap $expected, compile failed: $(head -1 "$err")"
return
fi
timeout "$TIMEOUT" "$WOVM" "$wob" >"$out" 2>"$err"
rc=$?
if [[ $rc -eq 124 ]]; then
bad "$name" "expected trap $expected, wovm timed out after ${TIMEOUT}s"
return
elif [[ $rc -eq 0 ]]; then
bad "$name" "expected trap $expected, ran to completion"
return
elif [[ $rc -eq 2 ]]; then
bad "$name" "expected trap $expected, got a loader rejection: $(head -1 "$err")"
return
elif [[ $rc -gt 128 ]]; then
bad "$name" "expected trap $expected, wovm crashed (signal $((rc - 128)))"
return
elif [[ $rc -ne 1 ]]; then
bad "$name" "expected trap $expected, wovm exited $rc"
return
fi
# wovm's fixed stderr line: "trap N in METHOD at line L: MESSAGE"
got="$(sed -n 's/^trap \([0-9][0-9]*\) in .*/\1/p' "$err" | head -1)"
if [[ -z "$got" ]]; then
bad "$name" "exit 1 but no parseable trap line: $(head -1 "$err")"
elif [[ "$got" != "$expected" ]]; then
bad "$name" "expected trap $expected, got trap $got"
else
ok "$name"
fi
}
walk() {
local kind="$1" fn="$2" dir
for dir in "$CORPUS/$kind"/*/; do
[[ -d "$dir" ]] || continue
"$fn" "${dir%/}"
done
}
walk run run_fixture
walk compile-fail compile_fail_fixture
walk trap trap_fixture
walk gc gc_fixture
# ---- single-binary smoke (Task 6, plan 3) -----------------------------
# `woc build` end to end (relocate outside the repo, run, diff; corrupt
# the trailer, confirm a clear failure) doesn't fit the fixture-walk
# shape above, so it's a dedicated script -- its ok/FAIL lines fold into
# this harness's own tally the same way a fixture's would.
SB_LOG="$WORK/single-binary-smoke.log"
"$ROOT/scripts/single-binary-smoke.sh" | tee "$SB_LOG"
pass=$((pass + $(grep -c '^ok ' "$SB_LOG")))
fail=$((fail + $(grep -c '^FAIL ' "$SB_LOG")))
echo
total=$((pass + fail))
printf 'oop-e2e: %d checks, %d failures\n' "$total" "$fail"
if [[ $total -eq 0 ]]; then
echo "oop-e2e: no fixtures found under $CORPUS/{run,compile-fail,trap,gc} — harness misconfigured?" >&2
exit 1
fi
[[ $fail -eq 0 ]]

118
scripts/single-binary-smoke.sh Executable file
View file

@ -0,0 +1,118 @@
#!/usr/bin/env bash
# scripts/single-binary-smoke.sh — Task 6 (plan 3): proves `woc build`
# produces a genuinely self-contained executable, and that a corrupted
# trailer (docs/plan/oop-vm/00-wob-format.md, "single-binary trailer"
# section) fails clearly instead of crashing or misbehaving silently.
#
# Steps: build the hello fixture into one file; copy it to a temp
# directory OUTSIDE the repo (hardcoded under /tmp, not $TMPDIR — the
# whole point is proving nothing repo-relative is needed at run time);
# run it there with no arguments; diff stdout byte-for-byte. Then corrupt
# the trailer's payload_len field and confirm a clear exit-2 error, not a
# crash or a hang.
#
# Called by scripts/oop-e2e.sh (its ok/FAIL lines fold into that
# harness's tally); also runnable standalone. Same output shape as
# oop-e2e.sh: "ok NAME" / "FAIL NAME -- reason", one line per check.
set -uo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
WOC="$ROOT/compiler/_build/default/bin/woc"
WOVM="$ROOT/runtime/wovm"
FIXTURE="$ROOT/tests/corpus/run/hello/fixture.wo"
EXPECTED="$ROOT/tests/corpus/run/hello/fixture.out"
TIMEOUT="${OOP_E2E_TIMEOUT:-10}"
if [[ ! -x "$WOC" ]]; then
echo "single-binary-smoke: woc is not built ($WOC) — run: just woc-build" >&2
exit 1
fi
if [[ ! -x "$WOVM" ]]; then
echo "single-binary-smoke: wovm is not built ($WOVM) — run: make -C runtime wovm" >&2
exit 1
fi
pass=0
fail=0
ok() { echo "ok $1"; pass=$((pass + 1)); }
bad() { echo "FAIL $1 -- $2"; fail=$((fail + 1)); }
WORK="$(mktemp -d "${TMPDIR:-/tmp}/wo-single-binary-smoke.XXXXXX")"
# Deliberately NOT under $WORK / $TMPDIR: this directory is the actual
# proof of self-containment, so it must be unambiguously outside the repo
# regardless of how TMPDIR is set in the environment running the harness.
ELSEWHERE="$(mktemp -d /tmp/wo-single-binary-elsewhere.XXXXXX)"
trap 'rm -rf "$WORK" "$ELSEWHERE"' EXIT
echo "single-binary-smoke: relocation dir = $ELSEWHERE"
# ---- build the hello fixture into one file --------------------------------
SRC="$WORK/src"
mkdir -p "$SRC"
cp "$FIXTURE" "$SRC/fixture.wo"
APP="$WORK/app"
if ! timeout "$TIMEOUT" "$WOC" build "$SRC" -o "$APP" --runtime "$WOVM" \
>"$WORK/build.out" 2>"$WORK/build.err"; then
rc=$?
bad "single-binary/build" "woc build exited $rc: $(head -1 "$WORK/build.err")"
elif [[ ! -x "$APP" ]]; then
bad "single-binary/build" "output missing or not executable: $APP"
else
ok "single-binary/build"
fi
# ---- relocate outside the repo, run with no args, diff --------------------
if [[ -x "$APP" ]]; then
cp "$APP" "$ELSEWHERE/app"
chmod +x "$ELSEWHERE/app"
( cd / && timeout "$TIMEOUT" "$ELSEWHERE/app" ) >"$WORK/relocated.out" 2>"$WORK/relocated.err"
rc=$?
if [[ $rc -ne 0 ]]; then
bad "single-binary/relocated-run" "exit $rc: $(head -1 "$WORK/relocated.err")"
elif ! diff -q "$EXPECTED" "$WORK/relocated.out" >/dev/null 2>&1; then
bad "single-binary/relocated-run" "stdout mismatch"
diff -u "$EXPECTED" "$WORK/relocated.out" | sed 's/^/ /'
else
ok "single-binary/relocated-run"
fi
else
bad "single-binary/relocated-run" "skipped: no app to relocate"
fi
# ---- corrupt the trailer's payload_len field, confirm a clear failure -----
# Trailer is the last 20 bytes (payload_off u64, payload_len u64, magic
# u32): payload_len sits 12 bytes from EOF. Overwriting it with all-0xFF
# breaks the reader's "payload_off + payload_len == file_size - 20" check
# without touching the magic, so this exercises the "recognized trailer,
# bad bounds" path specifically (not the "no trailer at all" fallback).
if [[ -f "$ELSEWHERE/app" ]]; then
CORRUPT="$WORK/corrupt-app"
cp "$ELSEWHERE/app" "$CORRUPT"
chmod +x "$CORRUPT"
size=$(stat -c%s "$CORRUPT")
printf '\xff\xff\xff\xff\xff\xff\xff\xff' \
| dd of="$CORRUPT" bs=1 seek=$((size - 12)) count=8 conv=notrunc status=none
timeout "$TIMEOUT" "$CORRUPT" >"$WORK/corrupt.out" 2>"$WORK/corrupt.err"
rc=$?
if [[ $rc -eq 124 ]]; then
bad "single-binary/corrupt-trailer" "timed out after ${TIMEOUT}s instead of failing cleanly"
elif [[ $rc -gt 128 ]]; then
bad "single-binary/corrupt-trailer" "crashed (signal $((rc - 128))) instead of failing cleanly"
elif [[ $rc -ne 2 ]]; then
bad "single-binary/corrupt-trailer" "expected exit 2, got $rc: $(head -1 "$WORK/corrupt.err")"
elif [[ -s "$WORK/corrupt.out" ]]; then
bad "single-binary/corrupt-trailer" "exit 2 but stdout was not empty (partial run before failing?)"
elif ! grep -qi "corrupt trailer" "$WORK/corrupt.err"; then
bad "single-binary/corrupt-trailer" "exit 2 but no clear message: $(head -1 "$WORK/corrupt.err")"
else
ok "single-binary/corrupt-trailer"
fi
else
bad "single-binary/corrupt-trailer" "skipped: no relocated app to corrupt"
fi
total=$((pass + fail))
printf 'single-binary-smoke: %d checks, %d failures\n' "$total" "$fail"
[[ $fail -eq 0 ]]

17
tests/corpus/README.md Normal file
View file

@ -0,0 +1,17 @@
# tests/corpus/ — the conformance spine
Fixture kinds, exact-outcome matching (byte-equal stdout / exact `WO-E###` / exact trap code / exact gc step+freed counts):
| dir | kind | landed by plan |
| --- | --- | --- |
| `run/` | compiles + runs, expected stdout | 3 |
| `compile-fail/` | must fail with expected `WO-E###` | 3–4 |
| `trap/` | must trap with expected code | 3–4 |
| `gc/` | cycle collection scenarios | 3 |
| `actor/` | shard/spawn/send (ASan+TSan) | 4 |
| `db/` | insert/select + crash/replay | 5 |
| `lang/` | Haxe-parity adoptions | 8 |
| `sys/` | fs/proc/net/time/json stdlib | 9 |
| `sample-logwatcher/` | ported log-watcher fixtures | 10 |
Runner: `scripts/oop-e2e.sh` (plan 3, task 2 — includes the how-to-add-a-fixture doc pointer `docs/plan/oop-vm/02-corpus.md`).

View file

View file

View file

@ -0,0 +1 @@
WO-E304

View file

@ -0,0 +1,22 @@
-- Ownership suite (plan 2), as an end-user program: `h` is a plain
-- (borrowed) parameter, so returning `h.box` out of `leak` would let the
-- borrow outlive the scope it was borrowed from. Mirrors the `leak` case
-- of compiler/test/golden/owner-err/borrow-escape.wo, given a `main` that
-- actually calls it.
class Box {
n: Int
}
class Holder {
box: Box
}
fn leak(h: Holder) -> Box {
return h.box
}
fn main() {
let h = Holder { box: Box { n: 1 } }
let b = leak(h)
print_int(b.n)
}

View file

@ -0,0 +1 @@
WO-E303

View file

@ -0,0 +1,26 @@
-- Ownership suite (plan 2), as an end-user program: `bag.items[0]` passed
-- twice as a `mut` parameter in the same call is a *provable* alias --
-- same runtime index, same call -- so it fails at compile time. Contrast
-- with tests/corpus/trap/exclusive-borrow-alias, the same shape with a
-- runtime-variable index (i == j): the analysis can't prove that one
-- either way, so it becomes a residual check the VM traps BORROW on
-- instead. This is the hybrid boundary: provable violations fail here;
-- unprovable ones trap there. Mirrors the `same_index` case of
-- compiler/test/golden/owner-err/double-mut.wo.
class Item {
n: Int
}
class Bag {
items: multi Item
}
fn swap(mut a: Item, mut b: Item) -> Int {
return a.n + b.n
}
fn main() {
let bag = Bag { items: multi_new() }
push(bag.items, Item { n: 1 })
print_int(swap(bag.items[0], bag.items[0]))
}

View file

@ -0,0 +1 @@
WO-E215

View file

@ -0,0 +1,13 @@
-- Canary fixture (Task 2 review, Important 2): pins the front-end fix
-- landed in this same task. A second `class Dup` in one file used to be
-- silently dropped by collect_declarations's bare StringMap.add; now it
-- is WO-E215, reported at the later declaration.
class Dup {
n: Int
}
class Dup {
s: Text
}
fn main() {
}

View file

@ -0,0 +1 @@
WO-E206

View file

@ -0,0 +1,16 @@
-- E2xx-range substitute for the briefed "unsatisfied interface": WO-E205
-- is unreachable by design in the milestone grammar (no interface-typed
-- position exists for structural satisfaction to check against --
-- docs/plan/oop-vm/01-error-catalog.md's "Unreachable by design") -- a
-- fixture expecting it could never pass, by construction, not merely by
-- omission. This exercises the constructor check that does fire instead:
-- a class literal that omits a declared field with no default.
class Item {
n: Int
label: Text
}
fn main() {
let it = Item { n: 1 }
print_int(it.n)
}

View file

@ -0,0 +1 @@
WO-E301

View file

@ -0,0 +1,22 @@
-- Ownership suite (plan 2), as an end-user program: `b` is moved into
-- `consume` on the first call, so the second call reads it after the
-- move. Mirrors compiler/test/golden/owner-err/use-after-move.wo, the
-- unit-test-shaped original this fixture gives a `main` and turns into a
-- program the corpus harness actually runs through `woc --emit`.
class Box {
n: Int
}
fn consume(take b: Box) -> Int {
return b.n
}
fn run(take b: Box) -> Int {
let first = consume(b)
let second = consume(b)
return first + second
}
fn main() {
print_int(run(Box { n: 1 }))
}

View file

@ -0,0 +1 @@
WO-E302

View file

@ -0,0 +1,25 @@
-- Ownership suite (plan 2), as an end-user program: `alias` borrows
-- `b.inner` while `b` itself is moved into `consume` -- a move while a
-- live borrow of it still exists. Mirrors
-- compiler/test/golden/owner-err/move-while-borrowed.wo, given a `main`
-- that actually calls it.
class Inner {
n: Int
}
class Box {
inner: Inner
}
fn consume(take b: Box) -> Int {
return 1
}
fn run(take b: Box) -> Int {
let alias = b.inner
return consume(b)
}
fn main() {
print_int(run(Box { inner: Inner { n: 1 } }))
}

View file

@ -0,0 +1 @@
WO-E202

View file

@ -0,0 +1,14 @@
-- E2xx-range substitute for the briefed "type error": WO-E201
-- (type-mismatch) is declared in types.ml but has no emission site in the
-- milestone front end (docs/plan/oop-vm/01-error-catalog.md's "Reserved,
-- not yet emitted") -- it can never fire, so a fixture expecting it could
-- never pass. This exercises the type-adjacent check that does fire
-- instead: a `.field` access naming a field its class doesn't declare.
class Item {
n: Int
}
fn main() {
let it = Item { n: 1 }
print_int(it.price)
}

0
tests/corpus/db/.gitkeep Normal file
View file

0
tests/corpus/gc/.gitkeep Normal file
View file

View file

@ -0,0 +1 @@
1

View file

@ -0,0 +1,2 @@
steps=1
freed=2

View file

@ -0,0 +1,153 @@
-- 129 padding Int fields (p1..p129) push Node's instance size past the
-- arena's 1024-byte size-class ceiling (runtime/src/obj.h), so instances
-- allocate through plain malloc/free instead of the bump arena -- the
-- same trick runtime/test/test_cycle.c uses (BIG=130) so ASan can prove
-- a Node is actually freed, not just recycled inside the arena's own
-- freelist where a sanitizer can never see it.
@gc
class Node {
peers: multi Node
p1: Int
p2: Int
p3: Int
p4: Int
p5: Int
p6: Int
p7: Int
p8: Int
p9: Int
p10: Int
p11: Int
p12: Int
p13: Int
p14: Int
p15: Int
p16: Int
p17: Int
p18: Int
p19: Int
p20: Int
p21: Int
p22: Int
p23: Int
p24: Int
p25: Int
p26: Int
p27: Int
p28: Int
p29: Int
p30: Int
p31: Int
p32: Int
p33: Int
p34: Int
p35: Int
p36: Int
p37: Int
p38: Int
p39: Int
p40: Int
p41: Int
p42: Int
p43: Int
p44: Int
p45: Int
p46: Int
p47: Int
p48: Int
p49: Int
p50: Int
p51: Int
p52: Int
p53: Int
p54: Int
p55: Int
p56: Int
p57: Int
p58: Int
p59: Int
p60: Int
p61: Int
p62: Int
p63: Int
p64: Int
p65: Int
p66: Int
p67: Int
p68: Int
p69: Int
p70: Int
p71: Int
p72: Int
p73: Int
p74: Int
p75: Int
p76: Int
p77: Int
p78: Int
p79: Int
p80: Int
p81: Int
p82: Int
p83: Int
p84: Int
p85: Int
p86: Int
p87: Int
p88: Int
p89: Int
p90: Int
p91: Int
p92: Int
p93: Int
p94: Int
p95: Int
p96: Int
p97: Int
p98: Int
p99: Int
p100: Int
p101: Int
p102: Int
p103: Int
p104: Int
p105: Int
p106: Int
p107: Int
p108: Int
p109: Int
p110: Int
p111: Int
p112: Int
p113: Int
p114: Int
p115: Int
p116: Int
p117: Int
p118: Int
p119: Int
p120: Int
p121: Int
p122: Int
p123: Int
p124: Int
p125: Int
p126: Int
p127: Int
p128: Int
p129: Int
}
-- Two @gc Nodes reference each other through "peers" (a multi -- the
-- only way v1 can build a cycle at all: there is no nil literal, so a
-- direct GCREF field can never be the first edge of a pair that needs
-- the other to exist first). Both locals go out of scope at the end of
-- main with nothing else referencing either: an abandoned cycle. Only
-- the post-exit gc pump (runtime/src/main.c) frees them.
fn main() {
let a = Node { peers: multi_new(), p1: 0, p2: 0, p3: 0, p4: 0, p5: 0, p6: 0, p7: 0, p8: 0, p9: 0, p10: 0, p11: 0, p12: 0, p13: 0, p14: 0, p15: 0, p16: 0, p17: 0, p18: 0, p19: 0, p20: 0, p21: 0, p22: 0, p23: 0, p24: 0, p25: 0, p26: 0, p27: 0, p28: 0, p29: 0, p30: 0, p31: 0, p32: 0, p33: 0, p34: 0, p35: 0, p36: 0, p37: 0, p38: 0, p39: 0, p40: 0, p41: 0, p42: 0, p43: 0, p44: 0, p45: 0, p46: 0, p47: 0, p48: 0, p49: 0, p50: 0, p51: 0, p52: 0, p53: 0, p54: 0, p55: 0, p56: 0, p57: 0, p58: 0, p59: 0, p60: 0, p61: 0, p62: 0, p63: 0, p64: 0, p65: 0, p66: 0, p67: 0, p68: 0, p69: 0, p70: 0, p71: 0, p72: 0, p73: 0, p74: 0, p75: 0, p76: 0, p77: 0, p78: 0, p79: 0, p80: 0, p81: 0, p82: 0, p83: 0, p84: 0, p85: 0, p86: 0, p87: 0, p88: 0, p89: 0, p90: 0, p91: 0, p92: 0, p93: 0, p94: 0, p95: 0, p96: 0, p97: 0, p98: 0, p99: 0, p100: 0, p101: 0, p102: 0, p103: 0, p104: 0, p105: 0, p106: 0, p107: 0, p108: 0, p109: 0, p110: 0, p111: 0, p112: 0, p113: 0, p114: 0, p115: 0, p116: 0, p117: 0, p118: 0, p119: 0, p120: 0, p121: 0, p122: 0, p123: 0, p124: 0, p125: 0, p126: 0, p127: 0, p128: 0, p129: 0 }
let b = Node { peers: multi_new(), p1: 0, p2: 0, p3: 0, p4: 0, p5: 0, p6: 0, p7: 0, p8: 0, p9: 0, p10: 0, p11: 0, p12: 0, p13: 0, p14: 0, p15: 0, p16: 0, p17: 0, p18: 0, p19: 0, p20: 0, p21: 0, p22: 0, p23: 0, p24: 0, p25: 0, p26: 0, p27: 0, p28: 0, p29: 0, p30: 0, p31: 0, p32: 0, p33: 0, p34: 0, p35: 0, p36: 0, p37: 0, p38: 0, p39: 0, p40: 0, p41: 0, p42: 0, p43: 0, p44: 0, p45: 0, p46: 0, p47: 0, p48: 0, p49: 0, p50: 0, p51: 0, p52: 0, p53: 0, p54: 0, p55: 0, p56: 0, p57: 0, p58: 0, p59: 0, p60: 0, p61: 0, p62: 0, p63: 0, p64: 0, p65: 0, p66: 0, p67: 0, p68: 0, p69: 0, p70: 0, p71: 0, p72: 0, p73: 0, p74: 0, p75: 0, p76: 0, p77: 0, p78: 0, p79: 0, p80: 0, p81: 0, p82: 0, p83: 0, p84: 0, p85: 0, p86: 0, p87: 0, p88: 0, p89: 0, p90: 0, p91: 0, p92: 0, p93: 0, p94: 0, p95: 0, p96: 0, p97: 0, p98: 0, p99: 0, p100: 0, p101: 0, p102: 0, p103: 0, p104: 0, p105: 0, p106: 0, p107: 0, p108: 0, p109: 0, p110: 0, p111: 0, p112: 0, p113: 0, p114: 0, p115: 0, p116: 0, p117: 0, p118: 0, p119: 0, p120: 0, p121: 0, p122: 0, p123: 0, p124: 0, p125: 0, p126: 0, p127: 0, p128: 0, p129: 0 }
push(a.peers, b)
push(b.peers, a)
print_int(count(a.peers))
}

View file

@ -0,0 +1 @@
2

View file

@ -0,0 +1 @@
2

View file

@ -0,0 +1,2 @@
steps=2
freed=4

View file

@ -0,0 +1,157 @@
-- 129 padding Int fields (p1..p129) push Node's instance size past the
-- arena's 1024-byte size-class ceiling (runtime/src/obj.h), so instances
-- allocate through plain malloc/free instead of the bump arena -- the
-- same trick runtime/test/test_cycle.c uses (BIG=130) so ASan can prove
-- a Node is actually freed, not just recycled inside the arena's own
-- freelist where a sanitizer can never see it.
@gc
class Node {
peers: multi Node
p1: Int
p2: Int
p3: Int
p4: Int
p5: Int
p6: Int
p7: Int
p8: Int
p9: Int
p10: Int
p11: Int
p12: Int
p13: Int
p14: Int
p15: Int
p16: Int
p17: Int
p18: Int
p19: Int
p20: Int
p21: Int
p22: Int
p23: Int
p24: Int
p25: Int
p26: Int
p27: Int
p28: Int
p29: Int
p30: Int
p31: Int
p32: Int
p33: Int
p34: Int
p35: Int
p36: Int
p37: Int
p38: Int
p39: Int
p40: Int
p41: Int
p42: Int
p43: Int
p44: Int
p45: Int
p46: Int
p47: Int
p48: Int
p49: Int
p50: Int
p51: Int
p52: Int
p53: Int
p54: Int
p55: Int
p56: Int
p57: Int
p58: Int
p59: Int
p60: Int
p61: Int
p62: Int
p63: Int
p64: Int
p65: Int
p66: Int
p67: Int
p68: Int
p69: Int
p70: Int
p71: Int
p72: Int
p73: Int
p74: Int
p75: Int
p76: Int
p77: Int
p78: Int
p79: Int
p80: Int
p81: Int
p82: Int
p83: Int
p84: Int
p85: Int
p86: Int
p87: Int
p88: Int
p89: Int
p90: Int
p91: Int
p92: Int
p93: Int
p94: Int
p95: Int
p96: Int
p97: Int
p98: Int
p99: Int
p100: Int
p101: Int
p102: Int
p103: Int
p104: Int
p105: Int
p106: Int
p107: Int
p108: Int
p109: Int
p110: Int
p111: Int
p112: Int
p113: Int
p114: Int
p115: Int
p116: Int
p117: Int
p118: Int
p119: Int
p120: Int
p121: Int
p122: Int
p123: Int
p124: Int
p125: Int
p126: Int
p127: Int
p128: Int
p129: Int
}
-- Two INDEPENDENT 2-cycles (a<->b, c<->d), both abandoned. With
-- WO_GC_BUDGET=2 (fixture.gc_budget) each step's whole-component budget
-- covers exactly one 2-node cycle (test_budget_one_cycle_per_step in
-- runtime/test/test_cycle.c proves this at the collector level) -- the
-- pump must take two bounded steps to clear four objects, never one
-- unbounded sweep.
fn main() {
let a = Node { peers: multi_new(), p1: 0, p2: 0, p3: 0, p4: 0, p5: 0, p6: 0, p7: 0, p8: 0, p9: 0, p10: 0, p11: 0, p12: 0, p13: 0, p14: 0, p15: 0, p16: 0, p17: 0, p18: 0, p19: 0, p20: 0, p21: 0, p22: 0, p23: 0, p24: 0, p25: 0, p26: 0, p27: 0, p28: 0, p29: 0, p30: 0, p31: 0, p32: 0, p33: 0, p34: 0, p35: 0, p36: 0, p37: 0, p38: 0, p39: 0, p40: 0, p41: 0, p42: 0, p43: 0, p44: 0, p45: 0, p46: 0, p47: 0, p48: 0, p49: 0, p50: 0, p51: 0, p52: 0, p53: 0, p54: 0, p55: 0, p56: 0, p57: 0, p58: 0, p59: 0, p60: 0, p61: 0, p62: 0, p63: 0, p64: 0, p65: 0, p66: 0, p67: 0, p68: 0, p69: 0, p70: 0, p71: 0, p72: 0, p73: 0, p74: 0, p75: 0, p76: 0, p77: 0, p78: 0, p79: 0, p80: 0, p81: 0, p82: 0, p83: 0, p84: 0, p85: 0, p86: 0, p87: 0, p88: 0, p89: 0, p90: 0, p91: 0, p92: 0, p93: 0, p94: 0, p95: 0, p96: 0, p97: 0, p98: 0, p99: 0, p100: 0, p101: 0, p102: 0, p103: 0, p104: 0, p105: 0, p106: 0, p107: 0, p108: 0, p109: 0, p110: 0, p111: 0, p112: 0, p113: 0, p114: 0, p115: 0, p116: 0, p117: 0, p118: 0, p119: 0, p120: 0, p121: 0, p122: 0, p123: 0, p124: 0, p125: 0, p126: 0, p127: 0, p128: 0, p129: 0 }
let b = Node { peers: multi_new(), p1: 0, p2: 0, p3: 0, p4: 0, p5: 0, p6: 0, p7: 0, p8: 0, p9: 0, p10: 0, p11: 0, p12: 0, p13: 0, p14: 0, p15: 0, p16: 0, p17: 0, p18: 0, p19: 0, p20: 0, p21: 0, p22: 0, p23: 0, p24: 0, p25: 0, p26: 0, p27: 0, p28: 0, p29: 0, p30: 0, p31: 0, p32: 0, p33: 0, p34: 0, p35: 0, p36: 0, p37: 0, p38: 0, p39: 0, p40: 0, p41: 0, p42: 0, p43: 0, p44: 0, p45: 0, p46: 0, p47: 0, p48: 0, p49: 0, p50: 0, p51: 0, p52: 0, p53: 0, p54: 0, p55: 0, p56: 0, p57: 0, p58: 0, p59: 0, p60: 0, p61: 0, p62: 0, p63: 0, p64: 0, p65: 0, p66: 0, p67: 0, p68: 0, p69: 0, p70: 0, p71: 0, p72: 0, p73: 0, p74: 0, p75: 0, p76: 0, p77: 0, p78: 0, p79: 0, p80: 0, p81: 0, p82: 0, p83: 0, p84: 0, p85: 0, p86: 0, p87: 0, p88: 0, p89: 0, p90: 0, p91: 0, p92: 0, p93: 0, p94: 0, p95: 0, p96: 0, p97: 0, p98: 0, p99: 0, p100: 0, p101: 0, p102: 0, p103: 0, p104: 0, p105: 0, p106: 0, p107: 0, p108: 0, p109: 0, p110: 0, p111: 0, p112: 0, p113: 0, p114: 0, p115: 0, p116: 0, p117: 0, p118: 0, p119: 0, p120: 0, p121: 0, p122: 0, p123: 0, p124: 0, p125: 0, p126: 0, p127: 0, p128: 0, p129: 0 }
push(a.peers, b)
push(b.peers, a)
let c = Node { peers: multi_new(), p1: 0, p2: 0, p3: 0, p4: 0, p5: 0, p6: 0, p7: 0, p8: 0, p9: 0, p10: 0, p11: 0, p12: 0, p13: 0, p14: 0, p15: 0, p16: 0, p17: 0, p18: 0, p19: 0, p20: 0, p21: 0, p22: 0, p23: 0, p24: 0, p25: 0, p26: 0, p27: 0, p28: 0, p29: 0, p30: 0, p31: 0, p32: 0, p33: 0, p34: 0, p35: 0, p36: 0, p37: 0, p38: 0, p39: 0, p40: 0, p41: 0, p42: 0, p43: 0, p44: 0, p45: 0, p46: 0, p47: 0, p48: 0, p49: 0, p50: 0, p51: 0, p52: 0, p53: 0, p54: 0, p55: 0, p56: 0, p57: 0, p58: 0, p59: 0, p60: 0, p61: 0, p62: 0, p63: 0, p64: 0, p65: 0, p66: 0, p67: 0, p68: 0, p69: 0, p70: 0, p71: 0, p72: 0, p73: 0, p74: 0, p75: 0, p76: 0, p77: 0, p78: 0, p79: 0, p80: 0, p81: 0, p82: 0, p83: 0, p84: 0, p85: 0, p86: 0, p87: 0, p88: 0, p89: 0, p90: 0, p91: 0, p92: 0, p93: 0, p94: 0, p95: 0, p96: 0, p97: 0, p98: 0, p99: 0, p100: 0, p101: 0, p102: 0, p103: 0, p104: 0, p105: 0, p106: 0, p107: 0, p108: 0, p109: 0, p110: 0, p111: 0, p112: 0, p113: 0, p114: 0, p115: 0, p116: 0, p117: 0, p118: 0, p119: 0, p120: 0, p121: 0, p122: 0, p123: 0, p124: 0, p125: 0, p126: 0, p127: 0, p128: 0, p129: 0 }
let d = Node { peers: multi_new(), p1: 0, p2: 0, p3: 0, p4: 0, p5: 0, p6: 0, p7: 0, p8: 0, p9: 0, p10: 0, p11: 0, p12: 0, p13: 0, p14: 0, p15: 0, p16: 0, p17: 0, p18: 0, p19: 0, p20: 0, p21: 0, p22: 0, p23: 0, p24: 0, p25: 0, p26: 0, p27: 0, p28: 0, p29: 0, p30: 0, p31: 0, p32: 0, p33: 0, p34: 0, p35: 0, p36: 0, p37: 0, p38: 0, p39: 0, p40: 0, p41: 0, p42: 0, p43: 0, p44: 0, p45: 0, p46: 0, p47: 0, p48: 0, p49: 0, p50: 0, p51: 0, p52: 0, p53: 0, p54: 0, p55: 0, p56: 0, p57: 0, p58: 0, p59: 0, p60: 0, p61: 0, p62: 0, p63: 0, p64: 0, p65: 0, p66: 0, p67: 0, p68: 0, p69: 0, p70: 0, p71: 0, p72: 0, p73: 0, p74: 0, p75: 0, p76: 0, p77: 0, p78: 0, p79: 0, p80: 0, p81: 0, p82: 0, p83: 0, p84: 0, p85: 0, p86: 0, p87: 0, p88: 0, p89: 0, p90: 0, p91: 0, p92: 0, p93: 0, p94: 0, p95: 0, p96: 0, p97: 0, p98: 0, p99: 0, p100: 0, p101: 0, p102: 0, p103: 0, p104: 0, p105: 0, p106: 0, p107: 0, p108: 0, p109: 0, p110: 0, p111: 0, p112: 0, p113: 0, p114: 0, p115: 0, p116: 0, p117: 0, p118: 0, p119: 0, p120: 0, p121: 0, p122: 0, p123: 0, p124: 0, p125: 0, p126: 0, p127: 0, p128: 0, p129: 0 }
push(c.peers, d)
push(d.peers, c)
print_int(count(a.peers) + count(c.peers))
}

View file

View file

View file

@ -0,0 +1,2 @@
14
done

View file

@ -0,0 +1,28 @@
-- Seed fixture: arithmetic (+ - * / %, unary -) and control flow (if,
-- while, comparisons including the two operators the v1 instruction set
-- has no opcode for: `%` lowers to `a - (a / b) * b`, `!=` lowers to
-- `(a == b) == 0` -- see docs/plan/oop-vm/08-builtin-surface.md.
fn compute(a: Int, b: Int) -> Int {
let sum = a + b
let diff = a - b
let prod = sum * diff
let quot = prod / b
let rem = prod % b
let neg = -rem
if sum == diff {
return neg
}
let changed = sum != diff
while changed {
changed = false
}
if sum > diff {
return quot + rem
}
return neg
}
fn main() {
print_int(compute(7, 3))
print("done")
}

View file

@ -0,0 +1,2 @@
hello
42

View file

@ -0,0 +1,5 @@
-- Seed fixture: print/print_int, the smallest possible run/ case.
fn main() {
print("hello")
print_int(42)
}

View file

@ -0,0 +1,2 @@
12
15

View file

@ -0,0 +1,34 @@
-- Seed fixture: structural interface dispatch. Book and Toy satisfy
-- Priced purely by having the method (no `implements` keyword by
-- doctrine); `quote`'s call through the interface-typed parameter is
-- ICALL by global vtable slot, not a direct method-index CALL.
interface Priced {
fn current_price() -> Int
}
class Book {
base: Int
fn current_price() -> Int {
return self.base + 2
}
}
class Toy {
base: Int
fn current_price() -> Int {
return self.base * 3
}
}
fn quote(p: Priced) -> Int {
return p.current_price()
}
fn main() {
let b = Book { base: 10 }
let t = Toy { base: 5 }
print_int(quote(b))
print_int(quote(t))
}

View file

@ -0,0 +1,2 @@
15
42

View file

@ -0,0 +1,16 @@
-- Seed fixture: a direct method call (receiver's declared type is a
-- concrete class, so the emitter dispatches by CALL/method index, not
-- ICALL/vtable -- contrast with run/interface).
class Counter {
n: Int
fn add(amount: Int) -> Int {
return self.n + amount
}
}
fn main() {
let c = Counter { n: 10 }
print_int(c.add(5))
print_int(c.add(32))
}

View file

@ -0,0 +1,6 @@
3
20
2
499
1
0

View file

@ -0,0 +1,31 @@
-- Pricing-demo corpus (plan 3, Task 3): container round-trips over the
-- two container shapes the pricing demo's classes use -- a `multi`
-- (docs/examples/pricing/types/product.wo's `prices: multi Price`) and a
-- Text-keyed `map` (a SKU->price catalog, using `Text` directly since
-- `SKU` was removed from the language on 2026-08-10). Exercises
-- multi push/count/get and map set/get/has, per docs/plan/oop-vm/
-- 08-builtin-surface.md's builtin table.
class Item {
n: Int
}
class Catalog {
items: multi Item
by_name: map<Text, Int>
}
fn main() {
let c = Catalog { items: multi_new(), by_name: map_new() }
push(c.items, Item { n: 10 })
push(c.items, Item { n: 20 })
push(c.items, Item { n: 30 })
print_int(count(c.items))
print_int(c.items[1].n)
set(c.by_name, "mug", 499)
set(c.by_name, "shirt", 1999)
print_int(count(c.by_name))
print_int(c.by_name["mug"])
print_int(has(c.by_name, "shirt"))
print_int(has(c.by_name, "hat"))
}

View file

@ -0,0 +1 @@
150

View file

@ -0,0 +1,31 @@
-- Pricing-demo corpus (plan 3, Task 3): mirrors
-- docs/examples/pricing/types/product.wo's `current_price` --
-- `latest(self.prices).amount` through a `multi Price` field. Trimmed for
-- milestone-1 grammar: `@unique` on `sku`, the `id`/`sku`/`name` fields
-- (only `prices` is what `current_price` reads), `in txn` (no txn
-- keyword in this grammar), and the `service rest` block. `Money`/`SKU`
-- were removed from the language on 2026-08-10; nothing here needed
-- them. (There is no `@table`, `owner`, or `Customer` in the original --
-- Product has exactly id/sku/name/prices; an earlier draft of this
-- comment wrongly borrowed fields from the unrelated, non-emittable
-- compiler/test/golden/owner/pricing-demo.wo instead.)
class Price {
amount: Int
}
class Product {
prices: multi Price
fn current_price() -> Int {
return latest(self.prices).amount
}
}
fn main() {
-- Fresh-container destination rule (08-builtin-surface.md): multi_new()
-- must land directly in a field of declared container type.
let prod = Product { prices: multi_new() }
push(prod.prices, Price { amount: 100 })
push(prod.prices, Price { amount: 150 })
print_int(prod.current_price())
}

View file

@ -0,0 +1,2 @@
150
200

View file

@ -0,0 +1,22 @@
-- Pricing-demo corpus (plan 3, Task 3): mirrors
-- docs/examples/pricing/types/price.wo's `discounted` -- pure arithmetic,
-- no txn machinery. Trimmed for milestone-1 grammar (docs/plan/oop-vm/
-- 08-builtin-surface.md): `@table`, `id`/`product: ref Product`/
-- `currency`/`at: Timestamp = now()` fields and the `service rest` block
-- are all schema/runtime-layer surface the milestone-1 parser has no
-- lowering for -- only the field `discounted` actually reads (`amount`)
-- is kept. `Money` was removed from the language on 2026-08-10, so
-- `amount` is a plain `Int` (minor units), same value the original used.
class Price {
amount: Int
fn discounted(pct: Int) -> Int {
return self.amount * (100 - pct) / 100
}
}
fn main() {
let p = Price { amount: 200 }
print_int(p.discounted(25))
print_int(p.discounted(0))
}

View file

@ -0,0 +1,2 @@
writeonce ceramic mug
3

View file

@ -0,0 +1,12 @@
-- Pricing-demo corpus (plan 3, Task 3): text builtins -- `words`
-- (whitespace token count) and `..` (CONCAT; `+` is arithmetic-only,
-- never string addition -- docs/plan/oop-vm/08-builtin-surface.md).
-- Mirrors the product name text docs/examples/pricing/types/product.wo
-- carries (`name: Text`), reduced to what the two builtins need.
fn main() {
let brand = "writeonce"
let noun = "ceramic mug"
let full = brand .. " " .. noun
print(full)
print_int(words(full))
}

View file

View file

View file

View file

@ -0,0 +1 @@
1

View file

@ -0,0 +1,21 @@
-- Runtime trap: division by zero. DIV traps unconditionally on a zero
-- divisor (docs/plan/oop-vm/08-builtin-surface.md, docs/plan/oop-vm/
-- 00-wob-format.md) -- unprovable at compile time in general (the
-- divisor is a runtime value; milestone-1 does no constant-folding
-- either), so it traps rather than failing to compile.
--
-- Line assertion (harness's fixture.trap only carries the numeric code,
-- not the line -- verified manually per this task's brief): wovm must
-- report this trap at line 12, the `a / b` below. Proves line tables
-- survive emission. The machine-enforced coverage for pc->line survival
-- lives elsewhere, not in this corpus fixture: compiler/test/golden/bc's
-- goldens pin the emitted line table, and runtime/test/test_vm.c's
-- test_div_zero_traps_with_line asserts wovm reports the exact line for
-- this same DIV0 case.
fn divide(a: Int, b: Int) -> Int {
return a / b
}
fn main() {
print_int(divide(10, 0))
}

View file

@ -0,0 +1 @@
2

View file

@ -0,0 +1,28 @@
-- Canary fixture (Task 2 review, Important 2): a real runtime trap,
-- proving the trap/ path end to end. `pair` takes two exclusive borrows
-- through runtime indices (docs/plan/oop-vm/08-builtin-surface.md's
-- residual-borrow case); calling it with i == j aliases the same
-- element under two exclusive borrows, which the VM's runtime guard
-- traps rather than silently corrupting.
class Item {
n: Int
}
class Bag {
items: multi Item
}
fn touch(mut a: Item, mut b: Item) -> Int {
return a.n + b.n
}
fn pair(mut bag: Bag, i: Int, j: Int) -> Int {
return touch(bag.items[i], bag.items[j])
}
fn main() {
let bag = Bag { items: multi_new() }
push(bag.items, Item { n: 1 })
push(bag.items, Item { n: 2 })
print_int(pair(bag, 0, 0))
}

View file

@ -0,0 +1 @@
7

View file

@ -0,0 +1,14 @@
-- Runtime residual check: `get` on a `map` with a key that isn't present
-- traps KEY (docs/plan/oop-vm/08-builtin-surface.md's `get` row: "a
-- missing key traps KEY"). Unprovable at compile time -- the key is a
-- runtime `Text` value -- so it traps rather than failing to compile:
-- the hybrid boundary's runtime half.
class Catalog {
prices: map<Text, Int>
}
fn main() {
let c = Catalog { prices: map_new() }
set(c.prices, "mug", 499)
print_int(c.prices["shirt"])
}

View file

@ -0,0 +1 @@
5

View file

@ -0,0 +1,29 @@
-- Pricing-demo corpus (plan 3, Task 3): mirrors
-- docs/examples/pricing/types/product.wo's `set_price` -- the write path
-- whose `insert Price { ... }` is the SQL sublanguage's one opaque node
-- (compiler/src/parser.ml's `insert`/`select` "parses but traps"
-- contract) and lowers to a single DB_STUB opcode (docs/plan/oop-vm/
-- 00-wob-format.md), which traps WO_T_DB unconditionally ("engine not
-- linked") -- the spec's parse-but-trap story, proven end to end.
-- Trimmed for milestone-1 grammar: `in txn`, `assert ... otherwise
-- abort` (no assert/otherwise/abort keywords in this grammar), and the
-- `service rest` block -- only the `insert` statement `set_price`
-- actually needs to prove the trap is kept. `self.id` became plain
-- `self` since the `id` field itself was trimmed; it doesn't matter to
-- the trap -- `insert`'s body is never re-parsed, only captured verbatim.
class Price {
amount: Int
}
class Product {
prices: multi Price
fn set_price(amount: Int) {
insert Price { product: self, amount: amount }
}
}
fn main() {
let prod = Product { prices: multi_new() }
prod.set_price(4999)
}

View file

@ -0,0 +1 @@
6

View file

@ -0,0 +1,34 @@
-- KNOWN GAP, pinned deliberately (Task 4 review, Important 2). `Rock`
-- does not have a `current_price` method, so it does not structurally
-- satisfy `Priced` -- and its full method set is known at compile time,
-- so this violation IS provable statically. By the hybrid-boundary
-- doctrine (provable -> compile-time, unprovable -> runtime) this ought
-- to be WO-E205 (unsatisfied-interface) at the `quote(r)` call site.
--
-- It isn't: WO-E205 is declared in types.ml but has no call site today
-- (docs/plan/oop-vm/01-error-catalog.md). This compiles clean (exit 0,
-- zero diagnostics) and the unsatisfied call instead reaches `wovm` as
-- an `ICALL` with no matching vtable entry, which traps `WO_T_BOUNDS`
-- (6) -- "no vtable entry for receiver class". That is the CURRENT,
-- observed behavior this fixture pins, not the desired one.
--
-- When WO-E205 is implemented, this exact program must start failing to
-- *compile* instead -- move this fixture to compile-fail/ with
-- fixture.code WO-E205 in the same change that wires the check, rather
-- than leaving a stale trap/ fixture silently describing dead behavior.
interface Priced {
fn current_price() -> Int
}
class Rock {
n: Int
}
fn quote(p: Priced) -> Int {
return p.current_price()
}
fn main() {
let r = Rock { n: 1 }
print_int(quote(r))
}