writeonce/docs/plan/oop-vm/01-error-catalog.md
shoney.arickathil 0df9b4fe31 feat(compiler): iteration 5 Tasks 1-4 — modules, language surface, switch, typedef records + enum variants
- Modules: `use`/`pub`, directory-as-module, per-module symbol resolution (a
  flat first-wins merge silently ran the wrong `pub fn` body), six reserved
  stdlib namespaces typed UNKNOWN-BUT-RESERVED.
- Surface: `and`/`or` (own precedence tier, short-circuit, Bool-only), `${}`
  interpolation desugared at parse time, `const`, break/continue with
  drop-correct exits, do-while, inline-fn rejection.
- switch expr/stmt: required `default` over scalars/Text, arm unification,
  EQ/EQS+JZ lowering, per-arm drop scopes with N-way JOIN-DROP; `default`
  sorted last by a shared lowering order (textual order made arms dead).
- typedef records: structural, same shape = one class entry; `?name: T`
  nullable-by-shape; emit_ctor fills omitted defaults; `type` as field name.
- Enum variants: all-bare unions = int ordinals; any-payload = one class
  entry per variant, tag IS the header class_id (no header field, no format
  bump); exhaustive switch without `default`; arity checked both directions.
- Payload escape modeled as move-out (pointer-kind fields only — a scalar
  escape is a copy); caller reaps owned heap temps passed by borrow: two
  unbounded LSan-blind leaks, 10.5 MB -> 1.5 MB flat over 300k iterations.
- Fixed en route, each with a RED repro: dead E209 builtin-arg check and
  `int_to_text` missing from both types.ml builtin tables (both segfaulted
  wovm), multi-file phantom double-report, emit_ctor's field temp clobbering
  dst in tail position (pre-existing), warnings swallowed without an error.
- Two fenced VM builtins: `int_to_text` (13), `variant_tag` (14).
- 14+565 unit (was 14+401), corpus 71 (was 32) plain and under wovm_asan,
  wovm-test + cli_smoke green. Log-watcher 307 -> 93 diagnostics (85 E101 /
  4 E207 / 1 E208 / 3 W202); the 5 non-E101 residuals await Task 7 grammar.
2026-08-12 14:40:07 +02:00

26 KiB
Raw Blame History

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

Every diagnostic renders as file:line:col: <severity> <code>: <message>, the source line, and a caret under the column (compiler/src/diag.ml); ownership errors (WO-E3xx) add a second, indented site for the other half of the story ("moved here" / "borrowed here" / etc.).

WO-E0xx — lexing (Task 3, compiler/src/lexer.ml)

code meaning example message
WO-E001 an input byte the lexer doesn't recognize as the start of any token. Reported once per bad byte, which is then skipped — one bad byte never stops the whole file. unknown character '$'
WO-E002 a string literal's backslash escape is the last byte of the file, with no character left to escape (a plain unterminated string with no dangling backslash is not an error — rt parity). unterminated string escape

WO-E1xx — parsing (Tasks 4–5, compiler/src/parser.ml; WO-E103 haxe-parity Task 2)

code meaning example message
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-E103 haxe-parity Task 2. inline fn ... — the haxe keyword verdict table's own reject half of the inline row (const values are the adopted half). The whole declaration is discarded by the usual top-level recovery, same as any other bad declaration. `inline fn` is rejected — optimization is the compiler's job

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; WO-E210/E216–E218/W202 haxe-parity Task 1, compiler/src/types.ml; WO-E201 haxe-parity Task 2, compiler/src/types.ml; WO-E208 haxe-parity Task 3, compiler/src/types.ml; WO-E203 haxe-parity Task 4, compiler/src/types.ml)

code meaning example message
WO-E201 haxe-parity Task 2. An and/or operand whose type is confidently known (the same narrow, "stay silent when underivable" deriver WO-E209 uses — confident_typ) and is not Bool — this language has no truthiness. Reserved since Task 6, its first real emission site. Haxe-parity Task 3 gave it two more sites: a switch expression's arms disagree on their yielded type (the switch's own type is fixed by the first arm — types.ml's "first wins" convention — every later arm is checked against it, via the regular .typ inference, not confident_typ; since haxe-parity Task 4 the comparison is structural — two typedef records with the same shape are the same type, typ_equal); and (review fix, Critical 2) a case value whose representation (WO_K_TEXT vs. WO_K_SCALAR) doesn't match the switch subject's — a real VM segfault if unchecked (a Text subject picks EQS, and EQS's str_check dereferences whatever sits in a mismatched Int case value's register), checked via confident_typ, silent when either side is unresolved. Haxe-parity Task 4 added the union-subject site: a case value over a confidently union-typed subject that does not name one of that union's variants (a misspelled variant, a variant of some other union, or a plain literal — union arms match variants, never values). Task 4's fix round 1 added three inverse/porosity sites, each a reviewer-reproduced silent-wrong-behavior hole: a variant-named case over a confidently ?Union subject (it can never match — switch does not narrow ?T; the message points at handling nil first, since forced handling is Task 6's), a variant-named case over a confidently non-union subject (switch n { case Lo: } over n: Int silently ordinal-matched), and a cross-union ==/!= (X == P from two different bare unions was silently true whenever the ordinals matched; same-union comparison stays legal). Lexical scope wins at every one of these sites — a local sharing a variant's name is never misread as one. `Wat` is not a variant of union `Kind`
WO-E203 haxe-parity Task 4 (typedef records + enum payload variants). A payload variant's argument count doesn't match its declaration, at either of the two places payload fields are positional: a construction (Failed("a", "b") against Failed(reason: Text)) or a switch pattern (case Failed(a, b):). The pattern site also rejects a non-name argument (case Failed("x"): — payload fields are bound positionally, never matched by value) and a payload-binding pattern sharing its arm with other values (case Failed(r), Pending: — the binding would be meaningless on the other match). Reserved since plan 2 Task 6; these are its first real emission sites, scoped to variant payloads only — user fn/method call arity is still the emitter's WO-E403, unchanged (see "Reserved, not yet emitted" below for the history of that gap). variant `Failed` of `Status` takes 1 payload argument(s), given 2
WO-W201 (warning) a class has recursive/shared structure (a field, directly or through ref/multi/map/?, refers back to its own class) that the ownership pass cannot prove disjoint, has no @table, and has no @unique field — suggests @gc. Node 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).
WO-E202 a .field access names a field that the base's class (a declared class — an unresolved/placeholder expression type never triggers this) doesn't have. unknown field \price` on `Product``
WO-E206 a constructor literal (ClassName { ... }) omits a field the class declares that is neither defaulted nor nullable. Haxe-parity Task 4 narrowed it from "omits any field with no default was already the rule, but defaults were unenforceable" to the real omittability rule: a field with a declared default is filled by the emitter (TailState {} — the sample's defaults-fill-in pattern), and a ?-typed field omitted is nil (the zero word NEW already leaves), for classes and typedef records alike. 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. typedef records (haxe-parity Task 4) are classes to this check — a record name resolves here like any declared class. unknown type \Widget` in constructor`
WO-E208 haxe-parity Task 3 (switch as expression); the union exhaustiveness rule is haxe-parity Task 4's. Two subject regimes: (1) a union-typed subject (derived via confident_typ, the "stay silent when underivable" deriver — an underivable subject falls to regime 2) needs no default exactly when every variant is covered by some arm; a gap fires this code and names the missing variants, in declaration order. A default always satisfies it. (2) every other subject (scalars, Text, and anything underivable) keeps Task 3's unconditional rule: no default arm is always this error. A ?Union subject is deliberately regime 2 until Task 6's forced-handling work legalizes narrowing it. switch over `Kind` has no `default` arm and does not cover: Mid, Hi
WO-E209 hotfix (2026-08-11, round 2). A builtin call (print, print_int, words, now, push, get, count, latest, set, has, multi_new, map_new) given the wrong number of arguments, or an argument whose type is confidently known and does not match the builtin's signature (source of truth: 08-builtin-surface.md). Motivated by a real segfault: print(7) compiled clean and crashed wovm — print wants a Text (a heap-string pointer), and the VM's str_check dereferences whatever register it is handed as a wo_str* with no runtime tag to check first, so a bare Int was a wild pointer read. types.ml's own confident_typ (deliberately narrower than the typechecker's regular .typ inference — see its doc comment) derives an argument's type from a literal, self, a parameter's declared type, a class field's declared type, a Ctor naming a real declared class, a let whose value was itself confidently typed, or (round 2 — round 1 missed this, a real second segfault repro: print(takesSecret(box)) where takesSecret is declared -> Int) a Call whose declared signature is known: a free fn (resolved own-module-first-then-used-modules, never the flat whole-program symbol merge — the same Critical-1 bug shape haxe-parity Task 1 already fixed for the emitter), a class method off a confidently-typed receiver, an interface method's signature, or another builtin's own confident return type (words/count → Int, now → Timestamp, has → Bool, …). ReqInt accepts any non-Text builtin scalar (Int/Bool/Timestamp/Id all share the identical WO_K_SCALAR runtime representation — found as a real false positive against print_int(has(...)) once builtin return-chasing went live). Anything still not chased (an unresolved name, an Index/Binary result, a qualified free-fn call through a use alias, an UNKNOWN-BUT-RESERVED stdlib call) is left unchecked rather than guessed at — see "Remaining unchecked surface" below. A user-declared free fn of the same name always wins over the builtin table (08-builtin-surface.md's shadowing rule; also resolved module-aware, not via the flat merge), so a shadowed name is never checked here either. Arity mismatches are also still caught later, at emission (WO-E403, unchanged) — this is an earlier, additional gate over the same contract, not a replacement. builtin `print` expects Text, got `Int`
WO-E210 haxe-parity Task 1 (modules). A Ctor/bare-call name resolves — it's declared, somewhere — but not in this file's own module and not through any use edge either; the module it actually lives in is named as a hint. Reserved by Task 6's own brief, genuinely blocked until a module concept existed at all (see the nullable-types-implementation.md handoff) — this is its first real emission site. `helper` is declared in module `shared/util`, which is not `use`d here
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, free fn, or (haxe-parity Task 4) union 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). Task 4 also fires it for a variant name reused within a file's unions (inside one union or across two — variants share one flat value namespace, so a bare Ok reference could not otherwise pick a tag), reported at the reusing variant with the owning union as the related site. 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-E216 haxe-parity Task 1 (modules). A use <path> names something that is neither one of the six reserved stdlib namespaces (fs, proc, net, time, json, env) nor a directory this program actually discovers. unknown module `nosuchmodule` — not a discovered project module and not a reserved stdlib namespace
WO-E217 haxe-parity Task 1 (modules). A qualified reference (alias.name(...)) names a real declaration in a real, used module, but that declaration has no pub marker — private to its own module. `hidden` is not `pub` in module `secret`
WO-E218 haxe-parity Task 1 (modules). A bare (unqualified) name resolves as pub in more than one used module — "collisions diagnose rather than shadow silently" (the plan's own words): resolution never silently picks a winner among used modules, it fails loudly and names every alias that matched. `thing` is ambiguous — exported `pub` by more than one used module (a, b)
WO-E225 a field's declared type name isn't a builtin scalar, a declared class (typedef records included), a declared interface, or (haxe-parity Task 4) a declared union. Also checked, same shape, on a payload variant's field types. A dotted name whose head is a reserved stdlib namespace (json.Value) is accepted as UNKNOWN-BUT-RESERVED — the same plan-9 convention fs.stat(...) calls get; any other dotted name is as unknown as a misspelling. Checked once per field declaration, at the field's own position. unknown type \Wdiget``
WO-W202 (warning) haxe-parity Task 1 (modules). A file's own use clause is never actually referenced — neither a bare name resolving through it nor a qualified alias.name(...) call — anywhere in that file's surviving parse tree. unused `use fs`
WO-W203 (warning) haxe-parity Task 3 review fix (Critical 1). A switch's default arm is not textually last — no longer a silent dead-code trap (default is lowered last regardless of source position, Ast.switch_lowering_order), but still surprising source; fired once per switch, at default's own position. `default` is not the last arm -- a `case` written after it still matches (this compiler evaluates `default` last regardless of source position), which reads as dead code

Reserved, not yet emitted

unknown_fn_code (WO-E204), nullable_used_without_check_code (WO-E211), nullable_assign_mismatch_code (WO-E212), and missing_nil_check_code (WO-E213) are declared in types.ml — the range is reserved — but as of this task nothing in the front end ever raises them; there is no call site and therefore no real example message to catalog. They read like placeholders for checks Task 6's own plan brief named (type mismatch, bad arity, unsatisfied interface, …) that the shipped typechecker doesn't yet implement. module_not_imported_code (WO-E210) — the one member of this list Task 6's brief named that a later task, not a gap in Task 6's own shipped work, was blocking — has moved up into the table above: haxe-parity Task 1 gave it its first real emission site. invalid_builtin_code (WO-E209) has also since moved up into the table above — a 2026-08-11 hotfix wired it (a real print(7) segfault forced the issue; see its row above) — and type_mismatch_code (WO-E201) has too: haxe-parity Task 2 wired it for and/or's non-Bool-operand check (see its row above). non_exhaustive_switch_code (WO-E208) has also moved up: haxe-parity Task 3 (switch as expression) wired it — the exact "genuinely blocked: no switch keyword exists yet" gap the nullable-types dead-code register named is closed (see its row above). bad_arity_code (WO-E203) has now moved up too: haxe-parity Task 4 wired it for variant payload arity (construction and pattern sites — see its row above). That wiring is deliberately narrower than the code's own name promises: a user-declared fn/method call's arity is still ungated here and caught only at emission (WO-E403), the same pre-existing gap the WO-E209 hotfix note already disclosed for builtins. Four of the original ten remain dead (WO-E204, WO-E211, WO-E212, WO-E213 — the last three are Task 6's own forced-handling work). Listed here so a conformance fixture (plan 3) or a future reader doesn't assume one of the remaining codes is reachable today; move a code up into the table above in the same commit that wires its first real emission site.

Reachable but unenforced

unsatisfied_interface_code (WO-E205) is declared in types.ml but does not 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:

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)

Every ownership diagnostic carries a second site (the module doc's "two-site errors are the product") — the example messages below are the primary message only; the related site's label (e.g. "b moved here") renders indented beneath it.

code meaning example message
WO-E301 a place is read (or moved again) after its value was already moved — by assignment, take argument passing, constructor field init, or return. use of \b` after it was moved`
WO-E302 a place is moved while a live borrow of it, or of an overlapping place, still exists. cannot move \bag.items` while `r` is borrowed`
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; 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); (haxe-parity Task 2) break/continue with no enclosing loop — nothing upstream tracks loop nesting to reject it earlier; (haxe-parity Task 2) a "${expr}" interpolant whose type is neither Text nor Int, or is unresolvable; (haxe-parity Task 4) a field default the emitter cannot lower when a constructor literal omits that field — defaults are opaque token spans by design (ast.ml), and the lowerable set is exactly the sample's own shapes: Int (optionally negated), Text, and Bool literals, now(), and [] (an empty container, kinds from the field's declared type). 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`
WO-E406 haxe-parity Task 1 (modules). A call through a reserved stdlib alias (use fs/proc/net/time/json/env) survived typechecking — types.ml accepts it as UNKNOWN-BUT-RESERVED, since the six namespaces' members arrive in plan 9 — and reached emission. There is nothing to lower it to yet; an unused use fs never reaches this code at all, only a call that actually goes through it. stdlib module `fs` is not linked in this milestone (called as `fs.stat`)

Completeness method

Every code in this catalog was found the same way: grep every Diag.error/Diag.warning call site across compiler/src/*.ml (lexer, parser, types, owner — dump.ml never constructs a diagnostic) and compiler/bin/main.ml (the driver — added after a Task 8 review found WO-E214 living there, outside the original src/*.ml-only sweep), then cross-reference each ~code: argument back to the let <name>_code = ... 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, 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 enumeration, not a dig: diag.ml already reserves the numeric ranges, so the only open question per stage was which reserved codes actually fire — answered by exhaustive grep, not inspection of a handful of samples.