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

6.2 KiB

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) 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.

Anything on this page is a contract for corpus fixtures and for every later sub-project's .wo code — not an emitter implementation detail.

Builtins

Containers and runtime services are free functions, never methods. Each maps to one BUILTIN id of the format doc.

source .wob builtin arity meaning
now() now 0 wall-clock milliseconds (Int)
print(t) print 1 a Text, newline-terminated
print_int(n) print_int 1 an Int, newline-terminated
words(t) words 1 whitespace token count of a Text
multi_new() multi_new 0 a fresh multi T — see the destination rule below
map_new() map_new 0 a fresh map<K, V> — see the destination rule below
push(m, v) multi_push 2 append to a multi
count(c) count 1 length of a multi or a map
latest(m) latest 1 last element of a multi (traps BOUNDS when empty)
get(c, k) multi_get / map_get 2 element by index, or value by key (a missing key traps KEY)
set(m, k, v) map_set 3 insert or replace in a map
has(m, k) map_has 2 1/0

get, set, push, count and has resolve on the container they are given, so one source name covers the multi and map ids the runtime keeps apart.

Sugar. c[i] is exactly get(c, i) and m[k] = v is exactly set(m, k, v). There is no element write into a multi — v1 has multi_push and multi_get and no element store — so m[i] = v on a multi is WO-E403.

Shadowing. A user-declared free fn of the same name always wins. A declared name is never silently replaced by a builtin.

push and @gc elements. push(m, v)'s value argument is never a resolved callee parameter (push has no declared signature), so the owner pass's ordinary Take-gated transfer never reaches it; a @gc value pushed into a multi is special-cased in owner.ml's analyze_call (the value escapes into the container exactly like a ctor field, RC_INC included) specifically so a multi-mediated @gc cycle can be built and later collected (tests/corpus/gc/, plan 3 task 5). set(m, k, v) has no equivalent special case — a @gc key or value handed to set is not retained, so a map<_, SomeGcClass> (or a @gc-keyed map) built this way will under-count its element's refcount and the collector will free it while the map still points at it. Nothing in the corpus exercises this yet; treat it as an open gap, not a proven-safe pattern, until set gets the same fix push did.

A fresh container needs a destination of declared type

multi_new() and map_new() carry their element (and key/value) kinds as an instruction immediate, and those kinds are the container's drop plan at runtime (runtime/src/gc.c). They cannot be guessed: assuming SCALAR for a multi Item leaks every element, and for a map<Text, _> leaks every key. Milestone-1 let has no container type annotation — its optional annotation is a bare identifier — so a fresh container must be created where its type is declared:

class Store {
  items:   multi Item
  by_name: map<Text, Int>
}

fn main() {
  let s = Store { items: multi_new(), by_name: map_new() }   -- kinds from the fields
  push(s.items, Item { n: 7 })
  set(s.by_name, "seven", 7)
}

A bare let m = map_new() is WO-E403, reported at the creation site. The same rule applies to a take/mut parameter of declared container type, which is also a typed destination.

Calls

  • Argument count must match the callee's parameter count (WO-E403). Nothing upstream checks arity — types.ml declares WO-E203 and never raises it — and a mismatched call reserves a register window the callee does not read, which the loader rejects outright.
  • A method is called as receiver.method(args). When the receiver's declared type is an interface, the call is dispatched by vtable (ICALL); satisfaction is structural (same method name, same parameter count), Go-style, with no implements keyword.
  • self occupies the callee's r0, so a method's argument count is 1 + parameters.

Program entry

The entry point is the zero-argument free fn main. A main that takes parameters is not an entry (the format's own rule is a zero-argument free fn), and wovm will report module has no entry method.

Operators with no dedicated opcode

Lowered by the emitter, not added to the format:

source lowering
a % b a - (a / b) * b — exact for the VM's truncating DIV, which traps on 0 and on INT64_MIN / -1, both correct for % too
a != b (a == b) == 0
a > b, a >= b LT / LE with the operands swapped
a == b on Text EQS (content equality); EQ otherwise
a .. b CONCAT — + is arithmetic only, never string addition

Not lowerable in milestone 1

Each is WO-E403 at the offending site, never invented bytecode:

  • an element write into a multi (no element-store instruction);
  • for over a map (v1 exposes no key enumeration);
  • a name that is neither a local, a parameter, self, a declared fn, nor a builtin;
  • a field or method on a type that is not a declared class — including a class named only inside multi T / ref T, which types.ml's unknown-type check (WO-E225) does not look inside.

?T

A nullable field stores exactly what T stores and spells nil as 0. The v1 format has no kind byte for it (field kinds run 0..5; the loader rejects 6), and it needs none: every per-kind drop plan already ignores a zero slot. ?T's field kind is therefore T's. Note the consequence for @gc: ?SomeGcClass is a GCREF field like any other, so it participates in refcounting and cycle detection normally.