From 641a41e25c60cd6b6972447c0ec1ca78f78ed77e Mon Sep 17 00:00:00 2001 From: "shoney.arickathil" Date: Tue, 11 Aug 2026 19:31:26 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20milestone=201=20complete=20=E2=80=94=20?= =?UTF-8?q?.wob=20emitter,=20conformance=20corpus,=20single=20binary;=20GC?= =?UTF-8?q?=20redesign=20specced?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `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. --- .dev/README.md | 1 + .gitignore | 18 +- compiler/README.md | 25 +- compiler/bin/main.ml | 175 ++ compiler/src/diag.ml | 2 + compiler/src/disasm.ml | 282 +++ compiler/src/dune | 2 +- compiler/src/emit.ml | 2022 +++++++++++++++++ compiler/src/owner.ml | 18 +- compiler/src/types.ml | 72 +- compiler/test/golden/bc/arith.expected | 50 + compiler/test/golden/bc/arith.wo | 29 + compiler/test/golden/bc/elision.expected | 50 + compiler/test/golden/bc/elision.wo | 32 + compiler/test/golden/bc/iface.expected | 69 + compiler/test/golden/bc/iface.wo | 37 + compiler/test/golden/bc/owned.expected | 65 + compiler/test/golden/bc/owned.wo | 25 + compiler/test/golden/bc/residual.expected | 156 ++ compiler/test/golden/bc/residual.wo | 64 + compiler/test/runner.ml | 754 ++++++ docs/00-status.md | 80 +- docs/plan/oop-vm/00-wob-format.md | 77 + docs/plan/oop-vm/01-error-catalog.md | 81 +- docs/plan/oop-vm/02-corpus.md | 222 ++ docs/plan/oop-vm/08-builtin-surface.md | 136 ++ docs/plan/oop-vm/README.md | 1 + .../language-runtime-database/00-story.md | 22 +- .../07b-inferred-gc-mark-sweep.md | 108 + .../09b-table-relations-query.md | 132 ++ .../2026-08-01-oop-compiler-vm-design.md | 32 + ...026-08-10-logwatcher-gap-closure-design.md | 36 +- ...026-08-11-inferred-gc-mark-sweep-design.md | 275 +++ justfile | 89 + runtime/Makefile | 10 +- runtime/README.md | 10 +- runtime/src/main.c | 128 +- scripts/oop-e2e.sh | 334 +++ scripts/single-binary-smoke.sh | 118 + tests/corpus/README.md | 17 + tests/corpus/actor/.gitkeep | 0 tests/corpus/compile-fail/.gitkeep | 0 .../borrow-escape-return/fixture.code | 1 + .../borrow-escape-return/fixture.wo | 22 + .../double-mut-alias/fixture.code | 1 + .../compile-fail/double-mut-alias/fixture.wo | 26 + .../duplicate-class-same-file/fixture.code | 1 + .../duplicate-class-same-file/fixture.wo | 13 + .../incomplete-constructor/fixture.code | 1 + .../incomplete-constructor/fixture.wo | 16 + .../compile-fail/move-after-use/fixture.code | 1 + .../compile-fail/move-after-use/fixture.wo | 22 + .../move-while-borrowed/fixture.code | 1 + .../move-while-borrowed/fixture.wo | 25 + .../compile-fail/unknown-field/fixture.code | 1 + .../compile-fail/unknown-field/fixture.wo | 14 + tests/corpus/db/.gitkeep | 0 tests/corpus/gc/.gitkeep | 0 tests/corpus/gc/abandoned-cycle/fixture.out | 1 + tests/corpus/gc/abandoned-cycle/fixture.trace | 2 + tests/corpus/gc/abandoned-cycle/fixture.wo | 153 ++ .../corpus/gc/budget-steps/fixture.gc_budget | 1 + tests/corpus/gc/budget-steps/fixture.out | 1 + tests/corpus/gc/budget-steps/fixture.trace | 2 + tests/corpus/gc/budget-steps/fixture.wo | 157 ++ tests/corpus/lang/.gitkeep | 0 tests/corpus/run/.gitkeep | 0 tests/corpus/run/arithmetic/fixture.out | 2 + tests/corpus/run/arithmetic/fixture.wo | 28 + tests/corpus/run/hello/fixture.out | 2 + tests/corpus/run/hello/fixture.wo | 5 + tests/corpus/run/interface/fixture.out | 2 + tests/corpus/run/interface/fixture.wo | 34 + tests/corpus/run/methods/fixture.out | 2 + tests/corpus/run/methods/fixture.wo | 16 + .../corpus/run/pricing-containers/fixture.out | 6 + .../corpus/run/pricing-containers/fixture.wo | 31 + .../run/pricing-current-price/fixture.out | 1 + .../run/pricing-current-price/fixture.wo | 31 + .../corpus/run/pricing-discounted/fixture.out | 2 + .../corpus/run/pricing-discounted/fixture.wo | 22 + tests/corpus/run/pricing-text/fixture.out | 2 + tests/corpus/run/pricing-text/fixture.wo | 12 + tests/corpus/sample-logwatcher/.gitkeep | 0 tests/corpus/sys/.gitkeep | 0 tests/corpus/trap/.gitkeep | 0 tests/corpus/trap/div-by-zero/fixture.trap | 1 + tests/corpus/trap/div-by-zero/fixture.wo | 21 + .../trap/exclusive-borrow-alias/fixture.trap | 1 + .../trap/exclusive-borrow-alias/fixture.wo | 28 + .../corpus/trap/missing-map-key/fixture.trap | 1 + tests/corpus/trap/missing-map-key/fixture.wo | 14 + .../pricing-set-price-db-stub/fixture.trap | 1 + .../trap/pricing-set-price-db-stub/fixture.wo | 29 + .../trap/unsatisfied-interface/fixture.trap | 1 + .../trap/unsatisfied-interface/fixture.wo | 34 + 96 files changed, 6545 insertions(+), 102 deletions(-) create mode 100644 compiler/src/disasm.ml create mode 100644 compiler/src/emit.ml create mode 100644 compiler/test/golden/bc/arith.expected create mode 100644 compiler/test/golden/bc/arith.wo create mode 100644 compiler/test/golden/bc/elision.expected create mode 100644 compiler/test/golden/bc/elision.wo create mode 100644 compiler/test/golden/bc/iface.expected create mode 100644 compiler/test/golden/bc/iface.wo create mode 100644 compiler/test/golden/bc/owned.expected create mode 100644 compiler/test/golden/bc/owned.wo create mode 100644 compiler/test/golden/bc/residual.expected create mode 100644 compiler/test/golden/bc/residual.wo create mode 100644 docs/plan/oop-vm/02-corpus.md create mode 100644 docs/plan/oop-vm/08-builtin-surface.md create mode 100644 docs/stories/language-runtime-database/07b-inferred-gc-mark-sweep.md create mode 100644 docs/stories/language-runtime-database/09b-table-relations-query.md create mode 100644 docs/superpowers/specs/2026-08-11-inferred-gc-mark-sweep-design.md create mode 100755 scripts/oop-e2e.sh create mode 100755 scripts/single-binary-smoke.sh create mode 100644 tests/corpus/README.md create mode 100644 tests/corpus/actor/.gitkeep create mode 100644 tests/corpus/compile-fail/.gitkeep create mode 100644 tests/corpus/compile-fail/borrow-escape-return/fixture.code create mode 100644 tests/corpus/compile-fail/borrow-escape-return/fixture.wo create mode 100644 tests/corpus/compile-fail/double-mut-alias/fixture.code create mode 100644 tests/corpus/compile-fail/double-mut-alias/fixture.wo create mode 100644 tests/corpus/compile-fail/duplicate-class-same-file/fixture.code create mode 100644 tests/corpus/compile-fail/duplicate-class-same-file/fixture.wo create mode 100644 tests/corpus/compile-fail/incomplete-constructor/fixture.code create mode 100644 tests/corpus/compile-fail/incomplete-constructor/fixture.wo create mode 100644 tests/corpus/compile-fail/move-after-use/fixture.code create mode 100644 tests/corpus/compile-fail/move-after-use/fixture.wo create mode 100644 tests/corpus/compile-fail/move-while-borrowed/fixture.code create mode 100644 tests/corpus/compile-fail/move-while-borrowed/fixture.wo create mode 100644 tests/corpus/compile-fail/unknown-field/fixture.code create mode 100644 tests/corpus/compile-fail/unknown-field/fixture.wo create mode 100644 tests/corpus/db/.gitkeep create mode 100644 tests/corpus/gc/.gitkeep create mode 100644 tests/corpus/gc/abandoned-cycle/fixture.out create mode 100644 tests/corpus/gc/abandoned-cycle/fixture.trace create mode 100644 tests/corpus/gc/abandoned-cycle/fixture.wo create mode 100644 tests/corpus/gc/budget-steps/fixture.gc_budget create mode 100644 tests/corpus/gc/budget-steps/fixture.out create mode 100644 tests/corpus/gc/budget-steps/fixture.trace create mode 100644 tests/corpus/gc/budget-steps/fixture.wo create mode 100644 tests/corpus/lang/.gitkeep create mode 100644 tests/corpus/run/.gitkeep create mode 100644 tests/corpus/run/arithmetic/fixture.out create mode 100644 tests/corpus/run/arithmetic/fixture.wo create mode 100644 tests/corpus/run/hello/fixture.out create mode 100644 tests/corpus/run/hello/fixture.wo create mode 100644 tests/corpus/run/interface/fixture.out create mode 100644 tests/corpus/run/interface/fixture.wo create mode 100644 tests/corpus/run/methods/fixture.out create mode 100644 tests/corpus/run/methods/fixture.wo create mode 100644 tests/corpus/run/pricing-containers/fixture.out create mode 100644 tests/corpus/run/pricing-containers/fixture.wo create mode 100644 tests/corpus/run/pricing-current-price/fixture.out create mode 100644 tests/corpus/run/pricing-current-price/fixture.wo create mode 100644 tests/corpus/run/pricing-discounted/fixture.out create mode 100644 tests/corpus/run/pricing-discounted/fixture.wo create mode 100644 tests/corpus/run/pricing-text/fixture.out create mode 100644 tests/corpus/run/pricing-text/fixture.wo create mode 100644 tests/corpus/sample-logwatcher/.gitkeep create mode 100644 tests/corpus/sys/.gitkeep create mode 100644 tests/corpus/trap/.gitkeep create mode 100644 tests/corpus/trap/div-by-zero/fixture.trap create mode 100644 tests/corpus/trap/div-by-zero/fixture.wo create mode 100644 tests/corpus/trap/exclusive-borrow-alias/fixture.trap create mode 100644 tests/corpus/trap/exclusive-borrow-alias/fixture.wo create mode 100644 tests/corpus/trap/missing-map-key/fixture.trap create mode 100644 tests/corpus/trap/missing-map-key/fixture.wo create mode 100644 tests/corpus/trap/pricing-set-price-db-stub/fixture.trap create mode 100644 tests/corpus/trap/pricing-set-price-db-stub/fixture.wo create mode 100644 tests/corpus/trap/unsatisfied-interface/fixture.trap create mode 100644 tests/corpus/trap/unsatisfied-interface/fixture.wo diff --git a/.dev/README.md b/.dev/README.md index b79c306..f106561 100644 --- a/.dev/README.md +++ b/.dev/README.md @@ -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.) diff --git a/.gitignore b/.gitignore index fef897c..0737a17 100644 --- a/.gitignore +++ b/.gitignore @@ -45,6 +45,12 @@ # ln -s .dev/reference/colibri # ln -s .dev/reference/llama-cpp # ln -s .dev/reference/llvm-project +# ln -s .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 diff --git a/compiler/README.md b/compiler/README.md index 9c97af5..6ed42f2 100644 --- a/compiler/README.md +++ b/compiler/README.md @@ -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 # compile (lex, parse, typecheck, ownership-check); nothing prints on success -woc --dump-tokens # stdout: one line per lexed token -woc --dump-ast # stdout: the declaration + body AST, indented -woc --dump-owner # stdout: the ownership pass's four tables (moves, drops, rc, residual) +woc # compile (lex, parse, typecheck, ownership-check); nothing prints on success +woc --dump-tokens # stdout: one line per lexed token +woc --dump-ast # stdout: the declaration + body AST, indented +woc --dump-owner # stdout: the ownership pass's four tables (moves, drops, rc, residual) +woc --dump-bc # stdout: disassembled bytecode for every emitted method +woc --emit -o # compile through to a .wob bytecode module, runnable by wovm +woc build -o [--runtime ] + # compile + append the .wob image to a copy of wovm (default + # runtime/wovm, or --runtime) into one self-contained ``` `` 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//` 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//` 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). diff --git a/compiler/bin/main.ml b/compiler/bin/main.ml index 20963eb..4c38ecf 100644 --- a/compiler/bin/main.ml +++ b/compiler/bin/main.ml @@ -40,9 +40,12 @@ let usage_msg = "usage: woc \n\ + usage: woc --emit -o \n\ + usage: woc build -o [--runtime ]\n\ usage: woc --dump-tokens \n\ usage: woc --dump-ast \n\ usage: woc --dump-owner \n\ + usage: woc --dump-bc \n\ \n\ Compiles writeonce (.wo) source. 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 like --emit, then produces one self-contained\n\ + executable at -o: the wovm runtime binary (--runtime , 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 -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; diff --git a/compiler/src/diag.ml b/compiler/src/diag.ml index 580c5d8..2128a9c 100644 --- a/compiler/src/diag.ml +++ b/compiler/src/diag.ml @@ -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 = diff --git a/compiler/src/disasm.ml b/compiler/src/disasm.ml new file mode 100644 index 0000000..690c075 --- /dev/null +++ b/compiler/src/disasm.ml @@ -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 "" 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 diff --git a/compiler/src/dune b/compiler/src/dune index 20c77b7..4a671bb 100644 --- a/compiler/src/dune +++ b/compiler/src/dune @@ -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)) \ No newline at end of file + (modules diag token ast lexer parser types owner emit disasm dump)) diff --git a/compiler/src/emit.ml b/compiler/src/emit.ml new file mode 100644 index 0000000..74ed96c --- /dev/null +++ b/compiler/src/emit.ml @@ -0,0 +1,2022 @@ +(* emit.ml — the `.wob` bytecode emitter (plan 3, Task 1). + + Lowers the parsed, typechecked, owner-annotated program into a `.wob` + v1 image. Two documents govern every byte produced here and neither + is negotiable from this file: + + - docs/plan/oop-vm/00-wob-format.md — the normative format + - runtime/src/wob.h — its machine-readable twin + + The round-trip rule from the plan's global constraints: an image + `woc` produces that `wovm`'s loader (runtime/src/loader.c) rejects is + always an emitter bug. Everything the loader validates is therefore + maintained as an invariant here — register counts 1..64 with + args <= registers, every static register operand < the method's + register count, constant/class/callee/slot indexes in range, CALL + argument windows inside the caller's frame, jump targets inside the + code, a terminator as the last instruction, builtin ids with their + fixed arity, and strictly ascending line/drop tables whose masks fit + the register count. + + ---- what this module consumes rather than re-derives ---------------- + + Types.symbols declarations, per-class field lists + Types.wob_kind_of_typ the .wob field kind of a declared type + Owner.tables the four ownership tables, verbatim: + moves -> which MOVEs are real transfers + drops -> DROP placement + drop-table masks + rcs -> RC_INC / RC_DEC, minus ELIDED pairs + residuals -> the ONLY places borrow ops appear + + Where the drop map is synced is worth stating once: the owner table is + authoritative at every node it records a LIVE-MASK for, which is every + Call expression and every DbStub — so those sync from the table. An + `Index` read, a `map` element write and a `for` cursor step lower to + BUILTIN instructions that are NOT Call nodes, so no table entry exists + to sync to; they run on the emitter's running mask, which is exactly + right for them. + + Residual regions are anchored by owner.ml on more than one node kind + (a call expression, an assignment statement, a moved place), so each + consumption site marks the region used and anything left over at the + end of the unit is WO-E404 — a region nobody wrapped would ship the + aliasing check silently disabled, which is the one failure a residual + site exists to prevent. + + Two obligations dump.ml states for the tables and this file honors: + the residual table may name the same canonical operand in several + entries, so borrow guards are coalesced *per operand* (one acquire / + release pair per operand register, strongest access kind winning) — + emitting a pair per table entry would ask for both an exclusive and a + shared borrow of one object and self-trap on legal code; and the + conditional-move drop rule (JOIN-DROP) is already normalized in the + table, so its entries are emitted as given. + + ---- register allocation -------------------------------------------- + + One scope-stack allocator per method, three tiers: + + r0 `self`, for a class method (the VM's window + convention: ICALL leaves the receiver in the callee's + r0, see runtime/src/vm.c's ICALL case) + next parameters, in declaration order — `arg_cnt` is + therefore (self ? 1 : 0) + parameter count + then locals, allocated on declaration (`let`, and a + `for`'s cursor plus its three loop-carried slots), + released when their block ends + above those expression temporaries from a high-water pool, reset + to the local watermark at every statement boundary + + Temporaries are a bump allocator, which is what makes the Lua-style + call window safe: a call's window sits at the current top, so every + register the callee's frame overlaps (it zeroes r[argc..regc) at + entry) is already dead. Argument slots are reserved *before* any + argument is evaluated, so a nested call inside argument i cannot + clobber an already-filled slot of the outer window. + + A method needing more than 64 registers is WO-E401 — a diagnostic, + never a truncation. + + ---- what the front end does not supply ----------------------------- + + Nothing in plan 2 exports a per-expression type table (types.ml's + pass 2 returns unit), so this file carries a small local type + resolver (`ty_of_expr`) for the four decisions that need one: EQ vs + EQS, direct CALL vs ICALL, which container builtin an `Index` or a + `get`/`set` names, and the element/key kinds a fresh container is + created with. It resolves types the same way owner.ml's own `expr_ty` + does; it does not re-derive ownership, kinds, or any table. *) + +open Ast + +module SM = Map.Make (String) + +(* ============================================================ + Diagnostics — WO-E4xx, the emitter's reserved range (diag.ml) + ============================================================ *) + +(* WO-E401 — the method needs more registers than the VM's 64-slot + window allows. The spec's register budget is a hard format limit + (runtime/src/wob.h WO_MAX_REGS), so this is a diagnostic and the + method is never silently truncated. *) +let over_budget_code = Diag.emitter_prefix ^ "01" + +(* WO-E402 — a value that does not fit the instruction encoding: more + than 65536 constants/classes/methods/interface slots (LOADK, NEW, + CALL and 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. Same doctrine as WO-E401: name the limit, emit + nothing. *) +let limit_code = Diag.emitter_prefix ^ "02" + +(* WO-E403 — a construct the milestone-1 instruction set cannot express + (an unresolved name, an element write into a `multi`, iterating a + `map`). The front end accepts more surface than the VM implements — + see the scope fence in the plan — and the emitter's job at that + boundary is to say so, not to invent bytecode. *) +let cannot_lower_code = Diag.emitter_prefix ^ "03" + +(* WO-E404 — a residual borrow site whose operand the emitter cannot tie + to a live register at the guarded region. Emitting the region + unguarded would drop the one enforcement the residual site exists for + (a real aliasing violation would go unchecked), so this fails loudly + instead. *) +let unguardable_code = Diag.emitter_prefix ^ "04" + +(* WO-E405 — the program entry (the zero-arg free fn `main` selected + below) declares a return type other than `Int`. The systems-track + spec (docs/superpowers/specs/2026-08-01-systems-track-design.md:70) + is explicit that a free `fn main(args) -> Int` compiles as a program + *because* the return value is the exit code — so an entry returning + a class was never legal, it just wasn't checked. Left unchecked, a + `@gc` return escapes into runtime/src/main.c's `uint64_t ret`, which + the driver has no way to release (the .wob method table carries no + return kind), inflating that object's refcount permanently — the + exact leak this diagnostic closes off at the source instead of in + the runtime. `main` with no return annotation at all is unaffected + (nothing declared, nothing to contradict `Int`). *) +let entry_return_code = Diag.emitter_prefix ^ "05" + +(* ============================================================ + Format constants (mirror of runtime/src/wob.h — never diverge) + ============================================================ *) + +let wob_magic = 0x31424F57 (* "WOB1" read as an LE u32 *) +let wob_version = 1 +let wob_hdr_size = 44 +let wob_none = 0xFFFFFFFF +let k_int = 0 +let k_text = 1 +let max_regs = 64 +let classf_gc = 0x01 + +let op_nop = 0 +let op_loadk = 1 +let op_move = 2 +let op_add = 3 +let op_sub = 4 +let op_mul = 5 +let op_div = 6 +let op_neg = 7 +let op_concat = 8 +let op_eq = 9 +let op_lt = 10 +let op_le = 11 +let op_eqs = 12 +let op_jmp = 13 +let op_jz = 14 +let op_call = 15 +let op_icall = 16 +let op_ret = 17 +let op_ret0 = 18 +let op_new = 19 +let op_getf = 20 +let op_setf = 21 +let op_drop = 22 +let op_borrow_s = 23 +let op_borrow_x = 24 +let op_release_s = 25 +let op_release_x = 26 +let op_rc_inc = 27 +let op_rc_dec = 28 +let op_builtin = 29 +let op_db_stub = 30 + +let b_now = 0 +let b_print = 1 +let b_print_int = 2 +let b_words = 3 +let b_multi_new = 4 +let b_multi_push = 5 +let b_multi_get = 6 +let b_count = 7 +let b_latest = 8 +let b_map_new = 9 +let b_map_set = 10 +let b_map_get = 11 +let b_map_has = 12 + +let ins_abc op a b c = op lor (a lsl 8) lor (b lsl 16) lor (c lsl 24) +let ins_abx op a bx = op lor (a lsl 8) lor (bx lsl 16) +let ins_asbx op a sbx = ins_abx op a (sbx + 32768) + +(* ============================================================ + Byte buffer + ============================================================ *) + +module Buf = struct + type t = { + mutable b : Bytes.t; + mutable len : int; + } + + let create () = { b = Bytes.create 1024; len = 0 } + + let room (t : t) (n : int) : unit = + if t.len + n > Bytes.length t.b then begin + let cap = ref (Bytes.length t.b) in + while t.len + n > !cap do + cap := !cap * 2 + done; + let nb = Bytes.create !cap in + Bytes.blit t.b 0 nb 0 t.len; + t.b <- nb + end + + let u8 (t : t) (v : int) : unit = + room t 1; + Bytes.set_uint8 t.b t.len (v land 0xFF); + t.len <- t.len + 1 + + let u16 (t : t) (v : int) : unit = + room t 2; + Bytes.set_uint16_le t.b t.len (v land 0xFFFF); + t.len <- t.len + 2 + + (* u32 via Int32: 0xFFFFFFFF (WOB_NONE) truncates to -1l, whose + little-endian bytes are FF FF FF FF — exactly what the loader + compares against. *) + let u32 (t : t) (v : int) : unit = + room t 4; + Bytes.set_int32_le t.b t.len (Int32.of_int v); + t.len <- t.len + 4 + + let i64 (t : t) (v : int64) : unit = + room t 8; + Bytes.set_int64_le t.b t.len v; + t.len <- t.len + 8 + + let str (t : t) (s : string) : unit = + let n = String.length s in + room t n; + Bytes.blit_string s 0 t.b t.len n; + t.len <- t.len + n + + let contents (t : t) : string = Bytes.sub_string t.b 0 t.len +end + +(* growable instruction array (jumps are patched after the fact) *) +type code = { + mutable a : int array; + mutable n : int; +} + +let code_create () = { a = Array.make 64 0; n = 0 } + +let code_push (c : code) (v : int) : unit = + if c.n = Array.length c.a then begin + let na = Array.make (2 * c.n) 0 in + Array.blit c.a 0 na 0 c.n; + c.a <- na + end; + c.a.(c.n) <- v; + c.n <- c.n + 1 + +(* ============================================================ + Program-level tables + ============================================================ *) + +type clsrec = { + cr_name : string; + cr_gc : bool; + cr_fields : (string * Ast.field_ty) array; + cr_methods : string list; (* method names, declaration order *) +} + +type ifacerec = { + ir_name : string; + ir_slot_base : int; + ir_methods : (string * int) list; (* name, parameter count *) +} + +type methrec = { + mr_name : string; + mr_class : int option; + mr_argc : int; + mutable mr_regc : int; + mutable mr_code : int array; + mutable mr_lines : (int * int) list; + mutable mr_drops : (int * int64 * int64) list; +} + +(* one input unit: a discovered file with its own program and its own + owner tables. Node ids are minted per parse, so they are unique + within a file and NOT across files — every side table keyed on a node + id is therefore built per unit. *) +type input = { + file : string; + prog : Ast.program; + tables : Owner.tables; +} + +type pctx = { + p_syms : Types.symbols; + p_coll : Diag.Collector.t; + p_classes : clsrec array; + p_class_id : int SM.t; + p_ifaces : ifacerec array; + p_iface_id : int SM.t; + (* method index by ("Class.method") for methods and ("name") for free + fns — free fns and classes share a namespace only through this + lookup, never in the emitted table *) + p_method_id : int SM.t; + p_methods : methrec array; + (* constant pool, deduplicated *) + p_kints : (int, int) Hashtbl.t; + p_ktexts : (string, int) Hashtbl.t; + mutable p_consts : [ `Int of int | `Text of string ] list; (* rev *) + mutable p_nconsts : int; +} + +let const_int (p : pctx) (v : int) : int = + match Hashtbl.find_opt p.p_kints v with + | Some i -> i + | None -> + let i = p.p_nconsts in + Hashtbl.replace p.p_kints v i; + p.p_consts <- `Int v :: p.p_consts; + p.p_nconsts <- i + 1; + i + +let const_text (p : pctx) (s : string) : int = + match Hashtbl.find_opt p.p_ktexts s with + | Some i -> i + | None -> + let i = p.p_nconsts in + Hashtbl.replace p.p_ktexts s i; + p.p_consts <- `Text s :: p.p_consts; + p.p_nconsts <- i + 1; + i + +(* ============================================================ + Per-method lowering state + ============================================================ *) + +type fstate = { + f_file : string; + f_fn : string; + f_code : code; + mutable f_cur_line : int; (* line of the construct being lowered *) + mutable f_line : int; (* last line written to the table *) + mutable f_lines : (int * int) list; (* rev *) + mutable f_owned : int64; (* running owned-register mask *) + mutable f_gc : int64; (* running @gc-register mask *) + mutable f_last_owned : int64; + mutable f_last_gc : int64; + mutable f_drops : (int * int64 * int64) list; (* rev *) + mutable f_nlocals : int; + mutable f_temp : int; + mutable f_max : int; + mutable f_env : (string * (int * Ast.field_ty)) list; (* innermost first *) + f_decl : (int, int) Hashtbl.t; (* declaring node id -> register *) + (* declaring nodes in declaration order, innermost last — a stack, so + the nodes a block declared are the ones pushed since it opened. + Needed because owner.ml anchors a scope's @gc releases on the + scope's own node, and an `if`'s THEN and ELSE scopes share that + node: which release belongs to which arm is answered by which + block declared the local. *) + mutable f_declared : int list; + f_node : (int, int) Hashtbl.t; (* expression node id -> register *) + (* register -> the kind of holder living in it, for the one case the + declaration site cannot answer: a whole-local assignment + re-initializes a local that had been moved out of, so its bit goes + back into the live mask *) + f_kind : (int, Owner.local_kind) Hashtbl.t; + (* this path has returned, so it contributes no state to a merge — + the same rule owner.ml's own `diverged` follows, for the same + reason: a branch that returned never reaches the join, and letting + its (empty) live set intersect the other branch's would strip a + still-live register out of the drop map *) + mutable f_div : bool; + (* the farthest pc any jump was patched to. A forward jump out of the + last `if`/`while` of a body targets the position *after* the last + instruction, which the loader reads as "jump out of code" — so the + implicit return has to be appended for that reason too, not only + when the last instruction is not a terminator. *) + mutable f_maxjmp : int; + mutable f_over : bool; (* WO-E401 already reported for this method *) +} + +(* ---- per-unit views of the four owner tables ---- *) + +type views = { + v_move : (int, string) Hashtbl.t; (* place-expr node -> moved place text *) + v_scope : (int * string, Owner.drop_item list) Hashtbl.t; + v_join : (int * string, Owner.drop_item list) Hashtbl.t; + v_return : (int, Owner.drop_item list) Hashtbl.t; + v_overwrite : (int, unit) Hashtbl.t; + v_mask : (int, Owner.drop_item list) Hashtbl.t; + (* holder decl nodes: every declaring node the DROPS table ever names. + This is how the emitter learns which locals the frame destroys + without re-deriving owner.ml's own "holds" decision. *) + v_holder : (int, Owner.local_kind) Hashtbl.t; + v_rc : (int, Owner.rc_site list) Hashtbl.t; (* by rc_node *) + (* region node -> the region's position and its per-operand coalesced + guards *) + v_res : (int, Ast.pos * (int * Owner.acc_kind) list) Hashtbl.t; + (* regions the emitter actually wrapped. owner.ml anchors a residual + region on four different node kinds (a call expression, an + assignment statement, a moved place); a region nobody consumed + would silently ship unguarded, which is the one failure mode a + residual site exists to prevent — so what is left over at the end of + the unit is WO-E404, never silence. *) + v_res_used : (int, unit) Hashtbl.t; +} + +let build_views (t : Owner.tables) : views = + let v = + { v_move = Hashtbl.create 16; v_scope = Hashtbl.create 16; v_join = Hashtbl.create 16; + v_return = Hashtbl.create 16; v_overwrite = Hashtbl.create 16; v_mask = Hashtbl.create 16; + v_holder = Hashtbl.create 16; v_rc = Hashtbl.create 16; v_res = Hashtbl.create 16; + v_res_used = Hashtbl.create 16 } + in + List.iter + (fun (m : Owner.move_site) -> Hashtbl.replace v.v_move m.Owner.mv_node m.Owner.mv_place) + t.Owner.moves; + List.iter + (fun (d : Owner.drop_site) -> + let items = d.Owner.dr_items in + List.iter + (fun (i : Owner.drop_item) -> Hashtbl.replace v.v_holder i.Owner.di_node i.Owner.di_kind) + items; + match d.Owner.dr_kind with + | Owner.DScope label -> Hashtbl.replace v.v_scope (d.Owner.dr_node, label) items + | Owner.DBranchJoin label -> Hashtbl.replace v.v_join (d.Owner.dr_node, label) items + | Owner.DReturn -> Hashtbl.replace v.v_return d.Owner.dr_node items + | Owner.DOverwrite -> Hashtbl.replace v.v_overwrite d.Owner.dr_node () + | Owner.DLiveMask -> Hashtbl.replace v.v_mask d.Owner.dr_node items) + t.Owner.drops; + List.iter + (fun (r : Owner.rc_site) -> + let prev = try Hashtbl.find v.v_rc r.Owner.rc_node with Not_found -> [] in + Hashtbl.replace v.v_rc r.Owner.rc_node (prev @ [ r ])) + t.Owner.rcs; + (* Guard coalescing, per dump.ml's normative note: one entry per + (region, operand) with the strongest access kind, never one pair per + table entry. AExcl outranks AShared; a move is never a residual + side. *) + (* AMove is defensive: a move names a whole local, and relate answers + Overlap or Disjoint for a place with no projections, so a move can + never be a residual side (dump.ml says the same). If one ever + appeared, an exclusive guard is the conservative reading. *) + let stronger (a : Owner.acc_kind) (b : Owner.acc_kind) = + match (a, b) with + | Owner.AExcl, _ | _, Owner.AExcl -> Owner.AExcl + | Owner.AMove, _ | _, Owner.AMove -> Owner.AExcl + | Owner.AShared, Owner.AShared -> Owner.AShared + in + List.iter + (fun (r : Owner.residual_site) -> + let cur = try snd (Hashtbl.find v.v_res r.Owner.rs_node) with Not_found -> [] in + let add acc (node, kind) = + match List.assoc_opt node acc with + | None -> acc @ [ (node, kind) ] + | Some k0 -> List.map (fun (n, k) -> if n = node then (n, stronger k0 kind) else (n, k)) acc + in + let cur = add cur (r.Owner.rs_a_node, r.Owner.rs_a_kind) in + let cur = add cur (r.Owner.rs_b_node, r.Owner.rs_b_kind) in + Hashtbl.replace v.v_res r.Owner.rs_node (r.Owner.rs_pos, cur)) + t.Owner.residuals; + v + +(* ============================================================ + Diagnostic helpers + ============================================================ *) + +let err (p : pctx) ~code ~(file : string) ~(pos : Ast.pos) ~message : unit = + Diag.Collector.add p.p_coll + (Diag.error ~code ~file ~line:pos.line ~col:pos.col ~message ()) + +(* ============================================================ + Registers + ============================================================ *) + +let bump (f : fstate) (r : int) : unit = if r > f.f_max then f.f_max <- r + +let over_budget (p : pctx) (f : fstate) (pos : Ast.pos) : unit = + if not f.f_over then begin + f.f_over <- true; + err p ~code:over_budget_code ~file:f.f_file ~pos + ~message: + (Printf.sprintf + "`%s` needs more than %d registers — the VM's register window is %d slots; split the \ + method or reduce the number of live locals" + f.f_fn max_regs max_regs) + end + +(* Past the budget the allocation is clamped so the instruction encoding + (one byte per register operand) stays well-formed: WO-E401 has already + failed the compile, and a malformed instruction array would only + crash the serializer on the way out. *) +let alloc_local (p : pctx) (f : fstate) (pos : Ast.pos) : int = + let r = f.f_nlocals in + if r >= max_regs then begin + over_budget p f pos; + max_regs - 1 + end + else begin + f.f_nlocals <- r + 1; + if f.f_temp < f.f_nlocals then f.f_temp <- f.f_nlocals; + bump f r; + r + end + +let alloc_temp (p : pctx) (f : fstate) (pos : Ast.pos) : int = + let r = f.f_temp in + if r >= max_regs then begin + over_budget p f pos; + max_regs - 1 + end + else begin + f.f_temp <- r + 1; + bump f r; + r + end + +let alloc_temps (p : pctx) (f : fstate) (pos : Ast.pos) (n : int) : int = + let base = f.f_temp in + for _ = 1 to n do + ignore (alloc_temp p f pos) + done; + if base >= max_regs then max_regs - 1 else base + +let stmt_reset (f : fstate) : unit = f.f_temp <- f.f_nlocals + +(* ============================================================ + Instruction emission (line + drop tables ride along) + ============================================================ *) + +let put (f : fstate) (ins : int) : unit = + let pc = f.f_code.n in + if f.f_cur_line <> f.f_line then begin + f.f_lines <- (pc, f.f_cur_line) :: f.f_lines; + f.f_line <- f.f_cur_line + end; + if f.f_owned <> f.f_last_owned || f.f_gc <> f.f_last_gc then begin + f.f_drops <- (pc, f.f_owned, f.f_gc) :: f.f_drops; + f.f_last_owned <- f.f_owned; + f.f_last_gc <- f.f_gc + end; + code_push f.f_code ins + +let here (f : fstate) : int = f.f_code.n + +let patch_jump (p : pctx) (f : fstate) ~(file : string) ~(pos : Ast.pos) (at : int) (target : int) + : unit = + let d = target - (at + 1) in + if d < -32768 || d > 32767 then + err p ~code:limit_code ~file ~pos + ~message: + (Printf.sprintf "jump displacement %d does not fit the 16-bit signed field" d) + else begin + if target > f.f_maxjmp then f.f_maxjmp <- target; + let ins = f.f_code.a.(at) in + f.f_code.a.(at) <- ins_asbx (ins land 0xFF) ((ins lsr 8) land 0xFF) d + end + +(* ---- live-mask bookkeeping ---- *) + +let mask_set (f : fstate) (kind : Owner.local_kind) (r : int) : unit = + Hashtbl.replace f.f_kind r kind; + let bit = Int64.shift_left 1L r in + match kind with + | Owner.LOwned -> f.f_owned <- Int64.logor f.f_owned bit + | Owner.LGc -> f.f_gc <- Int64.logor f.f_gc bit + +let mask_clear (f : fstate) (r : int) : unit = + let bit = Int64.lognot (Int64.shift_left 1L r) in + f.f_owned <- Int64.logand f.f_owned bit; + f.f_gc <- Int64.logand f.f_gc bit + +(* Control-flow merge: keep only what is live on *every* incoming path. + Over-approximating liveness at a merge is the dangerous direction — a + register the other path already moved out of would be dropped a second + time during unwinding — so the merge intersects. *) +let mask_meet (f : fstate) (o : int64) (g : int64) : unit = + f.f_owned <- Int64.logand f.f_owned o; + f.f_gc <- Int64.logand f.f_gc g + +(* ============================================================ + Types (the small local resolver — see this file's module doc) + ============================================================ *) + +let rec unwrap (ft : Ast.field_ty) : Ast.field_ty = + match ft with Nullable inner -> unwrap inner | t -> t + +let kind_byte : Types.wob_kind -> int = function + | Types.WO_K_SCALAR -> 0 + | Types.WO_K_OWNED -> 1 + | Types.WO_K_GCREF -> 2 + | Types.WO_K_TEXT -> 3 + | Types.WO_K_MULTI -> 4 + | Types.WO_K_MAP -> 5 + (* `?T` has no kind byte of its own in the v1 format (kinds run 0..5; + the loader rejects 6). It needs none: a nullable field stores what + T stores and spells nil as 0, and every drop plan in + runtime/src/gc.c already ignores a 0 slot for every kind. So the + kind of `?T` is the kind of T — resolved by unwrapping before the + call, this arm is unreachable. *) + | Types.WO_K_NULLABLE -> 0 + +let field_kind (p : pctx) (ft : Ast.field_ty) : int = + kind_byte (Types.wob_kind_of_typ p.p_syms (Types.typ_of_field_ty (unwrap ft))) + +let class_of_name (p : pctx) (n : string) : int option = SM.find_opt n p.p_class_id + +let field_of (p : pctx) (cid : int) (fname : string) : (int * Ast.field_ty) option = + let fs = p.p_classes.(cid).cr_fields in + let rec go i = if i >= Array.length fs then None else + let n, ty = fs.(i) in + if n = fname then Some (i, ty) else go (i + 1) + in + go 0 + +let free_fn (p : pctx) (n : string) : Types.free_fn_info option = + Types.StringMap.find_opt n p.p_syms.Types.free_fns + +let class_method (p : pctx) (cname : string) (m : string) : Types.method_info option = + match Types.StringMap.find_opt cname p.p_syms.Types.classes with + | None -> None + | Some (c : Types.class_info) -> + List.find_opt (fun (mi : Types.method_info) -> mi.Types.name = m) c.Types.methods + +let iface_method (p : pctx) (iname : string) (m : string) : (int * Types.method_sig_info) option = + match SM.find_opt iname p.p_iface_id with + | None -> None + | Some iid -> ( + let ir = p.p_ifaces.(iid) in + let rec go i = function + | [] -> None + | (n, _) :: tl -> if n = m then Some (ir.ir_slot_base + i) else go (i + 1) tl + in + match go 0 ir.ir_methods with + | None -> None + | Some slot -> ( + match Types.StringMap.find_opt iname p.p_syms.Types.interfaces with + | None -> None + | Some (ii : Types.interface_info) -> ( + match List.find_opt (fun (s : Types.method_sig_info) -> s.Types.name = m) ii.Types.methods with + | None -> None + | Some sg -> Some (slot, sg)))) + +let builtin_ret (name : string) (argty : Ast.field_ty option) : Ast.field_ty option = + match name with + | "now" -> Some (Scalar "Timestamp") + | "print" | "print_int" | "push" | "set" -> Some (Scalar "Int") + | "words" | "count" -> Some (Scalar "Int") + | "has" -> Some (Scalar "Bool") + | "latest" -> ( match argty with Some t -> ( match unwrap t with Multi e -> Some (Scalar e) | _ -> None) | None -> None) + | "get" -> ( + match argty with + | Some t -> ( match unwrap t with Multi e -> Some (Scalar e) | Map (_, v) -> Some (Scalar v) | _ -> None) + | None -> None) + | _ -> None + +let is_builtin_name (n : string) = + List.mem n + [ "now"; "print"; "print_int"; "words"; "multi_new"; "push"; "get"; "count"; "latest"; + "map_new"; "set"; "has" ] + +let rec ty_of_expr (p : pctx) (f : fstate) (e : Ast.expr) : Ast.field_ty option = + match e.kind with + | IntLit _ -> Some (Scalar "Int") + | StrLit _ -> Some (Scalar "Text") + | BoolLit _ -> Some (Scalar "Bool") + | Ident n -> ( match List.assoc_opt n f.f_env with Some (_, t) -> Some t | None -> None) + | Field (base, fname) -> ( + match ty_of_expr p f base with + | Some bt -> ( + match unwrap bt with + | Scalar cn -> ( + match class_of_name p cn with + | Some cid -> ( match field_of p cid fname with Some (_, t) -> Some t | None -> None) + | None -> None) + | _ -> None) + | None -> None) + | Index (base, _) -> ( + match ty_of_expr p f base with + | Some bt -> ( match unwrap bt with Multi e' -> Some (Scalar e') | Map (_, v) -> Some (Scalar v) | _ -> None) + | None -> None) + | Call (callee, args) -> ( + match callee.kind with + | Ident n -> ( + match free_fn p n with + | Some fi -> fi.Types.ret + | None -> + if is_builtin_name n then + builtin_ret n (match args with a :: _ -> ty_of_expr p f a | [] -> None) + else None) + | Field (base, mname) -> ( + match ty_of_expr p f base with + | Some bt -> ( + match unwrap bt with + | Scalar cn -> ( + match class_method p cn mname with + | Some mi -> mi.Types.ret + | None -> ( match iface_method p cn mname with Some (_, sg) -> sg.Types.ret | None -> None)) + | _ -> None) + | None -> None) + | _ -> None) + | Unary (Neg, o) -> ty_of_expr p f o + | Binary (op, l, _) -> ( + match op with + | Concat -> Some (Scalar "Text") + | Eq | Ne | Lt | Le | Gt | Ge -> Some (Scalar "Bool") + | Add | Sub | Mul | Div | Mod -> ( match ty_of_expr p f l with Some t -> Some t | None -> Some (Scalar "Int"))) + | Ctor (cn, _) -> Some (Scalar cn) + | DbStub _ -> None + +let is_text (p : pctx) (f : fstate) (e : Ast.expr) : bool = + match ty_of_expr p f e with Some t -> ( match unwrap t with Scalar "Text" -> true | _ -> false) | None -> false + +(* ============================================================ + Lowering + ============================================================ *) + +let lookup_local (f : fstate) (n : string) : (int * Ast.field_ty) option = List.assoc_opt n f.f_env + +let reg_of_item (p : pctx) (f : fstate) (i : Owner.drop_item) : int option = + match Hashtbl.find_opt f.f_decl i.Owner.di_node with + | Some r -> Some r + | None -> ( + match lookup_local f i.Owner.di_name with + | Some (r, _) -> Some r + | None -> + err p ~code:unguardable_code ~file:f.f_file ~pos:{ line = 0; col = 0 } + ~message: + (Printf.sprintf "ownership table names `%s`, which has no register in `%s`" + i.Owner.di_name f.f_fn); + None) + +(* DROP for every item of a drop set, in the table's own order (the + tables already list them innermost-scope-first, reverse declaration + order inside a scope — that is destruction order). *) +let emit_drops (p : pctx) (f : fstate) (items : Owner.drop_item list) : unit = + List.iter + (fun (i : Owner.drop_item) -> + match reg_of_item p f i with + | None -> () + | Some r -> ( + match i.Owner.di_kind with + | Owner.LOwned -> + put f (ins_abc op_drop r 0 0); + mask_clear f r + | Owner.LGc -> + (* a @gc handle's release is an rc site, never a DROP: the RC + table carries it (with its own ELIDED decision) *) + ())) + items + +let emit_scope_drops (p : pctx) (f : fstate) (v : views) ~(node : int) ~(label : string) : unit = + match Hashtbl.find_opt v.v_scope (node, label) with + | Some items -> emit_drops p f items + | None -> () + +(* the declaring nodes a block introduced: everything pushed onto the + declaration stack since it opened *) +let declared_since (f : fstate) (saved : int list) : int list = + let rec take n l = + if n <= 0 then [] else match l with [] -> [] | x :: tl -> x :: take (n - 1) tl + in + take (List.length f.f_declared - List.length saved) f.f_declared + +let emit_join_drops (p : pctx) (f : fstate) (v : views) ~(node : int) ~(label : string) : unit = + match Hashtbl.find_opt v.v_join (node, label) with + | Some items -> emit_drops p f items + | None -> () + +(* RC sites, minus the ELIDED ones — the spec's zero-cost promise lives + here and in the residual-only borrow rule. `which` selects acquires or + releases; a return site carries both plus the returned value's own + escape increment, and acquires must precede releases or a balanced + pair could momentarily reach rc 0. *) +let emit_rc (p : pctx) (f : fstate) (v : views) ~(node : int) ~(acquire : bool) + ?(groups : int list option) () : unit = + match Hashtbl.find_opt v.v_rc node with + | None -> () + | Some sites -> + List.iter + (fun (r : Owner.rc_site) -> + let want = match r.Owner.rc_op with Owner.RcAcquire -> true | Owner.RcRelease -> false in + let in_scope = + match groups with None -> true | Some gs -> List.mem r.Owner.rc_group gs + in + if want = acquire && in_scope && not r.Owner.rc_elided then + let reg = + if r.Owner.rc_group >= 0 then Hashtbl.find_opt f.f_decl r.Owner.rc_group + else Hashtbl.find_opt f.f_node r.Owner.rc_node + in + match reg with + | Some g -> + put f (ins_abc (if acquire then op_rc_inc else op_rc_dec) g 0 0); + if not acquire then mask_clear f g + | None -> + err p ~code:unguardable_code ~file:f.f_file ~pos:r.Owner.rc_pos + ~message: + (Printf.sprintf "rc site for `%s` has no register in `%s`" r.Owner.rc_place f.f_fn)) + sites + +(* The frame's drop map at a call / DB_STUB site. The owner table is + authoritative here (it is taken after the call's own argument + transfers, so a value moved in is the callee's responsibility); an + absent entry means nothing is live. *) +let sync_mask (p : pctx) (f : fstate) (v : views) (node : int) : unit = + f.f_owned <- 0L; + f.f_gc <- 0L; + match Hashtbl.find_opt v.v_mask node with + | None -> () + | Some items -> + List.iter + (fun (i : Owner.drop_item) -> + match reg_of_item p f i with Some r -> mask_set f i.Owner.di_kind r | None -> ()) + items + +(* Residual borrow guards around one region, coalesced per operand. + [gbase] names a run of registers reserved *below* the call window: a + guard may not live in the window itself, because the callee's frame + overlaps the window (it may assign to its own parameters, and it + zeroes the slots above them) and the call's return value lands on the + window base. Releasing such a register after the call would hand the + borrow word of whatever now sits there — in the worst case an + integer result — to wo_release_excl. So each guarded operand is + copied out of the window into its own stable slot first. *) +let residual_guards (p : pctx) (f : fstate) (v : views) (node : int) (gbase : int option) : + (int * Owner.acc_kind) list = + match Hashtbl.find_opt v.v_res node with + | None -> [] + | Some (pos, ops) -> + Hashtbl.replace v.v_res_used node (); + List.concat + (List.mapi + (fun i (onode, kind) -> + match Hashtbl.find_opt f.f_node onode with + | Some r -> ( + match gbase with + | None -> [ (r, kind) ] (* no call in the region: guard in place *) + | Some gb -> + let g = gb + i in + if g <> r then put f (ins_abc op_move g r 0); + [ (g, kind) ]) + | None -> + err p ~code:unguardable_code ~file:f.f_file ~pos + ~message: + (Printf.sprintf + "residual borrow site in `%s` names an operand with no live register — the \ + runtime guard cannot be placed" + f.f_fn); + []) + ops) + +let residual_count (v : views) (node : int) : int = + match Hashtbl.find_opt v.v_res node with None -> 0 | Some (_, ops) -> List.length ops + +let acquire_guards (f : fstate) (gs : (int * Owner.acc_kind) list) : unit = + List.iter + (fun (r, k) -> + match k with + | Owner.AShared -> put f (ins_abc op_borrow_s r 0 0) + | Owner.AExcl | Owner.AMove -> put f (ins_abc op_borrow_x r 0 0)) + gs + +let release_guards (f : fstate) (gs : (int * Owner.acc_kind) list) : unit = + List.iter + (fun (r, k) -> + match k with + | Owner.AShared -> put f (ins_abc op_release_s r 0 0) + | Owner.AExcl | Owner.AMove -> put f (ins_abc op_release_x r 0 0)) + (List.rev gs) + +let check_bx (p : pctx) (f : fstate) (pos : Ast.pos) (what : string) (v : int) : int = + if v > 0xFFFF then begin + err p ~code:limit_code ~file:f.f_file ~pos + ~message:(Printf.sprintf "%s index %d exceeds the 16-bit instruction field" what v); + 0 + end + else v + +let check_field_idx (p : pctx) (f : fstate) (pos : Ast.pos) (v : int) : int = + if v > 0xFF then begin + err p ~code:limit_code ~file:f.f_file ~pos + ~message:(Printf.sprintf "field index %d exceeds the 8-bit GETF/SETF field" v); + 0 + end + else v + +(* Kind immediates for a fresh container, taken from the type the value is + expected to have at its destination (a constructor field, an + assignment target, a declared parameter type). There is deliberately + no default: the kinds are the container's drop plan + (runtime/src/gc.c), so guessing SCALAR for a `multi Item` would leak + every element and guessing it for a `map` would leak every + key. Milestone-1 `let` has no container type annotation (parser.ml + takes a bare identifier), so a fresh container must be created + somewhere its type is declared — otherwise WO-E403 at the creation + site, where the reader can act on it. *) +let container_imm (p : pctx) (expected : Ast.field_ty option) (map : bool) : int option = + match expected with + | Some t -> ( + match unwrap t with + | Multi e when not map -> Some (field_kind p (Scalar e)) + | Map (k, v) when map -> Some (field_kind p (Scalar k) lor (field_kind p (Scalar v) lsl 4)) + | _ -> None) + | None -> None + +let rec emit_expr (p : pctx) (f : fstate) (v : views) ~(dst : int) ?expected (e : Ast.expr) : unit = + f.f_cur_line <- e.pos.line; + (match e.kind with + | IntLit n -> put f (ins_abx op_loadk dst (check_bx p f e.pos "constant" (const_int p n))) + | BoolLit b -> put f (ins_abx op_loadk dst (check_bx p f e.pos "constant" (const_int p (if b then 1 else 0)))) + | StrLit s -> put f (ins_abx op_loadk dst (check_bx p f e.pos "constant" (const_text p s))) + | Ident n -> ( + match lookup_local f n with + | Some (r, _) -> if r <> dst then put f (ins_abc op_move dst r 0) + | None -> + err p ~code:cannot_lower_code ~file:f.f_file ~pos:e.pos + ~message:(Printf.sprintf "`%s` is not a local, parameter, or `self` — nothing to load" n); + put f (ins_abx op_loadk dst (const_int p 0))) + | Field (base, fname) -> ( + match ty_of_expr p f base with + | Some bt -> ( + match unwrap bt with + | Scalar cn -> ( + match class_of_name p cn with + | Some cid -> ( + match field_of p cid fname with + | Some (idx, _) -> + let b = emit_operand p f v base in + put f (ins_abc op_getf dst b (check_field_idx p f e.pos idx)) + | None -> + err p ~code:cannot_lower_code ~file:f.f_file ~pos:e.pos + ~message:(Printf.sprintf "`%s` has no field `%s`" cn fname); + put f (ins_abx op_loadk dst (const_int p 0))) + | None -> + err p ~code:cannot_lower_code ~file:f.f_file ~pos:e.pos + ~message:(Printf.sprintf "field access on `%s`, which is not a declared class" cn); + put f (ins_abx op_loadk dst (const_int p 0))) + | _ -> + err p ~code:cannot_lower_code ~file:f.f_file ~pos:e.pos + ~message:(Printf.sprintf "field `%s` read from a value that is not a class instance" fname); + put f (ins_abx op_loadk dst (const_int p 0))) + | None -> + err p ~code:cannot_lower_code ~file:f.f_file ~pos:e.pos + ~message: + (Printf.sprintf "cannot resolve the type of the value `%s` is read from" fname); + put f (ins_abx op_loadk dst (const_int p 0))) + | Index (base, idx) -> ( + let bid = + match ty_of_expr p f base with + | Some bt -> ( match unwrap bt with Multi _ -> Some b_multi_get | Map _ -> Some b_map_get | _ -> None) + | None -> None + in + match bid with + | None -> + err p ~code:cannot_lower_code ~file:f.f_file ~pos:e.pos + ~message:"indexing a value that is neither a `multi` nor a `map`"; + put f (ins_abx op_loadk dst (const_int p 0)) + | Some bid -> + let w = alloc_temps p f e.pos 2 in + emit_expr p f v ~dst:w base; + emit_expr p f v ~dst:(w + 1) idx; + put f (ins_abc op_builtin dst w bid)) + | Unary (Neg, o) -> + let b = emit_operand p f v o in + put f (ins_abc op_neg dst b 0) + | Binary (op, l, r) -> emit_binary p f v ~dst op l r + | Ctor (cn, fields) -> emit_ctor p f v ~dst e cn fields + | Call (callee, args) -> emit_call p f v ~dst ?expected e callee args + | DbStub _ -> + sync_mask p f v e.id; + put f (ins_abc op_db_stub 0 0 0)); + Hashtbl.replace f.f_node e.id dst; + (* An escaping @gc value takes its increment right where the value + lands. owner.ml's gc_escape anchors that acquire on the *place + expression's* own node and gives it group -1 (never elidable), and + it fires for all four escapes alike: a constructor field, an + assignment into a field, a `take` argument, and a return. Emitting + it here — once, at the one place every expression passes through — + is what keeps all four in step; anchoring it per statement kind is + how the constructor-field case went missing. *) + emit_rc p f v ~node:e.id ~acquire:true () + +(* An operand that only needs to *be* in some register: a place already + living in one is used where it is, everything else lands in a fresh + temporary. This is what keeps a proven method's disassembly free of + pointless MOVEs. *) +and emit_operand (p : pctx) (f : fstate) (v : views) (e : Ast.expr) : int = + match e.kind with + | Ident n when lookup_local f n <> None -> + let r = match lookup_local f n with Some (r, _) -> r | None -> 0 in + Hashtbl.replace f.f_node e.id r; + r + | _ -> + let t = alloc_temp p f e.pos in + emit_expr p f v ~dst:t e; + t + +(* The last value a statement computes (a `return` operand, a discarded + expression statement) needs no reserved slot: a place stays where it + lives, everything else lands in the next free temporary, which a call + then reuses as its own window base so the result needs no MOVE. *) +and emit_tail (p : pctx) (f : fstate) (v : views) (e : Ast.expr) : int = + match e.kind with + | Ident n when lookup_local f n <> None -> + let r = match lookup_local f n with Some (r, _) -> r | None -> 0 in + Hashtbl.replace f.f_node e.id r; + emit_rc p f v ~node:e.id ~acquire:true (); + r + | _ -> + (* allocate (so the register counts towards the budget and the + method's register count) and immediately un-reserve *) + let t = alloc_temp p f e.pos in + f.f_temp <- t; + emit_expr p f v ~dst:t e; + t + +and emit_binary (p : pctx) (f : fstate) (v : views) ~(dst : int) (op : Ast.binop) (l : Ast.expr) + (r : Ast.expr) : unit = + let pos = l.pos in + let simple o = + let a = emit_operand p f v l in + let b = emit_operand p f v r in + put f (ins_abc o dst a b) + in + let swapped o = + let a = emit_operand p f v l in + let b = emit_operand p f v r in + put f (ins_abc o dst b a) + in + match op with + | Add -> simple op_add + | Sub -> simple op_sub + | Mul -> simple op_mul + | Div -> simple op_div + | Concat -> simple op_concat + | Lt -> simple op_lt + | Le -> simple op_le + | Gt -> swapped op_lt + | Ge -> swapped op_le + | Eq -> if is_text p f l || is_text p f r then simple op_eqs else simple op_eq + | Ne -> + (* no NE opcode in the v1 set: `a != b` is `(a == b) == 0`. The + format doc governs, so this is a lowering, not a new opcode. *) + let a = emit_operand p f v l in + let b = emit_operand p f v r in + let t = alloc_temp p f pos in + put f (ins_abc (if is_text p f l || is_text p f r then op_eqs else op_eq) t a b); + let z = alloc_temp p f pos in + put f (ins_abx op_loadk z (check_bx p f pos "constant" (const_int p 0))); + put f (ins_abc op_eq dst t z) + | Mod -> + (* no MOD opcode either: truncating `a % b` is `a - (a / b) * b`, + exact for the VM's truncating DIV (which traps on 0 and on + INT64_MIN / -1 — both correct for `%` as well). *) + let a = emit_operand p f v l in + let b = emit_operand p f v r in + let q = alloc_temp p f pos in + put f (ins_abc op_div q a b); + put f (ins_abc op_mul q q b); + put f (ins_abc op_sub dst a q) + +and emit_ctor (p : pctx) (f : fstate) (v : views) ~(dst : int) (e : Ast.expr) (cn : string) + (fields : (string * Ast.expr) list) : unit = + match class_of_name p cn with + | None -> + err p ~code:cannot_lower_code ~file:f.f_file ~pos:e.pos + ~message:(Printf.sprintf "constructor of `%s`, which is not a declared class" cn); + put f (ins_abx op_loadk dst (const_int p 0)) + | Some cid -> + put f (ins_abx op_new dst (check_bx p f e.pos "class" cid)); + List.iter + (fun ((fname : string), (fe : Ast.expr)) -> + match field_of p cid fname with + | None -> + err p ~code:cannot_lower_code ~file:f.f_file ~pos:e.pos + ~message:(Printf.sprintf "`%s` has no field `%s`" cn fname) + | Some (idx, fty) -> + (* one temporary, reused for every field: each field's value is + dead the instant its SETF retires, so holding a slot per + field would make a wide class (the normal shape of a @table + class) burn the register budget for nothing. Same + save/restore the call and builtin windows use. *) + let save = f.f_temp in + let t = alloc_temp p f fe.pos in + emit_expr p f v ~dst:t ~expected:fty fe; + f.f_cur_line <- fe.pos.line; + put f (ins_abc op_setf dst (check_field_idx p f e.pos idx) t); + f.f_temp <- save) + fields + +(* ---- calls --------------------------------------------------------- + + The window convention (runtime/src/vm.c's CALL / ICALL): the callee's + r0 is the caller's slot A, arguments occupy A..A+argc-1, and the + return value lands back in A. `arg_cnt` counts `self`, so a method + call places the receiver at A and its arguments from A+1. + + The whole window is reserved before any argument is evaluated: a + nested call inside argument i then gets a window above the outer one + and cannot clobber an already-filled slot. *) +and emit_call (p : pctx) (f : fstate) (v : views) ~(dst : int) ?expected (e : Ast.expr) + (callee : Ast.expr) (args : Ast.expr list) : unit = + ignore expected; + match callee.kind with + | Ident name -> ( + match free_fn p name with + | Some fi -> emit_direct p f v ~dst e ~key:name ~recv:None ~params:fi.Types.params args + | None -> + if is_builtin_name name then emit_builtin p f v ~dst ?expected e name args + else begin + err p ~code:cannot_lower_code ~file:f.f_file ~pos:e.pos + ~message:(Printf.sprintf "call to `%s`, which is not a declared fn or a builtin" name); + put f (ins_abx op_loadk dst (const_int p 0)) + end) + | Field (base, mname) -> ( + match ty_of_expr p f base with + | Some bt -> ( + match unwrap bt with + | Scalar cn -> ( + match class_method p cn mname with + | Some mi -> + emit_direct p f v ~dst e ~key:(cn ^ "." ^ mname) ~recv:(Some base) ~params:mi.Types.params + args + | None -> ( + match iface_method p cn mname with + | Some (slot, sg) -> emit_iface p f v ~dst e ~slot ~base ~params:sg.Types.params args + | None -> + err p ~code:cannot_lower_code ~file:f.f_file ~pos:e.pos + ~message:(Printf.sprintf "`%s` has no method `%s`" cn mname); + put f (ins_abx op_loadk dst (const_int p 0)))) + | _ -> + err p ~code:cannot_lower_code ~file:f.f_file ~pos:e.pos + ~message:(Printf.sprintf "method `%s` called on a value that is not a class instance" mname); + put f (ins_abx op_loadk dst (const_int p 0))) + | None -> + err p ~code:cannot_lower_code ~file:f.f_file ~pos:e.pos + ~message:(Printf.sprintf "cannot resolve the receiver's type for the call to `%s`" mname); + put f (ins_abx op_loadk dst (const_int p 0))) + | _ -> + err p ~code:cannot_lower_code ~file:f.f_file ~pos:e.pos + ~message:"only a name or a `receiver.method` form can be called"; + put f (ins_abx op_loadk dst (const_int p 0)) + +(* The call-site argument count has to match the callee's, or the window + the caller reserves is not the window the callee reads: the loader's + own "call window exceeds frame" check rejects the image, and a + too-wide call would hand the callee whatever the caller happened to + leave in those registers. Nothing upstream catches this — types.ml + declares WO-E203 for bad arity and never raises it (see the error + catalog) — so the emitter is where it stops. *) +and check_arity (p : pctx) (f : fstate) (e : Ast.expr) ~(what : string) ~(want : int) + ~(got : int) : bool = + if want = got then true + else begin + err p ~code:cannot_lower_code ~file:f.f_file ~pos:e.pos + ~message: + (Printf.sprintf "%s takes %d argument(s), given %d" what want got); + false + end + +and call_window (p : pctx) (f : fstate) (v : views) (e : Ast.expr) ~(recv : Ast.expr option) + ~(params : (string * Ast.field_ty * Ast.param_conv) list) (args : Ast.expr list) : int * int = + let nrecv = match recv with Some _ -> 1 | None -> 0 in + let argc = nrecv + List.length args in + let base = alloc_temps p f e.pos (max argc 1) in + (match recv with None -> () | Some r -> emit_expr p f v ~dst:base r); + List.iteri + (fun i a -> + let expected = match List.nth_opt params i with Some (_, t, _) -> Some t | None -> None in + let slot = base + nrecv + i in + let save = f.f_temp in + (match expected with + | Some t -> emit_expr p f v ~dst:slot ~expected:t a + | None -> emit_expr p f v ~dst:slot a); + f.f_temp <- save) + args; + (base, argc) + +and emit_direct (p : pctx) (f : fstate) (v : views) ~(dst : int) (e : Ast.expr) ~(key : string) + ~(recv : Ast.expr option) ~(params : (string * Ast.field_ty * Ast.param_conv) list) + (args : Ast.expr list) : unit = + match SM.find_opt key p.p_method_id with + | None -> + err p ~code:cannot_lower_code ~file:f.f_file ~pos:e.pos + ~message:(Printf.sprintf "no emitted method for `%s`" key); + put f (ins_abx op_loadk dst (const_int p 0)) + | Some midx when check_arity p f e ~what:(Printf.sprintf "`%s`" key) + ~want:(List.length params) ~got:(List.length args) -> + let gbase = alloc_temps p f e.pos (residual_count v e.id) in + let base, _ = call_window p f v e ~recv ~params args in + let guards = residual_guards p f v e.id (Some gbase) in + acquire_guards f guards; + (* the frame's drop map, from the owner table, effective at the CALL *) + sync_mask p f v e.id; + f.f_cur_line <- e.pos.line; + put f (ins_abx op_call base (check_bx p f e.pos "method" midx)); + release_guards f guards; + if dst <> base then put f (ins_abc op_move dst base 0) + | Some _ -> put f (ins_abx op_loadk dst (const_int p 0)) + +and emit_iface (p : pctx) (f : fstate) (v : views) ~(dst : int) (e : Ast.expr) ~(slot : int) + ~(base : Ast.expr) ~(params : (string * Ast.field_ty * Ast.param_conv) list) + (args : Ast.expr list) : unit = + if not (check_arity p f e ~what:"the interface method" ~want:(List.length params) + ~got:(List.length args)) then + put f (ins_abx op_loadk dst (const_int p 0)) + else begin + let gbase = alloc_temps p f e.pos (residual_count v e.id) in + let w, _ = call_window p f v e ~recv:(Some base) ~params args in + let guards = residual_guards p f v e.id (Some gbase) in + acquire_guards f guards; + sync_mask p f v e.id; + f.f_cur_line <- e.pos.line; + put f (ins_abx op_icall w (check_bx p f e.pos "interface slot" slot)); + release_guards f guards; + if dst <> w then put f (ins_abc op_move dst w 0) + end + +(* ---- builtins ------------------------------------------------------ + + Source spellings of the format doc's BUILTIN ids. `get`/`set` and + `push`/`count`/`latest`/`has` read on the container they are given, so + one source name covers a `multi` and a `map` where the runtime has two + ids. A user-declared free fn of the same name wins (checked before + this function is reached) — a declared name is never shadowed by a + builtin. *) +and emit_builtin (p : pctx) (f : fstate) (v : views) ~(dst : int) ?expected (e : Ast.expr) + (name : string) (args : Ast.expr list) : unit = + let bad msg = + err p ~code:cannot_lower_code ~file:f.f_file ~pos:e.pos ~message:msg; + put f (ins_abx op_loadk dst (const_int p 0)) + in + let arity_of id = + if id = b_now || id = b_multi_new || id = b_map_new then 0 + else if id = b_print || id = b_print_int || id = b_words || id = b_count || id = b_latest then 1 + else if id = b_multi_push || id = b_multi_get || id = b_map_get || id = b_map_has then 2 + else 3 + in + let container_id first_arg on_multi on_map = + match ty_of_expr p f first_arg with + | Some t -> ( match unwrap t with Multi _ -> Some on_multi | Map _ -> Some on_map | _ -> None) + | None -> None + in + let fixed id = + let n = arity_of id in + if List.length args <> n then + bad (Printf.sprintf "builtin `%s` takes %d argument(s), given %d" name n (List.length args)) + else begin + let base = alloc_temps p f e.pos (max n 1) in + List.iteri + (fun i a -> + let save = f.f_temp in + emit_expr p f v ~dst:(base + i) a; + f.f_temp <- save) + args; + sync_mask p f v e.id; + f.f_cur_line <- e.pos.line; + put f (ins_abc op_builtin dst base id) + end + in + match name with + | "now" -> fixed b_now + | "print" -> fixed b_print + | "print_int" -> fixed b_print_int + | "words" -> fixed b_words + | "count" -> fixed b_count + | "latest" -> fixed b_latest + | "multi_new" | "map_new" -> + let is_map = name = "map_new" in + if args <> [] then bad (Printf.sprintf "builtin `%s` takes no arguments" name) + else ( + match container_imm p expected is_map with + | None -> + bad + (Printf.sprintf + "`%s` needs a destination of declared type `%s` — its element kinds are the container's drop plan and cannot be guessed; build it into a field of that type" + name + (if is_map then "map" else "multi T")) + | Some imm -> + sync_mask p f v e.id; + f.f_cur_line <- e.pos.line; + put f (ins_abc op_builtin dst imm (if is_map then b_map_new else b_multi_new))) + | "push" -> ( + match args with + | a :: _ -> ( + match container_id a b_multi_push b_multi_push with + | Some id -> fixed id + | None -> bad "builtin `push` needs a `multi` as its first argument") + | [] -> bad "builtin `push` takes 2 arguments, given 0") + | "get" -> ( + match args with + | a :: _ -> ( + match container_id a b_multi_get b_map_get with + | Some id -> fixed id + | None -> bad "builtin `get` needs a `multi` or a `map` as its first argument") + | [] -> bad "builtin `get` takes 2 arguments, given 0") + | "set" -> ( + match args with + | a :: _ -> ( + match container_id a b_map_set b_map_set with + | Some id -> fixed id + | None -> bad "builtin `set` needs a `map` as its first argument") + | [] -> bad "builtin `set` takes 3 arguments, given 0") + | "has" -> ( + match args with + | a :: _ -> ( + match container_id a b_map_has b_map_has with + | Some id -> fixed id + | None -> bad "builtin `has` needs a `map` as its first argument") + | [] -> bad "builtin `has` takes 2 arguments, given 0") + | _ -> bad (Printf.sprintf "unknown builtin `%s`" name) + +(* ---- statements ---------------------------------------------------- *) + +and emit_stmt (p : pctx) (f : fstate) (v : views) (s : Ast.stmt) : unit = + stmt_reset f; + f.f_cur_line <- s.s_pos.line; + match s.s_kind with + | Let { name; ty; value } -> + let declared = match ty with Some t -> Some (Scalar t) | None -> None in + let vty = + match declared with + | Some t -> t + | None -> ( match ty_of_expr p f value with Some t -> t | None -> Scalar "Int") + in + let r = alloc_local p f s.s_pos in + (match declared with + | Some t -> emit_expr p f v ~dst:r ~expected:t value + | None -> emit_expr p f v ~dst:r value); + f.f_env <- (name, (r, vty)) :: f.f_env; + Hashtbl.replace f.f_decl s.s_id r; + f.f_declared <- s.s_id :: f.f_declared; + (* a real transfer at this `let` retires the source: the VM's MOVE is + the move, so the only thing left to do is stop calling the source + live (the format doc's own words) *) + (match Hashtbl.find_opt v.v_move value.id with + | Some place -> ( match lookup_local f place with Some (sr, _) -> mask_clear f sr | None -> ()) + | None -> ()); + (match Hashtbl.find_opt v.v_holder s.s_id with + | Some kind -> mask_set f kind r + | None -> ()); + emit_rc p f v ~node:s.s_id ~acquire:true () + | Assign { target; value } -> emit_assign p f v s target value + | ExprStmt e -> + (* a discarded value lands in the next free temporary *without* + reserving it: a call then places its own window at that same slot + and needs no MOVE to hand the result back, and nothing later in + this statement can want the register (the statement ends here) *) + ignore (emit_tail p f v e); + (match Hashtbl.find_opt v.v_move e.id with + | Some place -> ( match lookup_local f place with Some (sr, _) -> mask_clear f sr | None -> ()) + | None -> ()) + | Return opt -> emit_return p f v s opt + | If { cond; then_body; else_body } -> emit_if p f v s cond then_body else_body + | While { cond; body } -> emit_while p f v s cond body + | For { var; iter; body } -> emit_for p f v s var iter body + +and emit_assign (p : pctx) (f : fstate) (v : views) (s : Ast.stmt) (target : Ast.expr) + (value : Ast.expr) : unit = + let overwrite = Hashtbl.mem v.v_overwrite s.s_id in + (* a @gc value the assignment displaces is released, not dropped: the + RC table carries that RELEASE at the assignment's own node *) + let releases = + match Hashtbl.find_opt v.v_rc s.s_id with + | None -> false + | Some sites -> + List.exists + (fun (r : Owner.rc_site) -> + r.Owner.rc_op = Owner.RcRelease && not r.Owner.rc_elided) + sites + in + match target.kind with + | Ident n -> ( + match lookup_local f n with + | None -> + err p ~code:cannot_lower_code ~file:f.f_file ~pos:target.pos + ~message:(Printf.sprintf "assignment to `%s`, which is not a local or parameter" n) + | Some (r, ty) -> + Hashtbl.replace f.f_node target.id r; + if overwrite || releases then begin + (* the replaced value dies here (the owner table's OVERWRITE or + RELEASE entry); compute the new one into a temporary first so + destroying the old one cannot destroy what is about to be + stored *) + let t = alloc_temp p f value.pos in + emit_expr p f v ~dst:t ~expected:ty value; + f.f_cur_line <- s.s_pos.line; + if overwrite then begin + put f (ins_abc op_drop r 0 0); + mask_clear f r + end; + if releases then begin + Hashtbl.replace f.f_node s.s_id r; + emit_rc p f v ~node:s.s_id ~acquire:false () + end; + put f (ins_abc op_move r t 0) + end + else emit_expr p f v ~dst:r ~expected:ty value; + (* a whole-local target cannot be an unprovable alias of anything + (relate answers Overlap or Disjoint for a place with no + projections), so this normally finds nothing; consumed anyway so + the end-of-unit backstop stays exact rather than special-cased *) + let guards = residual_guards p f v s.s_id None in + acquire_guards f guards; + release_guards f guards; + (match Hashtbl.find_opt v.v_move value.id with + | Some place -> ( match lookup_local f place with Some (sr, _) -> mask_clear f sr | None -> ()) + | None -> ()); + (* a whole-local assignment re-initializes it: whatever kind of + holder lived in that register holds again *) + match Hashtbl.find_opt f.f_kind r with Some kind -> mask_set f kind r | None -> ()) + | Field (base, fname) -> ( + match ty_of_expr p f base with + | Some bt -> ( + match unwrap bt with + | Scalar cn -> ( + match class_of_name p cn with + | None -> + err p ~code:cannot_lower_code ~file:f.f_file ~pos:target.pos + ~message:(Printf.sprintf "assignment into `%s`, which is not a declared class" cn) + | Some cid -> ( + match field_of p cid fname with + | None -> + err p ~code:cannot_lower_code ~file:f.f_file ~pos:target.pos + ~message:(Printf.sprintf "`%s` has no field `%s`" cn fname) + | Some (idx, fty) -> + let b = emit_operand p f v base in + Hashtbl.replace f.f_node target.id b; + let idx = check_field_idx p f target.pos idx in + if overwrite || releases then begin + (* SETF never auto-drops (format doc): the compiler emits + the destruction of the field's previous value — a DROP + for an owned field, an rc release for a @gc one *) + let old = alloc_temp p f target.pos in + f.f_cur_line <- s.s_pos.line; + put f (ins_abc op_getf old b idx); + if overwrite then put f (ins_abc op_drop old 0 0); + if releases then begin + Hashtbl.replace f.f_node s.s_id old; + emit_rc p f v ~node:s.s_id ~acquire:false () + end + end; + let t = alloc_temp p f value.pos in + emit_expr p f v ~dst:t ~expected:fty value; + f.f_cur_line <- s.s_pos.line; + (* an assignment is a region of its own: owner.ml anchors the + residual sites it produces on the *statement*, not on any + call expression. Guarded in place — no call intervenes, so + no operand register can be clobbered — and around the + mutation only, which is the narrowest correct window. *) + let guards = residual_guards p f v s.s_id None in + acquire_guards f guards; + put f (ins_abc op_setf b idx t); + release_guards f guards; + match Hashtbl.find_opt v.v_move value.id with + | Some place -> ( match lookup_local f place with Some (sr, _) -> mask_clear f sr | None -> ()) + | None -> ())) + | _ -> + err p ~code:cannot_lower_code ~file:f.f_file ~pos:target.pos + ~message:(Printf.sprintf "assignment into field `%s` of a non-class value" fname)) + | None -> + err p ~code:cannot_lower_code ~file:f.f_file ~pos:target.pos + ~message:(Printf.sprintf "cannot resolve the type of the value `%s` is written to" fname)) + | Index (base, idx) -> ( + match ty_of_expr p f base with + | Some bt -> ( + match unwrap bt with + | Map _ -> + (* `m[k] = x` is the map_set builtin: container, key, value in + three consecutive registers *) + let w = alloc_temps p f s.s_pos 3 in + emit_expr p f v ~dst:w base; + Hashtbl.replace f.f_node target.id w; + emit_expr p f v ~dst:(w + 1) idx; + emit_expr p f v ~dst:(w + 2) value; + let sink = alloc_temp p f s.s_pos in + f.f_cur_line <- s.s_pos.line; + let guards = residual_guards p f v s.s_id None in + acquire_guards f guards; + put f (ins_abc op_builtin sink w b_map_set); + release_guards f guards + | Multi _ -> + err p ~code:cannot_lower_code ~file:f.f_file ~pos:target.pos + ~message: + "element assignment into a `multi` — the v1 instruction set has push/get but no \ + element write" + | _ -> + err p ~code:cannot_lower_code ~file:f.f_file ~pos:target.pos + ~message:"element assignment into a value that is neither a `multi` nor a `map`") + | None -> + err p ~code:cannot_lower_code ~file:f.f_file ~pos:target.pos + ~message:"cannot resolve the container's type for an element assignment") + | _ -> + err p ~code:cannot_lower_code ~file:f.f_file ~pos:target.pos + ~message:"assignment target must be a local, a field, or an element" + +and emit_return (p : pctx) (f : fstate) (v : views) (s : Ast.stmt) (opt : Ast.expr option) : unit = + match opt with + | None -> + emit_rc p f v ~node:s.s_id ~acquire:true (); + (match Hashtbl.find_opt v.v_return s.s_id with Some items -> emit_drops p f items | None -> ()); + emit_rc p f v ~node:s.s_id ~acquire:false (); + f.f_cur_line <- s.s_pos.line; + put f (ins_abc op_ret0 0 0 0); + f.f_div <- true + | Some e -> + let t = emit_tail p f v e in + (match Hashtbl.find_opt v.v_move e.id with + | Some place -> ( match lookup_local f place with Some (sr, _) -> mask_clear f sr | None -> ()) + | None -> ()); + (* the escaping value's own increment was emitted where the value + landed (emit_expr / emit_tail), which is before the frame's + releases below — a balanced pair must never reach rc 0 in between *) + emit_rc p f v ~node:s.s_id ~acquire:true (); + (match Hashtbl.find_opt v.v_return s.s_id with Some items -> emit_drops p f items | None -> ()); + emit_rc p f v ~node:s.s_id ~acquire:false (); + f.f_cur_line <- s.s_pos.line; + put f (ins_abc op_ret t 0 0); + f.f_div <- true + +and emit_block (p : pctx) (f : fstate) (v : views) ~(node : int) ~(label : string) + (body : Ast.stmt list) : unit = + let saved_locals = f.f_nlocals in + let saved_env = f.f_env in + let saved_decls = f.f_declared in + List.iter (emit_stmt p f v) body; + (* scope end: the owner table's DROPs first, then the @gc releases for + the handles this block declared — owner.ml's own pop_scope order *) + emit_scope_drops p f v ~node ~label; + emit_rc p f v ~node ~acquire:false ~groups:(declared_since f saved_decls) (); + f.f_nlocals <- saved_locals; + f.f_env <- saved_env; + f.f_declared <- saved_decls; + f.f_temp <- saved_locals + +and emit_if (p : pctx) (f : fstate) (v : views) (s : Ast.stmt) (cond : Ast.expr) + (then_body : Ast.stmt list) (else_body : (Ast.pos * Ast.stmt list) option) : unit = + let t = alloc_temp p f s.s_pos in + emit_expr p f v ~dst:t cond; + f.f_cur_line <- s.s_pos.line; + let jz = here f in + put f (ins_asbx op_jz t 0); + let entry_owned = f.f_owned and entry_gc = f.f_gc in + let div0 = f.f_div in + emit_block p f v ~node:s.s_id ~label:"THEN" then_body; + emit_join_drops p f v ~node:s.s_id ~label:"THEN"; + let has_else_code = + (match else_body with Some (_, b) -> b <> [] | None -> false) + || Hashtbl.mem v.v_join (s.s_id, "ELSE") + || Hashtbl.mem v.v_scope (s.s_id, "ELSE") + in + (* the jump over the else arm still belongs to the then path, so it is + emitted before the mask goes back to the branch point *) + let jmp = if has_else_code then Some (here f) else None in + (match jmp with None -> () | Some _ -> put f (ins_asbx op_jmp 0 0)); + let then_owned = f.f_owned and then_gc = f.f_gc in + let div_then = f.f_div in + f.f_owned <- entry_owned; + f.f_gc <- entry_gc; + f.f_div <- div0; + patch_jump p f ~file:f.f_file ~pos:s.s_pos jz (here f); + (match jmp with + | None -> () + | Some jmp -> + (match else_body with + | Some (epos, b) -> + f.f_cur_line <- epos.line; + emit_block p f v ~node:s.s_id ~label:"ELSE" b + | None -> emit_scope_drops p f v ~node:s.s_id ~label:"ELSE"); + emit_join_drops p f v ~node:s.s_id ~label:"ELSE"; + patch_jump p f ~file:f.f_file ~pos:s.s_pos jmp (here f)); + let div_else = f.f_div in + if div_then && div_else then f.f_div <- true + else if div_then then f.f_div <- div0 (* the else arm's state stands *) + else begin + f.f_div <- div0; + if not div_else then mask_meet f then_owned then_gc + else begin + f.f_owned <- then_owned; + f.f_gc <- then_gc + end + end + +and emit_while (p : pctx) (f : fstate) (v : views) (s : Ast.stmt) (cond : Ast.expr) + (body : Ast.stmt list) : unit = + let top = here f in + let entry_owned = f.f_owned and entry_gc = f.f_gc in + let div0 = f.f_div in + let t = alloc_temp p f s.s_pos in + emit_expr p f v ~dst:t cond; + f.f_cur_line <- s.s_pos.line; + let jz = here f in + put f (ins_asbx op_jz t 0); + emit_block p f v ~node:s.s_id ~label:"WHILE" body; + f.f_cur_line <- s.s_pos.line; + let back = here f in + put f (ins_asbx op_jmp 0 0); + patch_jump p f ~file:f.f_file ~pos:s.s_pos back top; + patch_jump p f ~file:f.f_file ~pos:s.s_pos jz (here f); + (* a loop may run zero times, so the exit state always includes the + entry state; a body that returned contributes nothing *) + if f.f_div then begin + f.f_owned <- entry_owned; + f.f_gc <- entry_gc + end + else mask_meet f entry_owned entry_gc; + f.f_div <- div0 + +(* `for it in c` over a `multi`: the container, cursor index and length + are loop-carried, so they are locals of the loop's own scope (below + every statement temporary), and the container/index pair is adjacent + because BUILTIN's argument window is consecutive. A `map` has no + key-enumeration builtin in v1, so iterating one is WO-E403 rather + than invented bytecode. *) +and emit_for (p : pctx) (f : fstate) (v : views) (s : Ast.stmt) (var : string) (iter : Ast.expr) + (body : Ast.stmt list) : unit = + match ty_of_expr p f iter with + | Some t when (match unwrap t with Multi _ -> true | _ -> false) -> + let elem = match unwrap t with Multi e -> Scalar e | other -> other in + let saved_locals = f.f_nlocals in + let saved_env = f.f_env in + let saved_decls = f.f_declared in + let div0 = f.f_div in + let rc = alloc_local p f s.s_pos in + let ri = alloc_local p f s.s_pos in + let rn = alloc_local p f s.s_pos in + let rv = alloc_local p f s.s_pos in + f.f_temp <- f.f_nlocals; + emit_expr p f v ~dst:rc iter; + f.f_cur_line <- s.s_pos.line; + put f (ins_abc op_builtin rn rc b_count); + put f (ins_abx op_loadk ri (check_bx p f s.s_pos "constant" (const_int p 0))); + f.f_env <- (var, (rv, elem)) :: f.f_env; + Hashtbl.replace f.f_decl s.s_id rv; + f.f_declared <- s.s_id :: f.f_declared; + let top = here f in + let entry_owned = f.f_owned and entry_gc = f.f_gc in + f.f_temp <- f.f_nlocals; + let tc = alloc_temp p f s.s_pos in + put f (ins_abc op_lt tc ri rn); + let jz = here f in + put f (ins_asbx op_jz tc 0); + put f (ins_abc op_builtin rv rc b_multi_get); + List.iter (emit_stmt p f v) body; + emit_scope_drops p f v ~node:s.s_id ~label:"FOR"; + emit_rc p f v ~node:s.s_id ~acquire:false ~groups:(declared_since f saved_decls) (); + f.f_temp <- f.f_nlocals; + f.f_cur_line <- s.s_pos.line; + let one = alloc_temp p f s.s_pos in + put f (ins_abx op_loadk one (check_bx p f s.s_pos "constant" (const_int p 1))); + put f (ins_abc op_add ri ri one); + let back = here f in + put f (ins_asbx op_jmp 0 0); + patch_jump p f ~file:f.f_file ~pos:s.s_pos back top; + patch_jump p f ~file:f.f_file ~pos:s.s_pos jz (here f); + if f.f_div then begin + f.f_owned <- entry_owned; + f.f_gc <- entry_gc + end + else mask_meet f entry_owned entry_gc; + f.f_div <- div0; + f.f_nlocals <- saved_locals; + f.f_env <- saved_env; + f.f_declared <- saved_decls; + f.f_temp <- saved_locals + | _ -> + err p ~code:cannot_lower_code ~file:f.f_file ~pos:s.s_pos + ~message: + "`for` can only iterate a `multi` — the v1 builtins expose no key enumeration for a `map`" + +(* ============================================================ + One method + ============================================================ *) + +let emit_method (p : pctx) (v : views) ~(file : string) ~(self_class : (int * string) option) + (m : Ast.method_decl) (rec_ : methrec) : unit = + let f = + { f_file = file; f_fn = m.name; f_code = code_create (); f_cur_line = m.pos.line; + f_line = -1; f_lines = []; f_owned = 0L; f_gc = 0L; f_last_owned = 0L; f_last_gc = 0L; + f_drops = []; f_nlocals = 0; f_temp = 0; f_max = 0; f_env = []; f_decl = Hashtbl.create 16; + f_node = Hashtbl.create 64; f_kind = Hashtbl.create 16; f_declared = []; f_div = false; f_maxjmp = 0; + f_over = false } + in + (match self_class with + | None -> () + | Some (_, cn) -> + let r = alloc_local p f m.pos in + f.f_env <- ("self", (r, Scalar cn)) :: f.f_env; + Hashtbl.replace f.f_decl m.id r; + f.f_declared <- m.id :: f.f_declared); + List.iter + (fun (pa : Ast.param) -> + let r = alloc_local p f pa.pos in + f.f_env <- (pa.name, (r, pa.ty)) :: f.f_env; + Hashtbl.replace f.f_decl pa.id r; + f.f_declared <- pa.id :: f.f_declared; + match Hashtbl.find_opt v.v_holder pa.id with + | Some kind -> mask_set f kind r + | None -> ( + (* The one holder the tables cannot always name: a `take` + parameter holds from the first instruction, but if it is moved + on before the function's first call site no LIVE-MASK entry + ever mentions it (the owner pass records masks only at call / + DB_STUB sites) and a trap in between would leak it. The kind + comes from types.ml's own field-kind mapping, which agrees + with owner.ml's parameter rule by construction: OWNED/MULTI/ + MAP hold as owned values, GCREF as a counted handle, and + SCALAR/TEXT are copies that never drop. *) + match pa.conv with + | Borrow | Mut -> () + | Take -> ( + match field_kind p pa.ty with + | 1 | 4 | 5 -> mask_set f Owner.LOwned r + | 2 -> mask_set f Owner.LGc r + | _ -> ()))) + m.params; + List.iter (emit_stmt p f v) m.body; + stmt_reset f; + f.f_cur_line <- m.pos.line; + emit_scope_drops p f v ~node:m.id ~label:"BODY"; + emit_rc p f v ~node:m.id ~acquire:false ~groups:(declared_since f []) (); + (* the terminator rule: the loader rejects a method whose last + instruction is not one, and the implicit void return is what + control falling off the end means *) + let need_ret = + f.f_code.n = 0 + || f.f_maxjmp >= f.f_code.n + || + let last = f.f_code.a.(f.f_code.n - 1) land 0xFF in + not (last = op_ret || last = op_ret0) + in + if need_ret then put f (ins_abc op_ret0 0 0 0); + rec_.mr_regc <- max 1 (f.f_max + 1); + if rec_.mr_regc > max_regs then begin + over_budget p f m.pos; + rec_.mr_regc <- max_regs + end; + rec_.mr_code <- Array.sub f.f_code.a 0 f.f_code.n; + rec_.mr_lines <- List.rev f.f_lines; + rec_.mr_drops <- List.rev f.f_drops + +(* ============================================================ + Program assembly + ============================================================ *) + +(* Structural satisfaction, Go-style (spec section 2): a class satisfies + an interface when it has a method of the same name and parameter count + for every method the interface declares. There is no `implements` + keyword by doctrine, so this is the whole rule — and it is also why + the satisfying set is computed here: types.ml declares WO-E205 but, + per the error catalog, never raises it (the check has no legal call + site in the milestone grammar). *) +let satisfies (p : pctx) (cid : int) (ir : ifacerec) : int list option = + let cr = p.p_classes.(cid) in + let rec go acc = function + | [] -> Some (List.rev acc) + | (mname, nparams) :: tl -> ( + if not (List.mem mname cr.cr_methods) then None + else + match class_method p cr.cr_name mname with + | Some mi when List.length mi.Types.params = nparams -> ( + match SM.find_opt (cr.cr_name ^ "." ^ mname) p.p_method_id with + | Some midx -> go (midx :: acc) tl + | None -> None) + | _ -> None) + in + go [] ir.ir_methods + +let emit ~(syms : Types.symbols) (coll : Diag.Collector.t) (units : input list) : string = + (* ---- pass 1: declarations, in discovery then declaration order ---- *) + let classes = ref [] and class_id = ref SM.empty and nclasses = ref 0 in + let ifaces = ref [] and iface_id = ref SM.empty and nifaces = ref 0 and nslots = ref 0 in + let methods = ref [] and method_id = ref SM.empty and nmethods = ref 0 in + let entry = ref wob_none in + (* (file, self class option, method_decl, unit) in method-table order *) + let bodies = ref [] in + List.iter + (fun u -> + List.iter + (function + | Ast.Class (c : Ast.class_decl) -> + if not (SM.mem c.name !class_id) then begin + let cid = !nclasses in + class_id := SM.add c.name cid !class_id; + incr nclasses; + classes := + { cr_name = c.name; cr_gc = c.is_gc; + cr_fields = + Array.of_list (List.map (fun (fl : Ast.field) -> (fl.name, fl.ty)) c.fields); + cr_methods = List.map (fun (m : Ast.method_decl) -> m.name) c.methods } + :: !classes + end + | Ast.Interface (i : Ast.interface_decl) -> + (* an interface with no methods gets no slots and no rows: the + loader rejects a zero-method interface entry, and no ICALL + could ever name one *) + if i.methods <> [] && not (SM.mem i.name !iface_id) then begin + let iid = !nifaces in + iface_id := SM.add i.name iid !iface_id; + incr nifaces; + ifaces := + { ir_name = i.name; ir_slot_base = !nslots; + ir_methods = + List.map (fun (s : Ast.method_sig) -> (s.name, List.length s.params)) i.methods } + :: !ifaces; + nslots := !nslots + List.length i.methods + end + | Ast.Fn _ -> ()) + u.prog.decls) + units; + let class_id = !class_id in + let p_classes = Array.of_list (List.rev !classes) in + let p_ifaces = Array.of_list (List.rev !ifaces) in + List.iter + (fun u -> + List.iter + (function + | Ast.Class (c : Ast.class_decl) -> + let cid = SM.find c.name class_id in + List.iter + (fun (m : Ast.method_decl) -> + let key = c.name ^ "." ^ m.name in + if not (SM.mem key !method_id) then begin + method_id := SM.add key !nmethods !method_id; + methods := + { mr_name = m.name; mr_class = Some cid; mr_argc = 1 + List.length m.params; + mr_regc = 1; mr_code = [||]; mr_lines = []; mr_drops = [] } + :: !methods; + bodies := (u, Some (cid, c.name), m, !nmethods) :: !bodies; + incr nmethods + end) + c.methods + | Ast.Interface _ -> () + | Ast.Fn (m : Ast.method_decl) -> + if not (SM.mem m.name !method_id) then begin + method_id := SM.add m.name !nmethods !method_id; + methods := + { mr_name = m.name; mr_class = None; mr_argc = List.length m.params; mr_regc = 1; + mr_code = [||]; mr_lines = []; mr_drops = [] } + :: !methods; + bodies := (u, None, m, !nmethods) :: !bodies; + (* the entry point is the zero-argument free fn `main` — + the loader's own rule for an entry (a zero-arg free fn) + plus one fixed name so `wovm image.wob` needs no flag *) + if m.name = "main" && m.params = [] then begin + entry := !nmethods; + (match m.ret with + | Some ty when ty <> Ast.Scalar "Int" -> + Diag.Collector.add coll + (Diag.error ~code:entry_return_code ~file:u.file ~line:m.pos.line + ~col:m.pos.col + ~message: + (Printf.sprintf + "entry `main` declares return type `%s` — the entry's return \ + value is the process exit code, so it must return `Int`" + (Dump.field_ty_str ty)) + ()) + | Some _ | None -> ()) + end; + incr nmethods + end) + u.prog.decls) + units; + let p_methods = Array.of_list (List.rev !methods) in + let p = + { p_syms = syms; p_coll = coll; p_classes; p_class_id = class_id; p_ifaces; + p_iface_id = !iface_id; p_method_id = !method_id; p_methods; p_kints = Hashtbl.create 32; + p_ktexts = Hashtbl.create 32; p_consts = []; p_nconsts = 0 } + in + (* names are constants; interning them first keeps the pool's low + indexes stable and readable in a disassembly *) + let class_name_k = Array.map (fun c -> const_text p c.cr_name) p_classes in + let iface_name_k = Array.map (fun i -> const_text p i.ir_name) p_ifaces in + let method_name_k = Array.map (fun m -> const_text p m.mr_name) p_methods in + (* ---- pass 2: method bodies ---- *) + let views_of = Hashtbl.create 8 in + List.iter (fun u -> Hashtbl.replace views_of u.file (build_views u.tables)) units; + List.iter + (fun (u, self_class, m, idx) -> + let v = Hashtbl.find views_of u.file in + emit_method p v ~file:u.file ~self_class m p_methods.(idx)) + (List.rev !bodies); + (* The residual table is the ONLY licence to emit a borrow op, and it + is also an obligation: every region in it must end up wrapped. The + regions are anchored on several different node kinds (a call + expression, an assignment statement, a moved place), so a lowering + that forgets one would ship the aliasing check silently disabled — + the exact hazard the site exists for. Anything unconsumed is + WO-E404 here, reported at the region's own position. *) + List.iter + (fun u -> + let v = Hashtbl.find views_of u.file in + let leftover = + Hashtbl.fold + (fun node (pos, _) acc -> + if Hashtbl.mem v.v_res_used node then acc else (pos, node) :: acc) + v.v_res [] + in + List.iter + (fun ((pos : Ast.pos), _) -> + err p ~code:unguardable_code ~file:u.file ~pos + ~message: + "residual borrow site was never wrapped in runtime guards — the emitter has no \ + lowering for this region, and leaving it unguarded would disable the aliasing \ + check it exists for") + (List.sort (fun ((a : Ast.pos), _) ((b : Ast.pos), _) -> + compare (a.line, a.col) (b.line, b.col)) + leftover)) + units; + (* ---- pass 3: vtable rows ---- *) + let rows = ref [] in + Array.iteri + (fun cid _ -> + Array.iteri + (fun iid ir -> + match satisfies p cid ir with + | Some ms -> rows := (cid, iid, ms) :: !rows + | None -> ()) + p_ifaces) + p_classes; + let rows = List.rev !rows in + (* ---- pass 4: serialize ---- *) + let consts = Buf.create () in + List.iter + (fun c -> + match c with + | `Int n -> + Buf.u8 consts k_int; + Buf.i64 consts (Int64.of_int n) + | `Text s -> + Buf.u8 consts k_text; + Buf.u32 consts (String.length s); + Buf.str consts s) + (List.rev p.p_consts); + let cls = Buf.create () in + Array.iteri + (fun cid (c : clsrec) -> + Buf.u32 cls class_name_k.(cid); + Buf.u32 cls (if c.cr_gc then classf_gc else 0); + Buf.u32 cls (Array.length c.cr_fields); + Array.iter (fun (_, ty) -> Buf.u8 cls (field_kind p ty)) c.cr_fields; + let pad = (4 - (Array.length c.cr_fields mod 4)) mod 4 in + for _ = 1 to pad do + Buf.u8 cls 0 + done) + p_classes; + let ifs = Buf.create () in + Array.iteri + (fun iid (i : ifacerec) -> + Buf.u32 ifs iface_name_k.(iid); + Buf.u32 ifs (List.length i.ir_methods)) + p_ifaces; + Buf.u32 ifs (List.length rows); + List.iter + (fun (cid, iid, ms) -> + Buf.u32 ifs cid; + Buf.u32 ifs iid; + List.iter (fun m -> Buf.u32 ifs m) ms) + rows; + let mth = Buf.create () in + Array.iteri + (fun idx (m : methrec) -> + Buf.u32 mth method_name_k.(idx); + Buf.u32 mth (match m.mr_class with Some c -> c | None -> wob_none); + Buf.u8 mth m.mr_argc; + Buf.u8 mth m.mr_regc; + Buf.u16 mth 0; + Buf.u32 mth (Array.length m.mr_code * 4); + Array.iter (fun i -> Buf.u32 mth i) m.mr_code; + Buf.u32 mth (List.length m.mr_lines); + List.iter + (fun (pc, line) -> + Buf.u32 mth pc; + Buf.u32 mth line) + m.mr_lines; + Buf.u32 mth (List.length m.mr_drops); + List.iter + (fun (pc, owned, gc) -> + Buf.u32 mth pc; + Buf.i64 mth owned; + Buf.i64 mth gc) + m.mr_drops) + p_methods; + let out = Buf.create () in + let off = ref wob_hdr_size in + Buf.u32 out wob_magic; + Buf.u32 out wob_version; + Buf.u32 out !off; + Buf.u32 out p.p_nconsts; + off := !off + consts.Buf.len; + Buf.u32 out !off; + Buf.u32 out (Array.length p_classes); + off := !off + cls.Buf.len; + Buf.u32 out !off; + Buf.u32 out (Array.length p_ifaces); + off := !off + ifs.Buf.len; + Buf.u32 out !off; + Buf.u32 out (Array.length p_methods); + Buf.u32 out !entry; + Buf.str out (Buf.contents consts); + Buf.str out (Buf.contents cls); + Buf.str out (Buf.contents ifs); + Buf.str out (Buf.contents mth); + Buf.contents out diff --git a/compiler/src/owner.ml b/compiler/src/owner.ml index beaffc4..6ab3c3d 100644 --- a/compiler/src/owner.ml +++ b/compiler/src/owner.ml @@ -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)) diff --git a/compiler/src/types.ml b/compiler/src/types.ml index 50b1f9a..8099705 100644 --- a/compiler/src/types.ml +++ b/compiler/src/types.ml @@ -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 diff --git a/compiler/test/golden/bc/arith.expected b/compiler/test/golden/bc/arith.expected new file mode 100644 index 0000000..355d01a --- /dev/null +++ b/compiler/test/golden/bc/arith.expected @@ -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 diff --git a/compiler/test/golden/bc/arith.wo b/compiler/test/golden/bc/arith.wo new file mode 100644 index 0000000..f2f3f3c --- /dev/null +++ b/compiler/test/golden/bc/arith.wo @@ -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") +} diff --git a/compiler/test/golden/bc/elision.expected b/compiler/test/golden/bc/elision.expected new file mode 100644 index 0000000..2f6464f --- /dev/null +++ b/compiler/test/golden/bc/elision.expected @@ -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 diff --git a/compiler/test/golden/bc/elision.wo b/compiler/test/golden/bc/elision.wo new file mode 100644 index 0000000..1b9ae6c --- /dev/null +++ b/compiler/test/golden/bc/elision.wo @@ -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) +} diff --git a/compiler/test/golden/bc/iface.expected b/compiler/test/golden/bc/iface.expected new file mode 100644 index 0000000..63eb825 --- /dev/null +++ b/compiler/test/golden/bc/iface.expected @@ -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 diff --git a/compiler/test/golden/bc/iface.wo b/compiler/test/golden/bc/iface.wo new file mode 100644 index 0000000..77d5b93 --- /dev/null +++ b/compiler/test/golden/bc/iface.wo @@ -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()) +} diff --git a/compiler/test/golden/bc/owned.expected b/compiler/test/golden/bc/owned.expected new file mode 100644 index 0000000..8de7595 --- /dev/null +++ b/compiler/test/golden/bc/owned.expected @@ -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 diff --git a/compiler/test/golden/bc/owned.wo b/compiler/test/golden/bc/owned.wo new file mode 100644 index 0000000..9d98e08 --- /dev/null +++ b/compiler/test/golden/bc/owned.wo @@ -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)) +} diff --git a/compiler/test/golden/bc/residual.expected b/compiler/test/golden/bc/residual.expected new file mode 100644 index 0000000..599700e --- /dev/null +++ b/compiler/test/golden/bc/residual.expected @@ -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 diff --git a/compiler/test/golden/bc/residual.wo b/compiler/test/golden/bc/residual.wo new file mode 100644 index 0000000..0f555e1 --- /dev/null +++ b/compiler/test/golden/bc/residual.wo @@ -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)) +} diff --git a/compiler/test/runner.ml b/compiler/test/runner.ml index fa208fa..2b203ef 100644 --- a/compiler/test/runner.ml +++ b/compiler/test/runner.ml @@ -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\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) diff --git a/docs/00-status.md b/docs/00-status.md index 48ddb2b..b1787a0 100644 --- a/docs/00-status.md +++ b/docs/00-status.md @@ -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 | diff --git a/docs/plan/oop-vm/00-wob-format.md b/docs/plan/oop-vm/00-wob-format.md index 5103c95..0dada6b 100644 --- a/docs/plan/oop-vm/00-wob-format.md +++ b/docs/plan/oop-vm/00-wob-format.md @@ -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 -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 ` 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 (`.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. diff --git a/docs/plan/oop-vm/01-error-catalog.md b/docs/plan/oop-vm/01-error-catalog.md index 62ac4b8..d9a623f 100644 --- a/docs/plan/oop-vm/01-error-catalog.md +++ b/docs/plan/oop-vm/01-error-catalog.md @@ -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 diff --git a/docs/plan/oop-vm/02-corpus.md b/docs/plan/oop-vm/02-corpus.md new file mode 100644 index 0000000..f025319 --- /dev/null +++ b/docs/plan/oop-vm/02-corpus.md @@ -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//fixture.wo +tests/corpus/run//fixture.out + +tests/corpus/compile-fail//fixture.wo +tests/corpus/compile-fail//fixture.code + +tests/corpus/trap//fixture.wo +tests/corpus/trap//fixture.trap + +tests/corpus/gc//fixture.wo +tests/corpus/gc//fixture.out +tests/corpus/gc//fixture.trace +tests/corpus/gc//fixture.gc_budget -- optional +``` + +`scripts/oop-e2e.sh` globs `tests/corpus//*/`, 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 .wob` must exit 0, then +`wovm .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//fixture.wo -o /tmp/f.wob +runtime/wovm /tmp/f.wob > tests/corpus/run//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 .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 ` 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 .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 .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). diff --git a/docs/plan/oop-vm/08-builtin-surface.md b/docs/plan/oop-vm/08-builtin-surface.md new file mode 100644 index 0000000..e7de7dc --- /dev/null +++ b/docs/plan/oop-vm/08-builtin-surface.md @@ -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` — 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` 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 +} + +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. diff --git a/docs/plan/oop-vm/README.md b/docs/plan/oop-vm/README.md index bda47a9..a9fa364 100644 --- a/docs/plan/oop-vm/README.md +++ b/docs/plan/oop-vm/README.md @@ -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`. diff --git a/docs/stories/language-runtime-database/00-story.md b/docs/stories/language-runtime-database/00-story.md index 41b43d3..d7667b9 100644 --- a/docs/stories/language-runtime-database/00-story.md +++ b/docs/stories/language-runtime-database/00-story.md @@ -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, … — diff --git a/docs/stories/language-runtime-database/07b-inferred-gc-mark-sweep.md b/docs/stories/language-runtime-database/07b-inferred-gc-mark-sweep.md new file mode 100644 index 0000000..17ea718 --- /dev/null +++ b/docs/stories/language-runtime-database/07b-inferred-gc-mark-sweep.md @@ -0,0 +1,108 @@ +# Iteration 7b — inferred GC + incremental mark-sweep + +> Format: fiberloom `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 }`, 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. diff --git a/docs/stories/language-runtime-database/09b-table-relations-query.md b/docs/stories/language-runtime-database/09b-table-relations-query.md new file mode 100644 index 0000000..8c71914 --- /dev/null +++ b/docs/stories/language-runtime-database/09b-table-relations-query.md @@ -0,0 +1,132 @@ +# Iteration 9b — `@table`, relations, and language-integrated query + +> Format: fiberloom `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. diff --git a/docs/superpowers/specs/2026-08-01-oop-compiler-vm-design.md b/docs/superpowers/specs/2026-08-01-oop-compiler-vm-design.md index 013b6ac..c5d375d 100644 --- a/docs/superpowers/specs/2026-08-01-oop-compiler-vm-design.md +++ b/docs/superpowers/specs/2026-08-01-oop-compiler-vm-design.md @@ -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. diff --git a/docs/superpowers/specs/2026-08-10-logwatcher-gap-closure-design.md b/docs/superpowers/specs/2026-08-10-logwatcher-gap-closure-design.md index 0e84682..4c8917c 100644 --- a/docs/superpowers/specs/2026-08-10-logwatcher-gap-closure-design.md +++ b/docs/superpowers/specs/2026-08-10-logwatcher-gap-closure-design.md @@ -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. diff --git a/docs/superpowers/specs/2026-08-11-inferred-gc-mark-sweep-design.md b/docs/superpowers/specs/2026-08-11-inferred-gc-mark-sweep-design.md new file mode 100644 index 0000000..215901b --- /dev/null +++ b/docs/superpowers/specs/2026-08-11-inferred-gc-mark-sweep-design.md @@ -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 }`, 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`, `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. diff --git a/justfile b/justfile index cbf8374..2327766 100644 --- a/justfile +++ b/justfile @@ -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 diff --git a/runtime/Makefile b/runtime/Makefile index eca9d10..1535fbb 100644 --- a/runtime/Makefile +++ b/runtime/Makefile @@ -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 diff --git a/runtime/README.md b/runtime/README.md index b5b5bd2..503dcf1 100644 --- a/runtime/README.md +++ b/runtime/README.md @@ -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 diff --git a/runtime/src/main.c b/runtime/src/main.c index 694780b..f1daa51 100644 --- a/runtime/src/main.c +++ b/runtime/src/main.c @@ -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 #include #include +#include +#include +#include +#include +#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 \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 \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; diff --git a/scripts/oop-e2e.sh b/scripts/oop-e2e.sh new file mode 100755 index 0000000..8813270 --- /dev/null +++ b/scripts/oop-e2e.sh @@ -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 ]] diff --git a/scripts/single-binary-smoke.sh b/scripts/single-binary-smoke.sh new file mode 100755 index 0000000..5a4eb33 --- /dev/null +++ b/scripts/single-binary-smoke.sh @@ -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 ]] diff --git a/tests/corpus/README.md b/tests/corpus/README.md new file mode 100644 index 0000000..ddfd9fa --- /dev/null +++ b/tests/corpus/README.md @@ -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`). diff --git a/tests/corpus/actor/.gitkeep b/tests/corpus/actor/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/corpus/compile-fail/.gitkeep b/tests/corpus/compile-fail/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/corpus/compile-fail/borrow-escape-return/fixture.code b/tests/corpus/compile-fail/borrow-escape-return/fixture.code new file mode 100644 index 0000000..06edba4 --- /dev/null +++ b/tests/corpus/compile-fail/borrow-escape-return/fixture.code @@ -0,0 +1 @@ +WO-E304 diff --git a/tests/corpus/compile-fail/borrow-escape-return/fixture.wo b/tests/corpus/compile-fail/borrow-escape-return/fixture.wo new file mode 100644 index 0000000..639dc6e --- /dev/null +++ b/tests/corpus/compile-fail/borrow-escape-return/fixture.wo @@ -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) +} diff --git a/tests/corpus/compile-fail/double-mut-alias/fixture.code b/tests/corpus/compile-fail/double-mut-alias/fixture.code new file mode 100644 index 0000000..76b9e4a --- /dev/null +++ b/tests/corpus/compile-fail/double-mut-alias/fixture.code @@ -0,0 +1 @@ +WO-E303 diff --git a/tests/corpus/compile-fail/double-mut-alias/fixture.wo b/tests/corpus/compile-fail/double-mut-alias/fixture.wo new file mode 100644 index 0000000..233e405 --- /dev/null +++ b/tests/corpus/compile-fail/double-mut-alias/fixture.wo @@ -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])) +} diff --git a/tests/corpus/compile-fail/duplicate-class-same-file/fixture.code b/tests/corpus/compile-fail/duplicate-class-same-file/fixture.code new file mode 100644 index 0000000..9c32ee4 --- /dev/null +++ b/tests/corpus/compile-fail/duplicate-class-same-file/fixture.code @@ -0,0 +1 @@ +WO-E215 diff --git a/tests/corpus/compile-fail/duplicate-class-same-file/fixture.wo b/tests/corpus/compile-fail/duplicate-class-same-file/fixture.wo new file mode 100644 index 0000000..2383d59 --- /dev/null +++ b/tests/corpus/compile-fail/duplicate-class-same-file/fixture.wo @@ -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() { +} diff --git a/tests/corpus/compile-fail/incomplete-constructor/fixture.code b/tests/corpus/compile-fail/incomplete-constructor/fixture.code new file mode 100644 index 0000000..a8dab1a --- /dev/null +++ b/tests/corpus/compile-fail/incomplete-constructor/fixture.code @@ -0,0 +1 @@ +WO-E206 diff --git a/tests/corpus/compile-fail/incomplete-constructor/fixture.wo b/tests/corpus/compile-fail/incomplete-constructor/fixture.wo new file mode 100644 index 0000000..3b32ab2 --- /dev/null +++ b/tests/corpus/compile-fail/incomplete-constructor/fixture.wo @@ -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) +} diff --git a/tests/corpus/compile-fail/move-after-use/fixture.code b/tests/corpus/compile-fail/move-after-use/fixture.code new file mode 100644 index 0000000..189d29f --- /dev/null +++ b/tests/corpus/compile-fail/move-after-use/fixture.code @@ -0,0 +1 @@ +WO-E301 diff --git a/tests/corpus/compile-fail/move-after-use/fixture.wo b/tests/corpus/compile-fail/move-after-use/fixture.wo new file mode 100644 index 0000000..889d1a4 --- /dev/null +++ b/tests/corpus/compile-fail/move-after-use/fixture.wo @@ -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 })) +} diff --git a/tests/corpus/compile-fail/move-while-borrowed/fixture.code b/tests/corpus/compile-fail/move-while-borrowed/fixture.code new file mode 100644 index 0000000..a5032c5 --- /dev/null +++ b/tests/corpus/compile-fail/move-while-borrowed/fixture.code @@ -0,0 +1 @@ +WO-E302 diff --git a/tests/corpus/compile-fail/move-while-borrowed/fixture.wo b/tests/corpus/compile-fail/move-while-borrowed/fixture.wo new file mode 100644 index 0000000..bc13b16 --- /dev/null +++ b/tests/corpus/compile-fail/move-while-borrowed/fixture.wo @@ -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 } })) +} diff --git a/tests/corpus/compile-fail/unknown-field/fixture.code b/tests/corpus/compile-fail/unknown-field/fixture.code new file mode 100644 index 0000000..ad9c07e --- /dev/null +++ b/tests/corpus/compile-fail/unknown-field/fixture.code @@ -0,0 +1 @@ +WO-E202 diff --git a/tests/corpus/compile-fail/unknown-field/fixture.wo b/tests/corpus/compile-fail/unknown-field/fixture.wo new file mode 100644 index 0000000..9f14a64 --- /dev/null +++ b/tests/corpus/compile-fail/unknown-field/fixture.wo @@ -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) +} diff --git a/tests/corpus/db/.gitkeep b/tests/corpus/db/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/corpus/gc/.gitkeep b/tests/corpus/gc/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/corpus/gc/abandoned-cycle/fixture.out b/tests/corpus/gc/abandoned-cycle/fixture.out new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/tests/corpus/gc/abandoned-cycle/fixture.out @@ -0,0 +1 @@ +1 diff --git a/tests/corpus/gc/abandoned-cycle/fixture.trace b/tests/corpus/gc/abandoned-cycle/fixture.trace new file mode 100644 index 0000000..d47affc --- /dev/null +++ b/tests/corpus/gc/abandoned-cycle/fixture.trace @@ -0,0 +1,2 @@ +steps=1 +freed=2 diff --git a/tests/corpus/gc/abandoned-cycle/fixture.wo b/tests/corpus/gc/abandoned-cycle/fixture.wo new file mode 100644 index 0000000..757874c --- /dev/null +++ b/tests/corpus/gc/abandoned-cycle/fixture.wo @@ -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)) +} diff --git a/tests/corpus/gc/budget-steps/fixture.gc_budget b/tests/corpus/gc/budget-steps/fixture.gc_budget new file mode 100644 index 0000000..0cfbf08 --- /dev/null +++ b/tests/corpus/gc/budget-steps/fixture.gc_budget @@ -0,0 +1 @@ +2 diff --git a/tests/corpus/gc/budget-steps/fixture.out b/tests/corpus/gc/budget-steps/fixture.out new file mode 100644 index 0000000..0cfbf08 --- /dev/null +++ b/tests/corpus/gc/budget-steps/fixture.out @@ -0,0 +1 @@ +2 diff --git a/tests/corpus/gc/budget-steps/fixture.trace b/tests/corpus/gc/budget-steps/fixture.trace new file mode 100644 index 0000000..70090f5 --- /dev/null +++ b/tests/corpus/gc/budget-steps/fixture.trace @@ -0,0 +1,2 @@ +steps=2 +freed=4 diff --git a/tests/corpus/gc/budget-steps/fixture.wo b/tests/corpus/gc/budget-steps/fixture.wo new file mode 100644 index 0000000..b7cbe4a --- /dev/null +++ b/tests/corpus/gc/budget-steps/fixture.wo @@ -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)) +} diff --git a/tests/corpus/lang/.gitkeep b/tests/corpus/lang/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/corpus/run/.gitkeep b/tests/corpus/run/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/corpus/run/arithmetic/fixture.out b/tests/corpus/run/arithmetic/fixture.out new file mode 100644 index 0000000..ea981bf --- /dev/null +++ b/tests/corpus/run/arithmetic/fixture.out @@ -0,0 +1,2 @@ +14 +done diff --git a/tests/corpus/run/arithmetic/fixture.wo b/tests/corpus/run/arithmetic/fixture.wo new file mode 100644 index 0000000..e88049c --- /dev/null +++ b/tests/corpus/run/arithmetic/fixture.wo @@ -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") +} diff --git a/tests/corpus/run/hello/fixture.out b/tests/corpus/run/hello/fixture.out new file mode 100644 index 0000000..80aef93 --- /dev/null +++ b/tests/corpus/run/hello/fixture.out @@ -0,0 +1,2 @@ +hello +42 diff --git a/tests/corpus/run/hello/fixture.wo b/tests/corpus/run/hello/fixture.wo new file mode 100644 index 0000000..2fb88fc --- /dev/null +++ b/tests/corpus/run/hello/fixture.wo @@ -0,0 +1,5 @@ +-- Seed fixture: print/print_int, the smallest possible run/ case. +fn main() { + print("hello") + print_int(42) +} diff --git a/tests/corpus/run/interface/fixture.out b/tests/corpus/run/interface/fixture.out new file mode 100644 index 0000000..908cea3 --- /dev/null +++ b/tests/corpus/run/interface/fixture.out @@ -0,0 +1,2 @@ +12 +15 diff --git a/tests/corpus/run/interface/fixture.wo b/tests/corpus/run/interface/fixture.wo new file mode 100644 index 0000000..1ec5043 --- /dev/null +++ b/tests/corpus/run/interface/fixture.wo @@ -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)) +} diff --git a/tests/corpus/run/methods/fixture.out b/tests/corpus/run/methods/fixture.out new file mode 100644 index 0000000..3e441ba --- /dev/null +++ b/tests/corpus/run/methods/fixture.out @@ -0,0 +1,2 @@ +15 +42 diff --git a/tests/corpus/run/methods/fixture.wo b/tests/corpus/run/methods/fixture.wo new file mode 100644 index 0000000..fa42028 --- /dev/null +++ b/tests/corpus/run/methods/fixture.wo @@ -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)) +} diff --git a/tests/corpus/run/pricing-containers/fixture.out b/tests/corpus/run/pricing-containers/fixture.out new file mode 100644 index 0000000..8df8a02 --- /dev/null +++ b/tests/corpus/run/pricing-containers/fixture.out @@ -0,0 +1,6 @@ +3 +20 +2 +499 +1 +0 diff --git a/tests/corpus/run/pricing-containers/fixture.wo b/tests/corpus/run/pricing-containers/fixture.wo new file mode 100644 index 0000000..a8d32f3 --- /dev/null +++ b/tests/corpus/run/pricing-containers/fixture.wo @@ -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 +} + +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")) +} diff --git a/tests/corpus/run/pricing-current-price/fixture.out b/tests/corpus/run/pricing-current-price/fixture.out new file mode 100644 index 0000000..fa8f08c --- /dev/null +++ b/tests/corpus/run/pricing-current-price/fixture.out @@ -0,0 +1 @@ +150 diff --git a/tests/corpus/run/pricing-current-price/fixture.wo b/tests/corpus/run/pricing-current-price/fixture.wo new file mode 100644 index 0000000..1a9558e --- /dev/null +++ b/tests/corpus/run/pricing-current-price/fixture.wo @@ -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()) +} diff --git a/tests/corpus/run/pricing-discounted/fixture.out b/tests/corpus/run/pricing-discounted/fixture.out new file mode 100644 index 0000000..79ceb61 --- /dev/null +++ b/tests/corpus/run/pricing-discounted/fixture.out @@ -0,0 +1,2 @@ +150 +200 diff --git a/tests/corpus/run/pricing-discounted/fixture.wo b/tests/corpus/run/pricing-discounted/fixture.wo new file mode 100644 index 0000000..31a64bc --- /dev/null +++ b/tests/corpus/run/pricing-discounted/fixture.wo @@ -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)) +} diff --git a/tests/corpus/run/pricing-text/fixture.out b/tests/corpus/run/pricing-text/fixture.out new file mode 100644 index 0000000..636c2a6 --- /dev/null +++ b/tests/corpus/run/pricing-text/fixture.out @@ -0,0 +1,2 @@ +writeonce ceramic mug +3 diff --git a/tests/corpus/run/pricing-text/fixture.wo b/tests/corpus/run/pricing-text/fixture.wo new file mode 100644 index 0000000..ce7d365 --- /dev/null +++ b/tests/corpus/run/pricing-text/fixture.wo @@ -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)) +} diff --git a/tests/corpus/sample-logwatcher/.gitkeep b/tests/corpus/sample-logwatcher/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/corpus/sys/.gitkeep b/tests/corpus/sys/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/corpus/trap/.gitkeep b/tests/corpus/trap/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/corpus/trap/div-by-zero/fixture.trap b/tests/corpus/trap/div-by-zero/fixture.trap new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/tests/corpus/trap/div-by-zero/fixture.trap @@ -0,0 +1 @@ +1 diff --git a/tests/corpus/trap/div-by-zero/fixture.wo b/tests/corpus/trap/div-by-zero/fixture.wo new file mode 100644 index 0000000..1543f44 --- /dev/null +++ b/tests/corpus/trap/div-by-zero/fixture.wo @@ -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)) +} diff --git a/tests/corpus/trap/exclusive-borrow-alias/fixture.trap b/tests/corpus/trap/exclusive-borrow-alias/fixture.trap new file mode 100644 index 0000000..0cfbf08 --- /dev/null +++ b/tests/corpus/trap/exclusive-borrow-alias/fixture.trap @@ -0,0 +1 @@ +2 diff --git a/tests/corpus/trap/exclusive-borrow-alias/fixture.wo b/tests/corpus/trap/exclusive-borrow-alias/fixture.wo new file mode 100644 index 0000000..ea40ca4 --- /dev/null +++ b/tests/corpus/trap/exclusive-borrow-alias/fixture.wo @@ -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)) +} diff --git a/tests/corpus/trap/missing-map-key/fixture.trap b/tests/corpus/trap/missing-map-key/fixture.trap new file mode 100644 index 0000000..7f8f011 --- /dev/null +++ b/tests/corpus/trap/missing-map-key/fixture.trap @@ -0,0 +1 @@ +7 diff --git a/tests/corpus/trap/missing-map-key/fixture.wo b/tests/corpus/trap/missing-map-key/fixture.wo new file mode 100644 index 0000000..8450259 --- /dev/null +++ b/tests/corpus/trap/missing-map-key/fixture.wo @@ -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 +} + +fn main() { + let c = Catalog { prices: map_new() } + set(c.prices, "mug", 499) + print_int(c.prices["shirt"]) +} diff --git a/tests/corpus/trap/pricing-set-price-db-stub/fixture.trap b/tests/corpus/trap/pricing-set-price-db-stub/fixture.trap new file mode 100644 index 0000000..7ed6ff8 --- /dev/null +++ b/tests/corpus/trap/pricing-set-price-db-stub/fixture.trap @@ -0,0 +1 @@ +5 diff --git a/tests/corpus/trap/pricing-set-price-db-stub/fixture.wo b/tests/corpus/trap/pricing-set-price-db-stub/fixture.wo new file mode 100644 index 0000000..094770e --- /dev/null +++ b/tests/corpus/trap/pricing-set-price-db-stub/fixture.wo @@ -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) +} diff --git a/tests/corpus/trap/unsatisfied-interface/fixture.trap b/tests/corpus/trap/unsatisfied-interface/fixture.trap new file mode 100644 index 0000000..1e8b314 --- /dev/null +++ b/tests/corpus/trap/unsatisfied-interface/fixture.trap @@ -0,0 +1 @@ +6 diff --git a/tests/corpus/trap/unsatisfied-interface/fixture.wo b/tests/corpus/trap/unsatisfied-interface/fixture.wo new file mode 100644 index 0000000..96b9e5a --- /dev/null +++ b/tests/corpus/trap/unsatisfied-interface/fixture.wo @@ -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)) +}