- `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.
6.2 KiB
Milestone-1 source surface the emitter lowers — normative reference
What a
.woprogram may say and havewocproduce bytecode for. The.wobformat 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 in01-error-catalog.md.Anything on this page is a contract for corpus fixtures and for every later sub-project's
.wocode — 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.mldeclaresWO-E203and 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 noimplementskeyword. selfoccupies the callee'sr0, so a method's argument count is1 + 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); forover amap(v1 exposes no key enumeration);- a name that is neither a local, a parameter,
self, a declaredfn, 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, whichtypes.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.