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.
This commit is contained in:
shoney.arickathil 2026-08-12 14:40:07 +02:00
parent 641a41e25c
commit 0df9b4fe31
123 changed files with 7866 additions and 176 deletions

View file

@ -166,13 +166,24 @@ let build_lookup (sources : (string * string) list) : Woc_lib.Diag.source_lookup
List.iter (fun (f, src) -> Hashtbl.replace tbl f src) sources;
fun f -> Hashtbl.find_opt tbl f
(* Pre-existing defect, fixed here (haxe-parity Task 1, modules): this
used to gate printing on `has_error` alone, so a collector holding
*only* warnings (no error at all — e.g. WO-W201's gc-suggestion, or
this task's own WO-W202 unused-`use`) printed nothing and exited 0,
indistinguishable from a collector with zero diagnostics. A warning
nobody ever sees is a dead feature, not a working one — this task's
own unused-`use` warning needs to actually reach stderr to be worth
having, which is what surfaced this. Still exits 0 whenever nothing
is an error (unchanged contract); the only behavior change is that a
warning-only run now also prints, matching what "diagnostics, if
any, print to stderr" (this file's own usage_msg) already promised. *)
let finish (collector : Woc_lib.Diag.Collector.t) (lookup : Woc_lib.Diag.source_lookup) : unit =
if Woc_lib.Diag.Collector.has_error collector then begin
prerr_string (Woc_lib.Diag.Collector.render_all collector lookup);
prerr_newline ();
exit 1
end
else exit 0
let text = Woc_lib.Diag.Collector.render_all collector lookup in
if text <> "" then begin
prerr_string text;
prerr_newline ()
end;
exit (Woc_lib.Diag.Collector.exit_code collector)
(* ---- Cross-file symbol resolution (Task 8) ---------------------------
@ -206,11 +217,12 @@ let merge_symbols (syms_list : Woc_lib.Types.symbols list) : Woc_lib.Types.symbo
interfaces = SM.union keep_first acc.interfaces s.interfaces;
free_fns = SM.union keep_first acc.free_fns s.free_fns;
typedefs = SM.union keep_first acc.typedefs s.typedefs;
unions = SM.union keep_first acc.unions s.unions;
modules = acc.modules @ s.modules;
})
Woc_lib.Types.{
classes = SM.empty; interfaces = SM.empty; free_fns = SM.empty;
typedefs = SM.empty; modules = [];
typedefs = SM.empty; unions = SM.empty; modules = [];
}
syms_list
@ -273,19 +285,64 @@ let check_symbol_collisions (collector : Woc_lib.Diag.Collector.t)
syms.interfaces))
per_file
let typecheck_all (collector : Woc_lib.Diag.Collector.t)
(parsed : (string * Woc_lib.Ast.program) list) : Woc_lib.Types.symbols =
(* ---- module identity (haxe-parity Task 1, modules) --------------------
A file's module is its directory, relative to the root `woc` was
pointed at — exactly the directory structure discover_dir above
already walks, just not thrown away this time. "." denotes the root
module itself (Filename.dirname's own convention for a name with no
directory part — reused rather than inventing a second sentinel). A
single-file invocation (root is not a directory — the bare-path
`woc <file.wo>` form) has exactly one file and therefore exactly one
module: "." unconditionally, since there is no sibling directory
structure to differ from. *)
let module_of_file ~(root : string) (file : string) : string =
if not (Sys.is_directory root) then "."
else
let root_norm =
if String.length root > 0 && root.[String.length root - 1] = '/' then
String.sub root 0 (String.length root - 1)
else root
in
let prefix = root_norm ^ "/" in
let plen = String.length prefix in
let rel =
if String.length file >= plen && String.sub file 0 plen = prefix then
String.sub file plen (String.length file - plen)
else file (* defensive: discover_dir always builds full = Filename.concat root rel', so this never triggers *)
in
Filename.dirname rel
(* Returns the existing global, flat-merged `syms` (owner.ml's and most of
emit.ml's own view — unchanged by this task) alongside the new
per-module tables (CRITICAL 1 review finding: the emitter needs these
too, for the one place a flat merge is the wrong answer — see
Types.module_symbols' own doc comment). *)
let typecheck_all (collector : Woc_lib.Diag.Collector.t) ~(root : string)
(parsed : (string * Woc_lib.Ast.program) list) :
Woc_lib.Types.symbols * (string, Woc_lib.Types.symbols) Hashtbl.t =
let per_file_syms =
List.map
(fun (f, prog) -> (f, Woc_lib.Types.collect_declarations ~file:f prog collector))
parsed
in
check_symbol_collisions collector per_file_syms;
let module_of = module_of_file ~root in
Woc_lib.Types.check_modules collector ~module_of per_file_syms parsed;
let module_syms = Woc_lib.Types.module_symbols ~module_of per_file_syms in
let syms = merge_symbols (List.map snd per_file_syms) in
List.iter
(fun (f, prog) -> Woc_lib.Types.typecheck_program ~file:f prog syms collector)
parsed;
syms
(* `~file_syms` (hotfix, multi-file double-report): `per_file_syms` and
`parsed` are both `List.map`s over the same original file list, in
the same order, so pairing them positionally is exact -- each
file's own collect_declarations output goes with that same file's
own prog. `syms` (the merged table) is still passed through
separately for cross-file resolution; see typecheck_program's own
doc comment for what narrows and what doesn't. *)
List.iter2
(fun (f, prog) (_, file_syms) ->
Woc_lib.Types.typecheck_program ~file:f ~module_of ~module_syms ~file_syms prog syms collector)
parsed per_file_syms;
(syms, module_syms)
let dump_tokens path =
let sources = discover_and_read path in
@ -316,7 +373,7 @@ let dump_owner path =
let collector = Woc_lib.Diag.Collector.create () in
let multi = List.length sources > 1 in
let parsed = parse_all collector sources in
let syms = typecheck_all collector parsed in
let syms, _module_syms = typecheck_all collector ~root:path parsed in
List.iter
(fun (f, prog) ->
let tables = Woc_lib.Owner.analyze ~file:f prog syms collector in
@ -332,7 +389,7 @@ let check_only 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 syms, _module_syms = typecheck_all collector ~root:path parsed in
List.iter (fun (f, prog) -> ignore (Woc_lib.Owner.analyze ~file:f prog syms collector)) parsed;
finish collector (build_lookup sources)
@ -348,14 +405,16 @@ 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 syms, module_syms = typecheck_all collector ~root:path 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
let image =
Woc_lib.Emit.emit ~syms ~module_of:(module_of_file ~root:path) ~module_syms collector units
in
(collector, build_lookup sources, image)
let write_file path contents =

View file

@ -136,6 +136,13 @@ type field = {
`+`/`-`, tighter than comparison — this ladder's ordering). *)
type unop = Neg
(* `And`/`Or` (haxe-parity Task 2): real keywords, spelled as words, not
`&&`/`||` — the spec amendment's own wording. `Bool`-typed operands
only (types.ml wires this through, no truthiness); short-circuit,
lowered to compare-and-jump on the existing JZ/JMP opcodes (emit.ml),
no new opcode. Own precedence level, looser than every comparison —
see parser.ml's ladder doc for the exact ordering (`or` loosest, then
`and`, then comparison). *)
type binop =
| Add
| Sub
@ -149,6 +156,8 @@ type binop =
| Le
| Gt
| Ge
| And
| Or
type expr = {
id : int;
@ -190,6 +199,35 @@ and expr_kind =
| Binary of binop * expr * expr
| Ctor of string * (string * expr) list
| DbStub of Token.t list
(* haxe-parity Task 2: one `${expr}` interpolation site, produced only
by the string-interpolation desugar (parser.ml) — never written
directly by a parse rule the way every other expr_kind is. Its
*textification* (pass through if already Text, `int_to_text` if
Int, a diagnostic for anything else) is a type-directed decision
deferred to emit.ml, since the parser has no type information yet;
"desugars at parse time to concatenation" covers the chain SHAPE
(a `Binary(Concat, ...)` of StrLit/Interp segments), not this one
leaf's textification. *)
| Interp of expr
(* haxe-parity Task 3: `switch subject { case v1, v2: <stmts> ...
default: <stmts> }`. One construct for both positions (the brief's
own words: "statement position is the expression with a discarded
value") — `stmt` has no separate switch node; a bare `switch {...}`
statement is simply this same node wrapped in `ExprStmt`, exactly
like a bare `select ...` call already is. Arms carry a `stmt list`
body (not a single `expr`) because the sample's own sites do —
`case "tail_log": if args == nil { return err(...); } ... return
self.tools.tail_log(...);` is not reducible to one expression — so
`expr`/`stmt` must be mutually recursive from here down (this is
the one place `expr_kind` reaches into `stmt`; every other node
above predates this task and never needed to). `values = []` means
`default` (`is_default = true`); a `case` always has at least one
value, and — the sample's own `alias_of`/cron.wo shape,
`case "@daily", "@midnight": ...` — may have more than one,
matching on any of them. No guards, no ranges: the sample never
uses either, so neither is grammar here (YAGNI, recorded in the
task report). *)
| Switch of expr * switch_arm list
(* ---- statements (Task 5) ---------------------------------------------
@ -210,7 +248,7 @@ and expr_kind =
else-block whose sole statement is itself an `If` — rather than a
third `else_body` shape, so dump.ml's block-rendering code (already
written once, for `then_body`) renders the chain for free. *)
type stmt = {
and stmt = {
s_id : int;
s_pos : pos;
s_kind : stmt_kind;
@ -242,6 +280,76 @@ and stmt_kind =
}
| Return of expr option
| ExprStmt of expr
(* haxe-parity Task 2: loop control. Both reuse owner.ml's scope-end
drop machinery (see Owner.DBreak/DContinue) so an owned value still
alive in the loop body is dropped at the jump, not left to leak;
emit.ml refuses to lower either one outside a loop (WO-E403 —
"cannot lower", the same convention as every other construct with
no legal target, since nothing upstream tracks loop nesting as a
parse- or type-error). *)
| Break
| Continue
(* `do { body } while cond` — body runs at least once, then the
condition gates repeating it. Lowered onto the same JZ/JMP pair
`while`/`for` already use, just reordered (parser.ml/emit.ml). *)
| DoWhile of {
body : stmt list;
cond : expr;
}
(* haxe-parity Task 3: one arm of a `switch`. `values = []` iff
`is_default`; a `case` arm's `values` is never empty (parser
contract, mirrored — not re-checked — by every later stage). No
per-arm `id`: nothing downstream keys a side table on "this specific
arm" independent of the `Switch` expr that owns it (the shared
`Switch.id` is the drop-scope/branch-join node for every arm, one
per-arm string label telling them apart — exactly how `If`'s THEN/
ELSE already share `s_id` and differ only by label). *)
and switch_arm = {
arm_pos : pos;
values : expr list;
is_default : bool;
body : stmt list;
}
(* haxe-parity Task 3 (review fix, Critical 1): the arm order a
switch's own lowering actually walks — `default` moved to the end,
regardless of where it sits in the source. `default` has no
comparison of its own (it matches unconditionally); lowering the
arms in raw *source* order therefore made any `case` arm written
after a `default` permanently unreachable dead code (nothing ever
jumps into it, and `default`'s own body jumps straight to the
switch's exit, never falling through) — a real, reviewer-reproduced
bug, not a theoretical one. Both `owner.ml` (`analyze_switch`,
whose drop-scope/JOIN-DROP tables are keyed "ARM<i>" by this order)
and `emit.ml` (`emit_switch`, the compare-and-jump chain itself)
call this SAME function rather than each re-deriving the reorder
independently — the two-file fix the review flagged, done once so
the "ARM<i>" indices the two files hand each other can never drift
apart. `List.partition` is stable (documented in the stdlib): every
`case` arm keeps its own relative order, and — malformed, not
otherwise rejected — more than one `default` would too, all pushed
after every `case`. *)
let switch_lowering_order (arms : switch_arm list) : switch_arm list =
let cases, defaults = List.partition (fun (a : switch_arm) -> not a.is_default) arms in
cases @ defaults
(* haxe-parity Task 2: `const NAME = <literal>` — a compile-time value,
substituted for every unshadowed `Ident NAME` reference by a
dedicated post-parse pass (parser.ml's own const-substitution step,
run at the end of `parse`) rather than threaded through
typecheck/owner/emit as a new resolvable name: after substitution a
const reference simply *is* the literal expr it names, so every later
stage needs zero const-specific code. `value` is restricted by the
parser to a literal (`IntLit`/`StrLit`/`BoolLit`, optionally
`Unary(Neg, IntLit)`) — never a general expression, matching the
brief's own "= literal", not "= expr". *)
type const_decl = {
id : int;
pos : pos;
name : string;
value : expr;
}
(* A signature shared shape (name/params/ret) appears twice: as an
interface method (no body) and as a class/type/free-fn method (body
@ -266,6 +374,15 @@ type method_decl = {
(* Task 4 captured this as a verbatim token span (brace-depth counter
only); Task 5 parses it for real. *)
body : stmt list;
(* haxe-parity Task 1 (modules): true only for a top-level free `fn`
parsed with a leading `pub` marker. method_decl is shared with
class methods (Task 4's own design — see this file's module doc),
but `pub` is a Task-1-scoped, top-level-declaration-only marker
(classes, interfaces, free fns); method-level visibility is a
different, not-yet-designed question, so parse_method always
passes `pub = false` for a class body's own methods — this field
is meaningful only when the surrounding decl is `Fn`. *)
pub : bool;
}
(* `@table(name: "...", index: [a, b], index: [c])` — optional storage
@ -287,10 +404,31 @@ type class_decl = {
deliberate divergence from rt's plan-13 asymmetry, where a plain
`type`'s `fn` was skip-discarded — see parser.ml's module doc). *)
is_class : bool;
(* haxe-parity Task 4: true for `typedef Name = { ... }` — a
STRUCTURAL record alias, reusing this same node (same field
grammar, same downstream ctor/field machinery) rather than a
parallel decl kind. What the flag changes downstream: two records
with the same shape are the SAME type (emit.ml dedups them onto one
class-table entry; types.ml's arm unification compares shapes, not
names). A record body is fields only — the parser never puts a
method or const inside one, so `methods`/`consts` are always []
here. `is_class` is false whenever this is true. *)
is_record : bool;
is_gc : bool; (* @gc — reference semantics, spec section 3/4 *)
table : table_cfg option; (* @table(...) — absent unless annotated *)
fields : field list;
methods : method_decl list;
(* haxe-parity Task 2: class-level `const NAME = literal` (bare, no
`static` — `static const` is Task 7's syntax, deliberately not
handled here so it falls through to a clean parse error, counted
against the gap until Task 7 lands). Scoped to this class's own
methods only by the same post-parse substitution pass that handles
top-level consts — see const_decl's own doc comment. *)
consts : const_decl list;
(* haxe-parity Task 1 (modules): `pub` marker — false (private to the
declaring module) unless the declaration was written `pub class`/
`pub type`. *)
pub : bool;
}
type interface_decl = {
@ -298,11 +436,51 @@ type interface_decl = {
pos : pos;
name : string;
methods : method_sig list; (* signatures only — no fields, no bodies *)
pub : bool; (* haxe-parity Task 1 — see class_decl.pub *)
}
(* haxe-parity Task 1 (modules): `use fs` (a reserved stdlib namespace)
or `use shared/util` (project-relative, slash-separated path
segments naming another discovered module's directory). `segments`
is never empty — the parser requires at least one identifier. *)
type use_decl = {
id : int;
pos : pos;
segments : string list;
}
(* haxe-parity Task 4: one variant of a union declaration
(`type Name = A | B | C(field: Type, ...)`). `v_fields` is the
payload, in declaration order — [] for a bare variant. No per-variant
`id`: like switch_arm, nothing downstream keys a side table on "this
specific variant" independent of the union that owns it (a variant's
identity downstream is (union, ordinal) — its tag). *)
type variant_decl = {
v_pos : pos;
v_name : string;
v_fields : (string * field_ty) list;
}
(* haxe-parity Task 4: `type Name = V1 | V2 | ...` — a tagged union.
All-bare unions (every `v_fields` empty) lower to plain integer tags
(the variant's ordinal), no heap object and no class-table entry;
a union with at least one payload variant lowers every variant to a
small heap object whose class-table entry the compiler generates
(docs/plan/oop-vm/00-wob-format.md, "enum payload variants"). *)
type union_decl = {
id : int;
pos : pos;
name : string;
variants : variant_decl list;
pub : bool;
}
type decl =
| Class of class_decl
| Interface of interface_decl
| Fn of method_decl (* free (non-method) top-level function *)
| Use of use_decl
| Const of const_decl (* haxe-parity Task 2: top-level `const NAME = literal` *)
| Union of union_decl (* haxe-parity Task 4: `type Name = A | B | ...` *)
type program = { decls : decl list }

View file

@ -67,6 +67,8 @@ let builtin_name = function
| 10 -> "map_set"
| 11 -> "map_get"
| 12 -> "map_has"
| 13 -> "int_to_text"
| 14 -> "variant_tag"
| n -> Printf.sprintf "builtin%d" n
let kind_name = function

View file

@ -27,6 +27,12 @@ let kind_label (k : Token.kind) : string =
| Token.Ident s -> Printf.sprintf "IDENT(%s)" s
| Token.Int n -> Printf.sprintf "INT(%d)" n
| Token.Str s -> Printf.sprintf "STR(%s)" s
| Token.InterpStr segs ->
let part_str = function
| Token.SText s -> Printf.sprintf "TEXT(%s)" s
| Token.SExpr s -> Printf.sprintf "EXPR(%s)" s
in
Printf.sprintf "INTERP_STR(%s)" (String.concat "," (List.map part_str segs))
| Token.KwType -> "KW_TYPE"
| Token.KwClass -> "KW_CLASS"
| Token.KwInterface -> "KW_INTERFACE"
@ -44,6 +50,19 @@ let kind_label (k : Token.kind) : string =
| Token.KwFalse -> "KW_FALSE"
| Token.KwInsert -> "KW_INSERT"
| Token.KwSelect -> "KW_SELECT"
| Token.KwUse -> "KW_USE"
| Token.KwPub -> "KW_PUB"
| Token.KwBreak -> "KW_BREAK"
| Token.KwContinue -> "KW_CONTINUE"
| Token.KwDo -> "KW_DO"
| Token.KwConst -> "KW_CONST"
| Token.KwAnd -> "KW_AND"
| Token.KwOr -> "KW_OR"
| Token.KwInline -> "KW_INLINE"
| Token.KwSwitch -> "KW_SWITCH"
| Token.KwCase -> "KW_CASE"
| Token.KwDefault -> "KW_DEFAULT"
| Token.KwTypedef -> "KW_TYPEDEF"
| Token.LBrace -> "LBRACE"
| Token.RBrace -> "RBRACE"
| Token.LParen -> "LPAREN"
@ -168,6 +187,8 @@ let binop_str : Ast.binop -> string = function
| Ast.Le -> "<="
| Ast.Gt -> ">"
| Ast.Ge -> ">="
| Ast.And -> "and"
| Ast.Or -> "or"
(* Raw token span shared by both DbStub renderings below: a statement-
position DbStub (dump_stmt) and an expression-position one nested
@ -198,6 +219,20 @@ let rec expr_str (e : Ast.expr) : string =
(String.concat ", "
(List.map (fun (fname, fval) -> Printf.sprintf "%s: %s" fname (expr_str fval)) fields))
| Ast.DbStub toks -> Printf.sprintf "DB_STUB(%s)" (dbstub_tokens_str toks)
| Ast.Interp inner -> Printf.sprintf "INTERP(%s)" (expr_str inner)
(* haxe-parity Task 3: arm bodies are `stmt list`, not one `expr` — no
golden AST/bc fixture pins a switch (direct assertions instead, see
runner.ml, same convention haxe-parity Task 2 used), so this is a
one-line-per-arm-header summary ("readable enough to eyeball", this
file's own module-doc contract), not a full unparse of every arm's
statements. *)
| Ast.Switch (subject, arms) ->
let arm_str (a : Ast.switch_arm) =
if a.Ast.is_default then "default: ..."
else Printf.sprintf "case %s: ..." (String.concat ", " (List.map expr_str a.Ast.values))
in
Printf.sprintf "SWITCH %s { %s }" (expr_str subject)
(String.concat " " (List.map arm_str arms))
(* dump_stmt — one line per statement (LINE:COL KIND detail), matching
dump_field/dump_method_sig's "one descriptive line" convention;
@ -235,12 +270,25 @@ let rec dump_stmt (s : Ast.stmt) : string list =
| Ast.ExprStmt { Ast.kind = Ast.DbStub toks; _ } ->
[ Printf.sprintf "%s DB_STUB %s" (pos_str s.Ast.s_pos) (dbstub_tokens_str toks) ]
| Ast.ExprStmt e -> [ Printf.sprintf "%s EXPR %s" (pos_str s.Ast.s_pos) (expr_str e) ]
| Ast.Break -> [ Printf.sprintf "%s BREAK" (pos_str s.Ast.s_pos) ]
| Ast.Continue -> [ Printf.sprintf "%s CONTINUE" (pos_str s.Ast.s_pos) ]
| Ast.DoWhile { body; cond } ->
Printf.sprintf "%s DO" (pos_str s.Ast.s_pos) :: indent_block body
@ [ Printf.sprintf "%s WHILE %s" (pos_str s.Ast.s_pos) (expr_str cond) ]
(* [header; body statements...] — body lines are already indented two
spaces; callers nesting this under a class add one more level of
indent uniformly, same as before Task 5. *)
(* `pub` (haxe-parity Task 1, modules) prefixes the header when set; a
plain (non-pub) declaration renders byte-identical to every
pre-Task-1 golden fixture — this is additive, never a reformat of
the unmarked case. *)
let pub_prefix (pub : bool) : string = if pub then "PUB " else ""
let dump_method (m : Ast.method_decl) : string list =
let header = Printf.sprintf "%s METHOD %s" (pos_str m.pos) (sig_str m.name m.params m.ret) in
let header =
Printf.sprintf "%s %sMETHOD %s" (pos_str m.pos) (pub_prefix m.pub) (sig_str m.name m.params m.ret)
in
let body_lines = List.concat_map (fun s -> List.map (fun l -> " " ^ l) (dump_stmt s)) m.body in
header :: body_lines
@ -261,24 +309,56 @@ let annotations_header (is_gc : bool) (table : Ast.table_cfg option) : string =
in
gc_part ^ table_part
(* haxe-parity Task 2: `CONST NAME = <literal>` — top-level or (bare)
class-level. *)
let dump_const (c : Ast.const_decl) : string =
Printf.sprintf "%s CONST %s = %s" (pos_str c.pos) c.name (expr_str c.value)
let dump_class (c : Ast.class_decl) : string list =
let kw = if c.is_class then "CLASS" else "TYPE" in
let kw = if c.is_record then "TYPEDEF" else if c.is_class then "CLASS" else "TYPE" in
let header =
Printf.sprintf "%s %s %s%s" (pos_str c.pos) kw c.name (annotations_header c.is_gc c.table)
Printf.sprintf "%s %s%s %s%s" (pos_str c.pos) (pub_prefix c.pub) kw c.name
(annotations_header c.is_gc c.table)
in
let field_lines = List.map (fun f -> " " ^ dump_field f) c.fields in
let const_lines = List.map (fun cd -> " " ^ dump_const cd) c.consts in
let method_lines = List.concat_map (fun m -> List.map (fun l -> " " ^ l) (dump_method m)) c.methods in
(header :: field_lines) @ method_lines
(header :: field_lines) @ const_lines @ method_lines
let dump_interface (i : Ast.interface_decl) : string list =
let header = Printf.sprintf "%s INTERFACE %s" (pos_str i.pos) i.name in
let header = Printf.sprintf "%s %sINTERFACE %s" (pos_str i.pos) (pub_prefix i.pub) i.name in
let method_lines = List.map (fun m -> " " ^ dump_method_sig m) i.methods in
header :: method_lines
(* USE <path>, segments joined by '/' exactly as written in source
(`use shared/util` -> "shared/util") — no resolution performed here,
this is a syntax-level dump like every other dump_* function. *)
let dump_use (u : Ast.use_decl) : string list =
[ Printf.sprintf "%s USE %s" (pos_str u.pos) (String.concat "/" u.segments) ]
(* UNION Name = A | B(f: T) — one line, variants rendered inline the
same "readable enough to eyeball" way expr_str renders expressions
(haxe-parity Task 4). *)
let dump_union (u : Ast.union_decl) : string list =
let variant_str (v : Ast.variant_decl) =
match v.Ast.v_fields with
| [] -> v.Ast.v_name
| fs ->
Printf.sprintf "%s(%s)" v.Ast.v_name
(String.concat ", "
(List.map (fun (n, ty) -> Printf.sprintf "%s: %s" n (field_ty_str ty)) fs))
in
[ Printf.sprintf "%s %sUNION %s = %s" (pos_str u.Ast.pos) (pub_prefix u.Ast.pub) u.Ast.name
(String.concat " | " (List.map variant_str u.Ast.variants))
]
let dump_decl : Ast.decl -> string list = function
| Ast.Class c -> dump_class c
| Ast.Interface i -> dump_interface i
| Ast.Fn f -> dump_method f
| Ast.Const c -> [ dump_const c ]
| Ast.Use u -> dump_use u
| Ast.Union u -> dump_union u
let dump_ast (prog : Ast.program) : string =
let lines = List.concat_map dump_decl prog.decls in
@ -413,6 +493,8 @@ let dump_owner (t : Owner.tables) : string =
| Owner.DBranchJoin label ->
Printf.sprintf "%s JOIN-DROP %s %s" pos label (drop_items_str d.Owner.dr_items)
| Owner.DLiveMask -> Printf.sprintf "%s LIVE-MASK %s" pos (drop_items_str d.Owner.dr_items)
| Owner.DBreak -> Printf.sprintf "%s BREAK %s" pos (drop_items_str d.Owner.dr_items)
| Owner.DContinue -> Printf.sprintf "%s CONTINUE %s" pos (drop_items_str d.Owner.dr_items)
in
let rc_line (r : Owner.rc_site) =
Printf.sprintf "%s %s %s %s" (owner_pos_str r.Owner.rc_pos)

File diff suppressed because it is too large Load diff

View file

@ -105,7 +105,8 @@ let read_ident_chars lx =
plus uppercase-only INSERT/SELECT. Deliberately absent: self, me,
subscribe, receive, and lowercase insert/select — those fall
through to the `_ -> None` case below and lex as plain Ident,
matching rt and the CLAUDE.md gotcha this task exists to preserve. *)
matching rt and the CLAUDE.md gotcha this task exists to preserve.
`use`/`pub` (haxe-parity Task 1, modules) added on top of that set. *)
let keyword_kind = function
| "type" -> Some Token.KwType
| "class" -> Some Token.KwClass
@ -122,10 +123,82 @@ let keyword_kind = function
| "in" -> Some Token.KwIn
| "true" -> Some Token.KwTrue
| "false" -> Some Token.KwFalse
| "use" -> Some Token.KwUse
| "pub" -> Some Token.KwPub
| "break" -> Some Token.KwBreak
| "continue" -> Some Token.KwContinue
| "do" -> Some Token.KwDo
| "const" -> Some Token.KwConst
| "and" -> Some Token.KwAnd
| "or" -> Some Token.KwOr
| "inline" -> Some Token.KwInline
| "switch" -> Some Token.KwSwitch
| "case" -> Some Token.KwCase
| "default" -> Some Token.KwDefault
| "typedef" -> Some Token.KwTypedef
| "INSERT" -> Some Token.KwInsert
| "SELECT" -> Some Token.KwSelect
| _ -> None
(* haxe-parity Task 2: scans the raw source of one `${...}` interpolation
body, starting right after the `{` (caller already consumed `$` and
`{`). Returns that raw, unlexed text -- the parser re-tokenizes it as a
full expression (parser.ml's own desugar-to-Concat step; this is a
mechanical extraction only, no semantics). Tracks brace depth so a
nested `{}` (a constructor literal inside an interpolation,
`${Point{x:1}.x}`) doesn't end the scan early, and skips a nested
string literal verbatim (honoring its own backslash escapes) so a
quote or brace *inside* that nested string can't confuse either
count. Runs off the end of the file the same silent way an
unterminated outer string does -- the caller's own EOF handling picks
up right after. *)
let read_interp_expr lx =
let buf = Buffer.create 16 in
let depth = ref 0 in
let continue_ = ref true in
while !continue_ do
match peek lx with
| None -> continue_ := false
| Some '}' when !depth = 0 ->
ignore (advance lx);
continue_ := false
| Some ('{' as c) ->
incr depth;
Buffer.add_char buf c;
ignore (advance lx)
| Some ('}' as c) ->
decr depth;
Buffer.add_char buf c;
ignore (advance lx)
| Some (('"' | '\'') as q) ->
Buffer.add_char buf q;
ignore (advance lx);
let scanning = ref true in
while !scanning do
match peek lx with
| None -> scanning := false
| Some c when c = q ->
Buffer.add_char buf c;
ignore (advance lx);
scanning := false
| Some '\\' -> (
Buffer.add_char buf '\\';
ignore (advance lx);
match peek lx with
| Some c ->
Buffer.add_char buf c;
ignore (advance lx)
| None -> scanning := false)
| Some c ->
Buffer.add_char buf c;
ignore (advance lx)
done
| Some c ->
Buffer.add_char buf c;
ignore (advance lx)
done;
Buffer.contents buf
let tokenize (collector : Diag.Collector.t) ~(file : string) (src : string) :
Token.t list =
let lx = make src in
@ -171,6 +244,16 @@ let tokenize (collector : Diag.Collector.t) ~(file : string) (src : string) :
let quote = c in
ignore (advance lx);
let buf = Buffer.create 16 in
(* haxe-parity Task 2: segments accumulate here only when at
least one `${...}` is actually found (flush_text below); a
plain string never touches `parts` at all, so it emits the
exact same `Token.Str` it always did -- see the `match !parts`
dispatch after the loop. *)
let parts = ref [] in
let flush_text () =
parts := Token.SText (Buffer.contents buf) :: !parts;
Buffer.clear buf
in
let scanning = ref true in
while !scanning do
match peek lx with
@ -184,6 +267,17 @@ let tokenize (collector : Diag.Collector.t) ~(file : string) (src : string) :
| Some c when c = quote ->
ignore (advance lx);
scanning := false
| Some '$' when peek_at lx 1 = Some '{' ->
(* Unescaped `${` -- `\$` never reaches here, it is fully
consumed by the backslash branch below, one dispatch
earlier, so this is always a genuine interpolation start,
never an escaped `$` that happens to be followed by `{`. *)
flush_text ();
ignore (advance lx);
(* '$' *)
ignore (advance lx);
(* '{' *)
parts := Token.SExpr (read_interp_expr lx) :: !parts
| Some '\\' -> (
(* Captured before advancing: this is the backslash's own
position, so a dangling-escape diagnostic points at the
@ -196,6 +290,10 @@ let tokenize (collector : Diag.Collector.t) ~(file : string) (src : string) :
| Some '\\' -> Buffer.add_char buf '\\'
| Some '"' -> Buffer.add_char buf '"'
| Some '\'' -> Buffer.add_char buf '\''
(* `\$` -- not one of the escapes above, so it falls into
this catch-all exactly like any other unrecognized
backslash sequence, producing a literal `$` that the `$`
dispatch above never sees (it already advanced past it). *)
| Some other -> Buffer.add_char buf other
| None ->
report_unterminated_escape esc_line esc_col;
@ -204,7 +302,11 @@ let tokenize (collector : Diag.Collector.t) ~(file : string) (src : string) :
ignore (advance lx);
Buffer.add_char buf other
done;
emit (Token.Str (Buffer.contents buf)) line col
flush_text ();
(match List.rev !parts with
| [] -> emit (Token.Str "") line col
| [ Token.SText s ] -> emit (Token.Str s) line col
| segs -> emit (Token.InterpStr segs) line col)
end
else if is_digit c then begin
let n = ref 0 in

View file

@ -243,6 +243,13 @@ type drop_item = {
DReturn — every live owned local in *all* enclosing scopes at an
early (or final) return, after the returned value's own
move: the DROPs that must run before the frame leaves.
DBreak/DContinue (haxe-parity Task 2) — every live owned local in
every scope from here up to *and including* the nearest
enclosing loop's own body scope (WHILE/FOR/DO — never
beyond it, since a break/continue only exits the loop, not
the function). Same "reuse DReturn's own machinery" shape,
bounded to the loop instead of the whole function — see
live_holders_upto.
DOverwrite — the value an assignment overwrites (see the module doc).
DBranchJoin — join normalization: the locals the *other* branch of an if
moved and this one did not, dropped at this branch's end so
@ -260,6 +267,8 @@ type drop_kind =
| DOverwrite
| DBranchJoin of string
| DLiveMask
| DBreak
| DContinue
type drop_site = {
dr_node : int;
@ -370,6 +379,15 @@ type ctx = {
sink : sink;
fn_name : string;
mutable scopes : scope list; (* innermost first *)
(* haxe-parity Task 2: the enclosing While/For/DoWhile statement ids,
innermost first — the nearest enclosing loop's own `s.s_id`, which
is also its own scope's `sc_node` (analyze_block ~node:s.s_id).
Empty outside any loop; a break/continue found there records no
drop site at all (nothing to bound the drop to) and leaves the
actual rejection to emit.ml, which has no jump target to lower it
onto — the same WO-E403 "no legal target" convention as every
other construct with nothing to lower to. *)
mutable loop_stack : int list;
(* false during a loop's probe pass: no diagnostics, no table entries *)
mutable recording : bool;
(* set when the current path has returned; a diverged path contributes
@ -399,7 +417,15 @@ let oclass_of (ctx : ctx) (ft : Ast.field_ty) : oclass =
if Types.is_builtin_scalar n then Copy
else if Types.is_gc_class ctx.syms n then Gc
else if Types.StringMap.mem n ctx.syms.Types.classes then Owned
else Copy (* unknown type: WO-E225 already reported by types.ml *)
else (
(* haxe-parity Task 4: an all-bare union value is a plain integer
tag — Copy, exactly like a builtin scalar (moving/aliasing it
is copying an int). A payload union value is a heap variant
object — Owned, one owner, dropped at scope end like any other
non-@gc instance. *)
match Types.StringMap.find_opt n ctx.syms.Types.unions with
| Some u -> if u.Types.u_has_payload then Owned else Copy
| None -> Copy (* unknown type: WO-E225 already reported by types.ml *))
| Ref _ -> Copy
| Multi _ | Map _ -> Owned
| Nullable _ -> Copy (* unreachable: unwrapped above *)
@ -447,8 +473,8 @@ and stmt_writes_self (s : Ast.stmt) : bool =
| If { then_body; else_body; _ } ->
body_writes_self then_body
|| (match else_body with Some (_, b) -> body_writes_self b | None -> false)
| While { body; _ } | For { body; _ } -> body_writes_self body
| Let _ | Return _ | ExprStmt _ -> false
| While { body; _ } | For { body; _ } | DoWhile { body; _ } -> body_writes_self body
| Let _ | Return _ | ExprStmt _ | Break | Continue -> false
(* A resolved callee: its parameter conventions (positional), its return
type, and — for a method call — whether the receiver is borrowed
@ -463,22 +489,108 @@ type callee = {
ce_recv_excl : bool;
}
(* haxe-parity Task 4: a bare variant reference (`Pending`) or a variant
construction (`Failed("x")`) types as its union — locals always win
first (find_local / resolve_callee run before this), so a shadowing
binding is never mistaken for a variant. *)
let variant_union_ty (ctx : ctx) (n : string) : Ast.field_ty option =
match Types.find_variant ctx.syms n with
| Some (u, _) -> Some (Ast.Scalar u.Types.u_name)
| None -> None
let rec expr_ty (ctx : ctx) (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 find_local ctx n with Some l -> Some l.l_ty | None -> None)
| Ident n -> (
match find_local ctx n with
| Some l -> Some l.l_ty
| None -> variant_union_ty ctx n)
| Field (base, f) -> (
match expr_ty ctx base with
| Some bt -> ( match unwrap_nullable bt with Scalar cn -> field_ty_of ctx cn f | _ -> None)
| None -> None)
| Index (base, _) -> ( match expr_ty ctx base with Some bt -> elem_ty bt | None -> None)
| Call (callee, _) -> ( match resolve_callee ctx callee with Some c -> c.ce_ret | None -> None)
| Call (callee, _) -> (
match resolve_callee ctx callee with
| Some c -> c.ce_ret
| None -> (
(* an unresolved Ident callee may be a variant construction — its
value is a fresh Owned variant object of the union's type, and
missing this here is a real leak (analyze_let's None-fallback
classifies as Copy, so the object would never be dropped). *)
match callee.kind with
| Ident n -> variant_union_ty ctx n
| _ -> None))
| Unary (_, o) -> expr_ty ctx o
| Binary _ -> None (* arithmetic/comparison: Copy either way *)
| Ctor (cn, _) -> Some (Scalar cn)
| Interp _ -> Some (Scalar "Text") (* an interpolation always produces Text *)
| DbStub _ -> None
| Switch (subject, arms) ->
(* review fix, Critical 3: this was `None` ("not chased", the same
call as `Binary`/`DbStub` above) — a real, reviewer-reproduced
leak, not a theoretical gap: `analyze_let`'s own fallback for
`expr_ty = None` is `Scalar "Int"` (Copy), so an *unannotated*
`let v = switch ... { case ...: SomeClass{...}; ... }` was
classified Copy and never dropped, even for a plain class with
no union involved. Mirrors emit.ml's own `ty_of_expr` Switch
case exactly (same shape, this file's own `Ast.field_ty`/`ctx`
types instead of `Types.typ`/`pctx`) rather than duplicating
types.ml's `typecheck_switch` unification: the first arm's
trailing `ExprStmt`'s own type wins — types.ml already proved
every other arm agrees, or reported WO-E201 if not, so trusting
the first arm here is not a second, weaker check, just this
file's own narrower deriver reading the same fact.
Task 4 fix round 1 (review Critical 1b): an arm may yield its own
payload BINDING (`case Boxed(b): b;` — the escape shape the
log-watcher's own return pattern uses). The binding is not a
local at derivation time (it exists only during the arm's own
walk), so the plain recursive call typed the whole switch `None`
-> the `Scalar "Int"` fallback -> Copy — a leak (unannotated
`let`) or a bogus downstream error. `binding_ty_of_arm` reads the
binding's type straight off the subject's variant declaration. *)
(match arms with
| [] -> None
| first :: _ -> (
match List.rev first.Ast.body with
| { Ast.s_kind = ExprStmt ve; _ } :: _ -> (
match ve.Ast.kind with
| Ident n -> (
match binding_ty_of_arm ctx subject first n with
| Some fty -> Some fty
| None -> expr_ty ctx ve)
| _ -> expr_ty ctx ve)
| _ -> None))
(* The declared type of payload binding [n], when [arm]'s pattern binds
it off [subject]'s union — None whenever this is not that shape. *)
and binding_ty_of_arm (ctx : ctx) (subject : Ast.expr) (arm : Ast.switch_arm) (n : string) :
Ast.field_ty option =
match expr_ty ctx subject with
| Some (Scalar sn) -> (
match Types.StringMap.find_opt sn ctx.syms.Types.unions with
| Some u when u.Types.u_has_payload -> (
match arm.Ast.values with
| [ { Ast.kind = Ast.Call ({ Ast.kind = Ast.Ident vname; _ }, bargs); _ } ] -> (
match
List.find_opt (fun (vi : Types.variant_info) -> vi.Types.vi_name = vname)
u.Types.u_variants
with
| Some vi when List.length bargs = List.length vi.Types.vi_fields ->
let rec zip (args : Ast.expr list) fields =
match (args, fields) with
| { Ast.kind = Ast.Ident bn; _ } :: _, (_, fty) :: _ when bn = n -> Some fty
| _ :: ta, _ :: tf -> zip ta tf
| _ -> None
in
zip bargs vi.Types.vi_fields
| _ -> None)
| _ -> None)
| _ -> None)
| _ -> None
and resolve_callee (ctx : ctx) (callee : Ast.expr) : callee option =
let params_of ps = List.map (fun (n, _, conv) -> (n, conv)) ps in
@ -683,6 +795,23 @@ let is_live_holder (l : local) : bool =
let live_holders (ctx : ctx) : local list =
List.concat_map (fun sc -> List.filter is_live_holder sc.sc_locals) ctx.scopes
(* haxe-parity Task 2: every live holder from the innermost scope up to
and including the scope whose sc_node is [loop_node] (the nearest
enclosing loop's own body scope) — DBreak/DContinue's own bound
version of live_holders, which goes all the way to the function's
own outermost scope (return's job: leave the whole frame). A
break/continue only leaves the loop, so scopes *outside* it are
untouched — their own DScope drop still runs later, at the loop's
normal exit. *)
let live_holders_upto (ctx : ctx) (loop_node : int) : local list =
let rec go = function
| [] -> []
| (sc : scope) :: rest ->
let here = List.filter is_live_holder sc.sc_locals in
if sc.sc_node = loop_node then here else here @ go rest
in
go ctx.scopes
let owned_items (ls : local list) : drop_item list =
ls
|> List.filter (fun l -> l.l_class = Owned)
@ -913,9 +1042,11 @@ let rec read_expr (ctx : ctx) (e : Ast.expr) : unit =
| Binary (_, a, b) ->
read_expr ctx a;
read_expr ctx b
| Interp inner -> read_expr ctx inner
| DbStub _ ->
(* trap-capable: the frame needs its drop map here *)
record_drop ctx ~node:e.id ~pos:e.pos ~kind:DLiveMask ~items:(mask_items (live_holders ctx))
| Switch (subject, arms) -> analyze_switch ctx e.id subject arms
(* The root of a place expression is already accounted for by use_place;
what still needs walking are index subexpressions and a non-place base
@ -947,6 +1078,43 @@ and analyze_ctor (ctx : ctx) (cn : string) (fields : (string * Ast.expr) list) :
transfers — that ordering is what makes `f(x, take x)` a
move-while-borrowed rather than a use-after-move. *)
and analyze_call (ctx : ctx) (call_e : Ast.expr) (callee : Ast.expr) (args : Ast.expr list) : unit =
(* haxe-parity Task 4: a variant construction is not a call — it
lowers to NEW + SETF, no CALL instruction — so every place-shaped
payload argument is a ctor-field ESCAPE (analyze_ctor's own rule:
the value is stored into the fresh object, which owns it from
here), never a borrow-for-the-duration-of-the-call. Getting this
wrong is a double free, not an imprecision: a local moved into the
payload would otherwise stay Live and be dropped at scope end on
top of the variant object's own recursive drop. No LIVE-MASK is
recorded either — there is no trap-capable call site to sync a
drop map at. A declared free fn of the same name shadows the
variant (the same flat-table resolution resolve_callee itself
uses). *)
let variant_ctor =
match callee.kind with
| Ident n when not (Types.StringMap.mem n ctx.syms.Types.free_fns) ->
Types.find_variant ctx.syms n
| _ -> None
in
match variant_ctor with
| Some (_, vi) ->
List.iteri
(fun i (fe : Ast.expr) ->
read_expr ctx fe;
match place_of fe with
| None -> ()
| Some p ->
let fname =
match List.nth_opt vi.Types.vi_fields i with
| Some (n, _) -> n
| None -> Printf.sprintf "arg%d" (i + 1)
in
if
transfer ctx p
~what:(Printf.sprintf "cannot be stored in `%s.%s`" vi.Types.vi_name fname)
then record_move ctx p (MvCtorField fname))
args
| None ->
let resolved = resolve_callee ctx callee in
(* receiver *)
let recv =
@ -1130,6 +1298,128 @@ and branch_join_drops (ctx : ctx) ~(node : int) ~(label : string) ~(pos : Ast.po
in
record_drop ctx ~node ~pos ~kind:(DBranchJoin label) ~items
(* haxe-parity Task 3: `switch`'s own arms are alternate flows joining
back together after the switch — exactly what `if`/`else` already
is, generalized from two branches to N (one per arm). Reused, not
reinvented, per the brief's own instruction: each arm is its own
`analyze_block` (so an arm-local owned value that is never moved
still gets its ordinary DScope drop at that arm's own end — nothing
special to write for that half); a diverging arm (every path inside
it returned) drops out of the join exactly like a diverging `if`
branch does; and a value moved in *some* arms but not others gets
`branch_join_drops`'s own JOIN-DROP treatment, called once per kept
arm against the join of every *other* (non-diverging) arm's ending
state — the N-way shape of the same "the branch that kept it drops
it at its own end" rule the module doc above states for two.
The subject is read (`read_expr`, never `transfer`) exactly once,
before any arm runs: it is compared against, never consumed — "the
SUBJECT's ownership — borrowed for the comparison, not consumed" per
this task's own brief. Case values are read the same way; for this
task's scalar/Text subjects they are always literals, so this is a
no-op today and only matters once a union variant tag becomes a real
bound reference (Task 4). *)
and analyze_switch (ctx : ctx) (node : int) (subject : Ast.expr) (arms : Ast.switch_arm list) :
unit =
read_expr ctx subject;
(* haxe-parity Task 4: over a union-typed subject the case "values"
are variant PATTERNS (`case Ok:`, `case Failed(reason):`), not
expressions — never read as such (a pattern's binding names are
unbound on purpose; reading them would be noise at best). The
subject's own union-ness comes from this pass's own expr_ty,
deliberately NOT unwrapped through `?T` (types.ml's subj_union
makes the same call — a `?Union` subject stays on the plain-value
path until Task 6). *)
let subj_union =
match expr_ty ctx subject with
| Some (Scalar n) -> Types.StringMap.find_opt n ctx.syms.Types.unions
| _ -> None
in
(match subj_union with
| Some _ -> ()
| None ->
List.iter (fun (a : Ast.switch_arm) -> List.iter (read_expr ctx) a.Ast.values) arms);
(* A payload pattern's bindings are BORROWS of the subject's own
fields (`case Failed(reason):` reads `reason` straight out of the
variant object via GETF — the subject keeps owning the payload, so
the binding must never be dropped by the arm or the subject's own
drop double-frees). Declared into the arm's own scope, exactly like
a `for` cursor is into its loop's (same Borrowed state, same
l_holds = false); when the subject is a place, the binding's source
place is that place plus the field projection, so moving the
subject out from under a live binding is the ordinary WO-E302. *)
let arm_bindings (a : Ast.switch_arm) : local list =
match subj_union with
| None -> []
| Some u -> (
match a.Ast.values with
| [ { Ast.kind = Ast.Call ({ Ast.kind = Ast.Ident vname; _ }, args); _ } ] -> (
match
List.find_opt (fun (v : Types.variant_info) -> v.Types.vi_name = vname)
u.Types.u_variants
with
| Some vi when List.length args = List.length vi.Types.vi_fields ->
let subj_place = place_of subject in
List.concat
(List.map2
(fun (arg : Ast.expr) (fname, fty) ->
match arg.Ast.kind with
| Ast.Ident bn ->
let src =
match subj_place with
| Some p ->
Some { p with projs = p.projs @ [ PField fname ]; pnode = arg.Ast.id }
| None -> None
in
[ { l_name = bn; l_ty = fty; l_class = oclass_of ctx fty;
l_node = arg.Ast.id; l_pos = arg.Ast.pos; l_holds = false; l_src = src;
l_bkind = AShared; l_state = Borrowed arg.Ast.pos } ]
| _ -> [])
args vi.Types.vi_fields)
| _ -> [])
| _ -> [])
in
let entry = snapshot ctx in
let div0 = ctx.diverged in
(* review fix, Critical 1: walk the *lowering* order (default last),
not raw source order — see Ast.switch_lowering_order's own doc
comment. "ARM<i>" must be the same index emit.ml's own
emit_switch hands the owner tables, or every DScope/JOIN-DROP
lookup below silently misses. *)
let results =
List.mapi
(fun i (a : Ast.switch_arm) ->
restore entry;
ctx.diverged <- div0;
let label = Printf.sprintf "ARM%d" i in
analyze_block ctx ~pre:(arm_bindings a) ~node ~pos:a.Ast.arm_pos ~label a.Ast.body;
(label, a.Ast.arm_pos, snapshot ctx, ctx.diverged))
(Ast.switch_lowering_order arms)
in
let non_diverged = List.filter (fun (_, _, _, d) -> not d) results in
match non_diverged with
| [] ->
(* every arm diverged (or there were no arms at all — a malformed
switch types.ml already reports on): nothing reachable follows,
so — mirroring analyze_stmt's own `If` case, which restores
*some* snapshot purely for hygiene even though it is provably
unobservable — restore the last arm's ending state, if any. *)
(match List.rev results with (_, _, sn, _) :: _ -> restore sn | [] -> ());
ctx.diverged <- true
| (_, _, first_sn, _) :: rest ->
List.iter
(fun (label, pos, sn, _) ->
match List.filter (fun (l, _, _, _) -> l <> label) non_diverged with
| [] -> () (* the only non-diverging arm: nothing else could have moved anything *)
| (_, _, first_other, _) :: rest_other ->
let moved_elsewhere =
List.fold_left (fun acc (_, _, s, _) -> join acc s) first_other rest_other
in
branch_join_drops ctx ~node ~label ~pos ~moving:moved_elsewhere ~other:sn)
non_diverged;
restore (List.fold_left (fun acc (_, _, s, _) -> join acc s) first_sn rest);
ctx.diverged <- div0
and analyze_stmt (ctx : ctx) (s : Ast.stmt) : unit =
match s.s_kind with
| Let { name; ty; value } -> analyze_let ctx s name ty value
@ -1182,16 +1472,19 @@ and analyze_stmt (ctx : ctx) (s : Ast.stmt) : unit =
ctx.diverged <- div0
end
| While { cond; body } ->
ctx.loop_stack <- s.s_id :: ctx.loop_stack;
fixpoint ctx
(fun () ->
read_expr ctx cond;
analyze_block ctx ~node:s.s_id ~pos:s.s_pos ~label:"WHILE" body)
analyze_block ctx ~node:s.s_id ~pos:s.s_pos ~label:"WHILE" body);
ctx.loop_stack <- List.tl ctx.loop_stack
| For { var; iter; body } ->
read_expr ctx iter;
let src = place_of iter in
let item_ty =
match expr_ty ctx iter with Some t -> ( match elem_ty t with Some e -> e | None -> t) | None -> Scalar "Int"
in
ctx.loop_stack <- s.s_id :: ctx.loop_stack;
fixpoint ctx
(fun () ->
push_scope ctx ~node:s.s_id ~pos:s.s_pos ~label:"FOR";
@ -1202,7 +1495,22 @@ and analyze_stmt (ctx : ctx) (s : Ast.stmt) : unit =
l_pos = s.s_pos; l_holds = false; l_src = src; l_bkind = AShared;
l_state = Borrowed s.s_pos };
List.iter (analyze_stmt ctx) body;
pop_scope ctx)
pop_scope ctx);
ctx.loop_stack <- List.tl ctx.loop_stack
| DoWhile { body; cond } ->
(* `do { body } while cond` — body always runs before the condition
is ever consulted, so it is analyzed first; still wrapped in
`fixpoint` for the same reason `while`/`for` are (a *second*
iteration's move state must be joined against the first's, the
brief's own loop rule — see fixpoint's doc comment). *)
ctx.loop_stack <- s.s_id :: ctx.loop_stack;
fixpoint ctx
(fun () ->
analyze_block ctx ~node:s.s_id ~pos:s.s_pos ~label:"DO" body;
read_expr ctx cond);
ctx.loop_stack <- List.tl ctx.loop_stack
| Break -> analyze_break ctx s
| Continue -> analyze_continue ctx s
(* The brief's loop rule: probe the body once with recording off, join
that with the entry state, then analyze for real against the joined
@ -1223,8 +1531,14 @@ and fixpoint (ctx : ctx) (run : unit -> unit) : unit =
restore (join entry after);
ctx.diverged <- div0
and analyze_block (ctx : ctx) ~node ~pos ~label (body : Ast.stmt list) : unit =
(* `~pre` (haxe-parity Task 4): locals to declare into the fresh scope
before its statements run — a payload pattern's bindings, and nothing
else today. Borrows only (l_holds = false), so pop_scope's drop
recording never sees them; every pre-existing call site passes
nothing and is byte-identical. *)
and analyze_block (ctx : ctx) ?(pre = []) ~node ~pos ~label (body : Ast.stmt list) : unit =
push_scope ctx ~node ~pos ~label;
List.iter (declare ctx) pre;
List.iter (analyze_stmt ctx) body;
pop_scope ctx
@ -1360,6 +1674,38 @@ and analyze_return (ctx : ctx) (s : Ast.stmt) (opt : Ast.expr option) : unit =
release_gc ctx ~node:s.s_id ~pos:s.s_pos live;
ctx.diverged <- true
(* haxe-parity Task 2: `break`/`continue` reuse analyze_return's own
drop machinery verbatim, bounded to the nearest enclosing loop
instead of the whole function (live_holders_upto vs. live_holders —
see that function's doc comment) — this is "the one non-trivial bit"
the brief calls out: an owned value still alive in the loop body at
a `break`/`continue` gets its DROP recorded right here, at the jump,
not left to leak. `ctx.diverged <- true` afterward mirrors
analyze_return's own reasoning exactly: the rest of *this* block is
unreachable, and the surrounding if/fixpoint machinery already knows
how to fold that into a branch join or a loop's own entry/exit meet
(the same normalization a `return` inside a loop or an `if` already
gets, unchanged by this task). Outside any loop, `loop_stack` is
empty and nothing is recorded — see that field's own doc comment for
why emit.ml, not this pass, is the actual gate for that case. *)
and analyze_break (ctx : ctx) (s : Ast.stmt) : unit =
(match ctx.loop_stack with
| [] -> ()
| loop_node :: _ ->
let live = live_holders_upto ctx loop_node in
record_drop ctx ~node:s.s_id ~pos:s.s_pos ~kind:DBreak ~items:(owned_items live);
release_gc ctx ~node:s.s_id ~pos:s.s_pos live);
ctx.diverged <- true
and analyze_continue (ctx : ctx) (s : Ast.stmt) : unit =
(match ctx.loop_stack with
| [] -> ()
| loop_node :: _ ->
let live = live_holders_upto ctx loop_node in
record_drop ctx ~node:s.s_id ~pos:s.s_pos ~kind:DContinue ~items:(owned_items live);
release_gc ctx ~node:s.s_id ~pos:s.s_pos live);
ctx.diverged <- true
(* ============================================================
Per-function driver
============================================================ *)
@ -1396,8 +1742,8 @@ let resolve_rc (ctx : ctx) : unit =
let analyze_fn ~(file : string) (syms : Types.symbols) (coll : Diag.Collector.t) (sink : sink)
~(self_class : string option) (m : Ast.method_decl) : unit =
let ctx =
{ file; syms; coll; sink; fn_name = m.name; scopes = []; recording = true; diverged = false;
fn_rcs = []; rc_groups = Hashtbl.create 8; rc_escaped = Hashtbl.create 8;
{ file; syms; coll; sink; fn_name = m.name; scopes = []; loop_stack = []; recording = true;
diverged = false; fn_rcs = []; rc_groups = Hashtbl.create 8; rc_escaped = Hashtbl.create 8;
clobbered = Hashtbl.create 8 }
in
push_scope ctx ~node:m.id ~pos:m.pos ~label:"BODY";
@ -1430,7 +1776,10 @@ let analyze ~(file : string) (prog : Ast.program) (syms : Types.symbols)
| Ast.Class c ->
List.iter (fun m -> analyze_fn ~file syms coll sink ~self_class:(Some c.name) m) c.methods
| Ast.Interface _ -> ()
| Ast.Fn f -> analyze_fn ~file syms coll sink ~self_class:None f)
| Ast.Fn f -> analyze_fn ~file syms coll sink ~self_class:None f
| Ast.Use _ -> ()
| Ast.Const _ -> ()
| Ast.Union _ -> () (* haxe-parity Task 4: no bodies to analyze *))
prog.decls;
(* Source order: sort by position, stably, so entries sharing a
position keep the order the walk produced. Node ids can *not* be

View file

@ -127,6 +127,15 @@ let fail (st : state) (site : Ast.pos) (code : string) (message : string) : 'a =
let syntax_code = Diag.parsing_prefix ^ "01" (* WO-E101: generic syntax error *)
let table_code = Diag.parsing_prefix ^ "02" (* WO-E102: invalid @table(...) configuration *)
(* haxe-parity Task 2: the haxe keyword verdict table's `inline` row —
"adopt (values): const compile-time values; inline *functions*
rejected — optimization is the compiler's job". `const` (below) is
the adopted half; this is the reject half's own diagnostic, cited at
`inline`'s own position, one per bad declaration (parse_program's
existing try/with resyncs past the whole discarded `inline fn ...`
body, same as any other bad top-level declaration). *)
let inline_fn_code = Diag.parsing_prefix ^ "03" (* WO-E103 *)
let unexpected (st : state) (what : string) : 'a =
let p = peek_pos st in
fail st p syntax_code
@ -149,6 +158,19 @@ let expect_ident (st : state) (what : string) : string =
s
| _ -> unexpected st what
(* haxe-parity Task 4: `type` is a legal FIELD name (the sample's own
wire-format key — mcp.wo's `ToolText = { type: Text, ... }` carries
JSON's `type` verbatim), so the three field-NAME positions (a field
declaration, a constructor-literal key, a `.field` access) accept the
keyword and treat it as the plain name "type". Field positions only —
everywhere else `type` stays the declaration keyword it is. *)
let expect_field_name (st : state) (what : string) : string =
match peek st with
| Token.KwType ->
ignore (advance st);
"type"
| _ -> expect_ident st what
(* ---- field/service/policy/on disambiguation --------------------------
Lookahead only: Ident immediately followed by Colon. Same shape rt
@ -205,9 +227,23 @@ let sync_to_next_top_level (st : state) : unit =
decr depth;
ignore (advance st)
| Token.KwType when !depth = 0 && st.pos > start -> continue_ := false
| Token.KwTypedef when !depth = 0 && st.pos > start -> continue_ := false
| Token.KwClass when !depth = 0 && st.pos > start -> continue_ := false
| Token.KwInterface when !depth = 0 && st.pos > start -> continue_ := false
| Token.KwFn when !depth = 0 && st.pos > start -> continue_ := false
| Token.KwUse when !depth = 0 && st.pos > start -> continue_ := false
| Token.KwConst when !depth = 0 && st.pos > start -> continue_ := false
(* KwPub deliberately NOT a sync point (unlike every other top-level
starter above): `pub(read)` (Task 7's field-accessor marker, not
this task's — ast.ml's method_decl.pub doc comment) recurs
*inside* an already-broken class/type body one field at a time,
and making KwPub a stop point would turn one coarse "whole class
discarded" diagnostic into one diagnostic per `pub(read)` field
line. Leaving it out preserves the pre-existing coarse-recovery
behavior there — the brief's own "leave it unparsed" option for
`pub(` — while a genuinely top-level `pub` still needs no sync
help at all: it's handled inline by parse_program on the
error-free path, and recovery only ever runs after a failure. *)
| Token.At when !depth = 0 && st.pos > start -> continue_ := false
| Token.Ident s when !depth = 0 && st.pos > start && is_sync_ident s -> continue_ := false
| _ -> ignore (advance st)
@ -330,7 +366,17 @@ let parse_field_ty (st : state) : Ast.field_ty =
Ast.Map (k, v)
| Token.Ident name ->
ignore (advance st);
Ast.Scalar name
(* haxe-parity Task 4: one qualified segment (`json.Value` — the
sample's own stdlib-reserved type in a record field). Kept as
one dotted Scalar name; whether it resolves is types.ml's
question (is_known_type_name treats a reserved-stdlib head as
UNKNOWN-BUT-RESERVED, same convention as `fs.stat(...)` calls). *)
if peek st = Token.Dot then begin
ignore (advance st);
let member = expect_ident st "qualified type name" in
Ast.Scalar (name ^ "." ^ member)
end
else Ast.Scalar name
| _ -> unexpected st "a field type"
in
if !nullable then Ast.Nullable base_ty else base_ty
@ -394,9 +440,17 @@ let parse_default_expr (st : state) : Ast.default_expr =
end
else Ast.DefaultOpaque (collect_default_tokens st)
let parse_field (st : state) : Ast.field =
(* `~comma_ends` (haxe-parity Task 4): a typedef record body may list
its fields on one line, comma-separated (`typedef HttpResp = {
status: Int, body: Text }` — the sample's own shape), so a depth-0
comma ends the field there exactly like a newline does in a
class/type body; the comma itself is left for the record loop to
consume. Every pre-existing call site passes nothing and keeps the
class-body behavior byte-identical (a comma there is still the same
"unexpected" error as before). *)
let parse_field ?(comma_ends = false) (st : state) : Ast.field =
let pos = peek_pos st in
let name = expect_ident st "field name" in
let name = expect_field_name st "field name" in
expect st Token.Colon "':'";
let ty = parse_field_ty st in
let default = ref None in
@ -412,6 +466,7 @@ let parse_field (st : state) : Ast.field =
| Token.Eq ->
ignore (advance st);
default := Some (parse_default_expr st)
| Token.Comma when comma_ends -> continue_ := false
| Token.Newline | Token.RBrace | Token.Eof -> continue_ := false
| _ -> unexpected st "an annotation, '=', or end of field"
done;
@ -487,6 +542,8 @@ let parse_sig_head (st : state) : sig_head =
Precedence ladder, loosest to tightest (parse_expr is the entry
point; each level's loop is left-associative):
or or (haxe-parity Task 2)
and and (haxe-parity Task 2)
comparison == != < <= > >=
concat ..
additive + -
@ -498,7 +555,11 @@ let parse_sig_head (st : state) : sig_head =
This ordering matches Lua's (concat binds looser than +/-, tighter
than comparison) — see ast.ml's module doc for why `..`/Concat is
this task's own addition, not a straight rt port.
this task's own addition, not a straight rt port. `and`/`or` sit
above comparison per the spec amendment's own words ("or binds
loosest, then and, then comparison") — real keywords (KwAnd/KwOr),
never `&&`/`||`, so `a == 1 and b == 2` parses with no parens: `and`
only ever sees fully-formed comparisons as its operands.
End-of-statement convention mirrors parse_field's: a "simple"
statement (let/assign/return/expr-statement/DbStub) must end at an
@ -650,7 +711,37 @@ let with_no_brace (st : state) (value : bool) (f : unit -> 'a) : 'a =
(* ---- expression parsing -------------------------------------------------- *)
let rec parse_expr (st : state) : Ast.expr = parse_comparison st
let rec parse_expr (st : state) : Ast.expr = parse_or st
and parse_or (st : state) : Ast.expr =
let lhs = ref (parse_and st) in
let continue_ = ref true in
while !continue_ do
match peek st with
| Token.KwOr ->
let pos = peek_pos st in
let id = fresh_id st in
ignore (advance st);
let rhs = parse_and st in
lhs := { Ast.id; pos; kind = Ast.Binary (Ast.Or, !lhs, rhs) }
| _ -> continue_ := false
done;
!lhs
and parse_and (st : state) : Ast.expr =
let lhs = ref (parse_comparison st) in
let continue_ = ref true in
while !continue_ do
match peek st with
| Token.KwAnd ->
let pos = peek_pos st in
let id = fresh_id st in
ignore (advance st);
let rhs = parse_comparison st in
lhs := { Ast.id; pos; kind = Ast.Binary (Ast.And, !lhs, rhs) }
| _ -> continue_ := false
done;
!lhs
and parse_comparison (st : state) : Ast.expr =
let lhs = ref (parse_concat st) in
@ -741,7 +832,7 @@ and parse_postfix (st : state) : Ast.expr =
| Token.Dot ->
let pos = peek_pos st in
ignore (advance st);
let name = expect_ident st "field or method name" in
let name = expect_field_name st "field or method name" in
base := { Ast.id = fresh_id st; pos; kind = Ast.Field (!base, name) }
| Token.LParen ->
let pos = peek_pos st in
@ -791,20 +882,90 @@ and parse_ctor_literal (st : state) : Ast.expr =
let fields = ref [] in
let continue_ = ref (peek st <> Token.RBrace) in
while !continue_ do
let fname = expect_ident st "constructor field name" in
let fname = expect_field_name st "constructor field name" in
expect st Token.Colon "':'";
let fval = parse_expr st in
fields := (fname, fval) :: !fields;
skip_newlines st;
if accept st Token.Comma then skip_newlines st else continue_ := false
if accept st Token.Comma then begin
skip_newlines st;
(* trailing comma before the close (haxe-parity Task 4 — the
sample's own multi-line record literals end `..., }`) *)
if peek st = Token.RBrace then continue_ := false
end
else continue_ := false
done;
skip_newlines st;
expect st Token.RBrace "'}'";
{ Ast.id; pos; kind = Ast.Ctor (name, List.rev !fields) }
(* haxe-parity Task 3: `switch subject { case v1, v2: <stmts> ... default:
<stmts> }` — the sample's own shape (grepped every `switch` site in
docs/examples/log-watcher/*.wo first). The subject is parsed
`no_brace` for the exact reason if/while/for's own conditions are:
`switch res { ... }` must not read `res {` as a constructor literal
swallowing the switch's own body. Arms have no brace of their own
(the sample never wraps a case body in `{ }`) — [parse_switch_arm_body]
is [parse_block]'s loop with `case`/`default`/`}` as its stop set
instead of `}` alone, and no brace to expect/consume. `default` is
grammar-optional here; whether it's *required* depends on the
subject's type (scalar/Text: yes; a union: only if a variant is
missing — Task 4's territory), which is a typecheck-time question
(WO-E208), not a parse-time one. *)
and parse_switch_arm_body (st : state) : Ast.stmt list =
let stmts = ref [] in
let continue_ = ref true in
while !continue_ do
skip_newlines st;
match peek st with
| Token.KwCase | Token.KwDefault | Token.RBrace -> continue_ := false
| Token.Eof -> fail st (peek_pos st) syntax_code "unexpected end of input inside switch arm"
| _ -> (
try stmts := parse_stmt st :: !stmts
with Parse_error -> sync_to_next_stmt st)
done;
List.rev !stmts
and parse_switch_expr (st : state) : Ast.expr =
let pos = peek_pos st in
let id = fresh_id st in
ignore (advance st);
(* 'switch' *)
let subject = parse_expr_no_brace st in
expect st Token.LBrace "'{' to open switch body";
let arms = ref [] in
let continue_ = ref true in
while !continue_ do
skip_newlines st;
match peek st with
| Token.RBrace ->
ignore (advance st);
continue_ := false
| Token.Eof -> fail st (peek_pos st) syntax_code "unexpected end of input inside switch body"
| Token.KwCase ->
let arm_pos = peek_pos st in
ignore (advance st);
let values = ref [ parse_expr st ] in
while accept st Token.Comma do
values := parse_expr st :: !values
done;
expect st Token.Colon "':' after switch case value(s)";
let body = parse_switch_arm_body st in
arms := { Ast.arm_pos; values = List.rev !values; is_default = false; body } :: !arms
| Token.KwDefault ->
let arm_pos = peek_pos st in
ignore (advance st);
expect st Token.Colon "':' after `default`";
let body = parse_switch_arm_body st in
arms := { Ast.arm_pos; values = []; is_default = true; body } :: !arms
| _ -> unexpected st "`case`, `default`, or '}' in switch body"
done;
{ Ast.id; pos; kind = Ast.Switch (subject, List.rev !arms) }
and parse_primary (st : state) : Ast.expr =
match peek st with
| k when is_select_trigger k -> parse_dbstub_expr st
| Token.KwSwitch -> parse_switch_expr st
| Token.Int n ->
let pos = peek_pos st in
let id = fresh_id st in
@ -815,6 +976,10 @@ and parse_primary (st : state) : Ast.expr =
let id = fresh_id st in
ignore (advance st);
{ Ast.id; pos; kind = Ast.StrLit s }
| Token.InterpStr segs ->
let pos = peek_pos st in
ignore (advance st);
desugar_interp st pos segs
| Token.KwTrue ->
let pos = peek_pos st in
let id = fresh_id st in
@ -841,6 +1006,67 @@ and parse_primary (st : state) : Ast.expr =
{ Ast.id; pos; kind = Ast.Ident s }
| _ -> unexpected st "an expression"
(* haxe-parity Task 2: desugars one interpolated string's segments into a
`..`/Concat chain of StrLit (text) and Interp (embedded expression)
nodes — the "desugars at parse time to concatenation" the brief
names. Each `Token.SExpr raw` segment is a full expression's *raw
source*, captured verbatim by the lexer (token.ml/lexer.ml's own doc
comments) — re-tokenized and re-parsed here via a fresh, nested
lexer/parser state over just that substring. Every generated node
(StrLit/Interp/the Concat spine) shares the outer string literal's
own single position: this AST has no source *ranges* (ast.ml's
module doc), and per-segment positions would need the lexer to track
an offset into the interpolation that nothing downstream needs today.
Known, disclosed imprecision: a malformed `${...}` expression's own
error therefore reports at the whole string's start, not the
sub-expression's real column — acceptable since the sub-parse still
raises a real, correctly-coded diagnostic, just at a coarser site.
Review fix (Important, post-Task-2): a genuine sub-parse failure
(`"${1 +}"`, not just trailing garbage — `"${1 2}"`) used to add its
OWN diagnostic straight to `st.collector` from inside `parse_expr
sub_st`, at `sub_st`'s own uncorrected line/col (that lexer counts
from 1:1 over the raw substring, so the reported position landed on
some unrelated line of the *real* file), and then let `Parse_error`
propagate straight past this function, skipping the "malformed
${...}" framing entirely. The sub-lex/sub-parse now runs against a
private, throwaway collector — nothing it reports (a lex error, a
parse error, or reaching a non-`Eof` leftover) ever touches the real
collector — so every failure inside collapses to exactly the one
diagnostic below, at the outer string's own position. *)
and desugar_interp (st : state) (pos : Ast.pos) (segs : Token.str_part list) : Ast.expr =
let mk_str s = { Ast.id = fresh_id st; pos; kind = Ast.StrLit s } in
let mk_interp inner = { Ast.id = fresh_id st; pos; kind = Ast.Interp inner } in
let parse_segment_expr (raw : string) : Ast.expr =
let sub_collector = Diag.Collector.create () in
let sub_toks = Lexer.tokenize sub_collector ~file:st.file raw in
let sub_st = make sub_collector ~file:st.file sub_toks in
let parsed =
try
let e = parse_expr sub_st in
if peek sub_st = Token.Eof && not (Diag.Collector.has_error sub_collector) then Some e
else None
with Parse_error -> None
in
match parsed with
| Some e -> e
| None -> fail st pos syntax_code "malformed \"${...}\" interpolation expression"
in
let parts =
List.filter_map
(function
| Token.SText "" -> None
| Token.SText s -> Some (mk_str s)
| Token.SExpr raw -> Some (mk_interp (parse_segment_expr raw)))
segs
in
match parts with
| [] -> mk_str ""
| first :: rest ->
List.fold_left
(fun acc e -> { Ast.id = fresh_id st; pos; kind = Ast.Binary (Ast.Concat, acc, e) })
first rest
(* ---- statement parsing --------------------------------------------------- *)
and parse_block (st : state) : Ast.stmt list =
@ -925,6 +1151,42 @@ and parse_return_stmt (st : state) : Ast.stmt =
end_of_stmt st;
{ Ast.s_id = id; s_pos = pos; s_kind = Ast.Return value }
(* haxe-parity Task 2: `break`/`continue`. Whether either actually sits
inside a loop is not checked here (this parser has no loop-nesting
state, unlike `state.no_brace`) — emit.ml is the gate, exactly the
existing WO-E403 convention: a construct the emitter has no legal
jump target for is a diagnostic, not invented bytecode. *)
and parse_break_stmt (st : state) : Ast.stmt =
let pos = peek_pos st in
let id = fresh_id st in
ignore (advance st);
end_of_stmt st;
{ Ast.s_id = id; s_pos = pos; s_kind = Ast.Break }
and parse_continue_stmt (st : state) : Ast.stmt =
let pos = peek_pos st in
let id = fresh_id st in
ignore (advance st);
end_of_stmt st;
{ Ast.s_id = id; s_pos = pos; s_kind = Ast.Continue }
(* `do { body } while cond` — no `parse_expr_no_brace` needed for `cond`:
unlike `if`/`while`, nothing braced follows it (the statement just
ends), so a constructor literal there is never ambiguous with a
trailing block, the same reasoning `parse_return_stmt`'s value
already relies on. *)
and parse_do_while_stmt (st : state) : Ast.stmt =
let pos = peek_pos st in
let id = fresh_id st in
ignore (advance st);
(* 'do' *)
let body = parse_block st in
skip_newlines st;
expect st Token.KwWhile "`while` after `do { ... }`";
let cond = parse_expr st in
end_of_stmt st;
{ Ast.s_id = id; s_pos = pos; s_kind = Ast.DoWhile { body; cond } }
and parse_stmt (st : state) : Ast.stmt =
match peek st with
| k when is_insert_trigger k ->
@ -938,6 +1200,9 @@ and parse_stmt (st : state) : Ast.stmt =
| Token.KwWhile -> parse_while_stmt st
| Token.KwFor -> parse_for_stmt st
| Token.KwReturn -> parse_return_stmt st
| Token.KwBreak -> parse_break_stmt st
| Token.KwContinue -> parse_continue_stmt st
| Token.KwDo -> parse_do_while_stmt st
| _ ->
let pos = peek_pos st in
let id = fresh_id st in
@ -957,15 +1222,20 @@ and parse_stmt (st : state) : Ast.stmt =
and [looks_like_ctor]. *)
and parse_expr_no_brace (st : state) : Ast.expr = with_no_brace st true (fun () -> parse_expr st)
let parse_method (st : state) : Ast.method_decl =
(* `pub` (haxe-parity Task 1, modules) defaults to false: a class body's
own methods always call this with no `~pub` argument (method-level
visibility is a different, not-yet-designed question — see
ast.ml's method_decl.pub doc comment), so this default is what keeps
every existing call site's behavior byte-identical. *)
let parse_method ?(pub = false) (st : state) : Ast.method_decl =
let h = parse_sig_head st in
let body = parse_block st in
{ Ast.id = h.s_id; pos = h.s_pos; name = h.s_name; params = h.s_params; ret = h.s_ret; body }
{ Ast.id = h.s_id; pos = h.s_pos; name = h.s_name; params = h.s_params; ret = h.s_ret; body; pub }
(* A free top-level function is grammatically identical to a class
method (signature + brace-delimited body span) — Task 6's brief
("free-fn tables") is why this exists as real grammar. *)
let parse_fn_decl (st : state) : Ast.method_decl = parse_method st
let parse_fn_decl ~(pub : bool) (st : state) : Ast.method_decl = parse_method ~pub st
(* Interface signatures have no body: the line ends at a Newline (which
is consumed) or at the interface's own closing brace / EOF (left for
@ -1040,6 +1310,51 @@ let skip_on_block (st : state) : unit =
| _ -> ignore (advance st)
done
(* ---- const declaration (haxe-parity Task 2) -----------------------------
`const NAME = <literal>` — top-level or (bare, no `static`) class-level.
The brief's own wording is "= literal", not "= expr": restricted here
to Int (optionally `-`-prefixed, folded directly into the literal —
no `Unary` wrapper needed for a compile-time value), Text, or Bool.
Resolved by a dedicated post-parse substitution pass (see `parse`,
below) rather than threaded through typecheck/owner/emit as a new
kind of name. *)
let parse_const_literal (st : state) : Ast.expr =
let pos = peek_pos st in
let id = fresh_id st in
match peek st with
| Token.Int n ->
ignore (advance st);
{ Ast.id; pos; kind = Ast.IntLit n }
| Token.Dash -> (
ignore (advance st);
match peek st with
| Token.Int n ->
ignore (advance st);
{ Ast.id; pos; kind = Ast.IntLit (-n) }
| _ -> unexpected st "an integer literal after '-'")
| Token.Str s ->
ignore (advance st);
{ Ast.id; pos; kind = Ast.StrLit s }
| Token.KwTrue ->
ignore (advance st);
{ Ast.id; pos; kind = Ast.BoolLit true }
| Token.KwFalse ->
ignore (advance st);
{ Ast.id; pos; kind = Ast.BoolLit false }
| _ -> unexpected st "a literal (Int, Text, or Bool)"
let parse_const_decl (st : state) : Ast.const_decl =
let pos = peek_pos st in
let id = fresh_id st in
ignore (advance st);
(* 'const' *)
let name = expect_ident st "const name" in
expect st Token.Eq "'=' in const declaration";
let value = parse_const_literal st in
end_of_stmt st;
{ Ast.id; pos; name; value }
(* ---- class / type declaration -------------------------------------------
Task 4 brief: "class Name { ... } and type Name { ... } — identical
@ -1048,7 +1363,7 @@ let skip_on_block (st : state) : unit =
skip-discarded) — so both keywords get the same body loop here,
`is_class` recorded purely as data for later stages, never gating
what's parsed. *)
let parse_class_or_type (st : state) (ann : type_annotations) : Ast.class_decl =
let parse_class_or_type ?(pub = false) (st : state) (ann : type_annotations) : Ast.class_decl =
let pos = peek_pos st in
let is_class = peek st = Token.KwClass in
if is_class then ignore (advance st) else expect st Token.KwType "`type` or `class`";
@ -1057,6 +1372,7 @@ let parse_class_or_type (st : state) (ann : type_annotations) : Ast.class_decl =
let id = fresh_id st in
let fields = ref [] in
let methods = ref [] in
let consts = ref [] in
let continue_ = ref true in
while !continue_ do
skip_newlines st;
@ -1067,6 +1383,13 @@ let parse_class_or_type (st : state) (ann : type_annotations) : Ast.class_decl =
| Token.Eof ->
fail st (peek_pos st) syntax_code "unexpected end of input inside type/class body"
| Token.KwFn -> methods := parse_method st :: !methods
(* bare `const` only — `static const` (Task 7's `static`) is not
recognized here at all: `static` lexes as a plain Ident, matches
none of this loop's arms (not looks_like_field: the next token is
`const`, not a Colon), and falls through to the same clean
"expected a field, method, or ..." error every other unrecognized
class-body construct gets — no half-swallow, per the brief. *)
| Token.KwConst -> consts := parse_const_decl st :: !consts
(* looks_like_field MUST be checked before is_sync_ident: `on`/
`service`/`policy` are plain Idents here (Task 3 deliberately kept
them as usable identifiers, unlike rt where they're real keywords
@ -1085,19 +1408,120 @@ let parse_class_or_type (st : state) (ann : type_annotations) : Ast.class_decl =
pos;
name;
is_class;
is_record = false;
is_gc = ann.is_gc;
table = ann.table;
fields = List.rev !fields;
methods = List.rev !methods;
consts = List.rev !consts;
pub;
}
(* ---- typedef record declaration (haxe-parity Task 4) ---------------------
`typedef Name = { field: Type [= default] [, ...] ?opt: Type ... }` —
a structural record alias. Fields only (no methods, no consts, no
service/policy/on leniency — a record is pure data shape); separators
are newlines OR commas (the sample writes both: multi-line SupConfig,
single-line HttpResp). A `?` prefixing the field NAME (`?detections:
Text`, the Haxe `@:optional` spelling) desugars to the field typed
`?T` — "?fields land as nullable-by-shape" (the task brief's own
words): one representation, `Nullable`, whether the `?` was written
on the name or on the type, so Task 6's forced-handling work has a
single shape to tighten. *)
let parse_record_decl ?(pub = false) (st : state) : Ast.class_decl =
let pos = peek_pos st in
ignore (advance st);
(* 'typedef' *)
let name = expect_ident st "typedef name" in
expect st Token.Eq "'=' in typedef declaration";
expect st Token.LBrace "'{' to open typedef record body";
let id = fresh_id st in
let fields = ref [] in
let continue_ = ref true in
while !continue_ do
skip_newlines st;
match peek st with
| Token.RBrace ->
ignore (advance st);
continue_ := false
| Token.Eof -> fail st (peek_pos st) syntax_code "unexpected end of input inside typedef body"
| _ ->
let optional = accept st Token.Question in
let f = parse_field ~comma_ends:true st in
let f =
if optional then
match f.Ast.ty with
| Ast.Nullable _ -> f (* `?opt: ?T` — already nullable, don't double-wrap *)
| ty -> { f with Ast.ty = Ast.Nullable ty }
else f
in
fields := f :: !fields;
ignore (accept st Token.Comma)
done;
{
Ast.id;
pos;
name;
is_class = false;
is_record = true;
is_gc = false;
table = None;
fields = List.rev !fields;
methods = [];
consts = [];
pub;
}
(* ---- union declaration (haxe-parity Task 4) -------------------------------
`type Name = V1 | V2 | V3(field: Type, ...)` — a tagged union,
sharing the `type` keyword with the struct form (`type Name { ... }`)
and disambiguated by the token after the name (`=` vs `{`, decided by
parse_program's two-token lookahead before either parser runs). A
variant's payload reuses the field grammar's `name: Type` pairs,
comma-separated inside its parens; a newline is allowed after a `|`
(so a long union can wrap) but the declaration otherwise ends the way
a `use`/`let` line does (end_of_stmt). *)
let parse_union_decl ?(pub = false) (st : state) : Ast.union_decl =
let pos = peek_pos st in
ignore (advance st);
(* 'type' *)
let name = expect_ident st "union name" in
let id = fresh_id st in
expect st Token.Eq "'=' in union declaration";
let parse_variant () : Ast.variant_decl =
let v_pos = peek_pos st in
let v_name = expect_ident st "variant name" in
let v_fields = ref [] in
if accept st Token.LParen then begin
let more = ref (peek st <> Token.RParen) in
while !more do
let fname = expect_ident st "payload field name" in
expect st Token.Colon "':'";
let fty = parse_field_ty st in
v_fields := (fname, fty) :: !v_fields;
if not (accept st Token.Comma) then more := false
done;
expect st Token.RParen "')'"
end;
{ Ast.v_pos; v_name; v_fields = List.rev !v_fields }
in
let variants = ref [ parse_variant () ] in
while accept st Token.Pipe do
skip_newlines st;
variants := parse_variant () :: !variants
done;
end_of_stmt st;
{ Ast.id; pos; name; variants = List.rev !variants; pub }
(* ---- interface declaration ----------------------------------------------
Signatures only — no fields, no bodies (Task 4 brief: "interface Name
{ fn sig... } (signatures only)"). Anything other than `fn` inside an
interface body is a parse error; interfaces don't get the
service/policy/on leniency class/type bodies get. *)
let parse_interface (st : state) : Ast.interface_decl =
let parse_interface ?(pub = false) (st : state) : Ast.interface_decl =
let pos = peek_pos st in
expect st Token.KwInterface "`interface`";
let name = expect_ident st "interface name" in
@ -1115,7 +1539,31 @@ let parse_interface (st : state) : Ast.interface_decl =
| Token.KwFn -> methods := parse_iface_sig st :: !methods
| _ -> unexpected st "a method signature (`fn ...`)"
done;
{ Ast.id; pos; name; methods = List.rev !methods }
{ Ast.id; pos; name; methods = List.rev !methods; pub }
(* ---- use declaration (haxe-parity Task 1, modules) ----------------------
`use fs` (a reserved stdlib namespace) or `use shared/util` (a
project-relative path — slash-separated directory segments naming
another discovered module's directory). Which of those two a given
path actually is, and whether it resolves at all, is the resolver's
job (types.ml) — this only demands at least one identifier segment,
with `/`-separated continuations, ended the same way a `let`/return
statement is (`end_of_stmt`: optional `;`, then newline/EOF — there
is no enclosing block at top level, but end_of_stmt's RBrace arm is
harmless dead code here, never reached). *)
let parse_use_decl (st : state) : Ast.use_decl =
let pos = peek_pos st in
let id = fresh_id st in
ignore (advance st);
(* 'use' *)
let first = expect_ident st "module name" in
let segments = ref [ first ] in
while accept st Token.Slash do
segments := expect_ident st "module path segment" :: !segments
done;
end_of_stmt st;
{ Ast.id; pos; segments = List.rev !segments }
(* ---- top-level program ---------------------------------------------------
@ -1124,6 +1572,15 @@ let parse_interface (st : state) : Ast.interface_decl =
try/with: on Parse_error (already reported at its raise site, see
[fail]), sync to the next top-level construct and keep going —
exactly one diagnostic per broken declaration, never a cascade. *)
(* `type Name = ...` is a union; `type Name { ... }` stays the struct
form — two-token lookahead past the name (haxe-parity Task 4),
mirroring looks_like_ctor's identifier-then-brace trick one token
further out. Anything else after the name falls to
parse_class_or_type's own "expected '{'" error, unchanged. *)
let looks_like_union (st : state) : bool =
(match (tok_at st (st.pos + 1)).kind with Token.Ident _ -> true | _ -> false)
&& (tok_at st (st.pos + 2)).kind = Token.Eq
let parse_program (st : state) : Ast.program =
let decls = ref [] in
let continue_ = ref true in
@ -1133,10 +1590,40 @@ let parse_program (st : state) : Ast.program =
else begin
(try
match peek st with
| Token.KwType when looks_like_union st ->
decls := Ast.Union (parse_union_decl st) :: !decls
| Token.KwType | Token.KwClass ->
decls := Ast.Class (parse_class_or_type st no_annotations) :: !decls
| Token.KwTypedef -> decls := Ast.Class (parse_record_decl st) :: !decls
| Token.KwInterface -> decls := Ast.Interface (parse_interface st) :: !decls
| Token.KwFn -> decls := Ast.Fn (parse_fn_decl st) :: !decls
| Token.KwFn -> decls := Ast.Fn (parse_fn_decl ~pub:false st) :: !decls
| Token.KwUse -> decls := Ast.Use (parse_use_decl st) :: !decls
| Token.KwConst -> decls := Ast.Const (parse_const_decl st) :: !decls
| Token.KwInline ->
let ipos = peek_pos st in
ignore (advance st);
(match peek st with
| Token.KwFn ->
fail st ipos inline_fn_code
"`inline fn` is rejected — optimization is the compiler's job (const values are \
the adopted half of this row)"
| _ -> unexpected st "`fn` after `inline`")
| Token.KwPub -> (
ignore (advance st);
(* consume 'pub'; a stray '(' here (`pub(read)` at top level —
Task 7's field-accessor marker, not this task's) falls
through to the same clean "expected ... after `pub`" error
as any other unrecognized token, rather than being
half-parsed. *)
match peek st with
| Token.KwType when looks_like_union st ->
decls := Ast.Union (parse_union_decl ~pub:true st) :: !decls
| Token.KwType | Token.KwClass ->
decls := Ast.Class (parse_class_or_type ~pub:true st no_annotations) :: !decls
| Token.KwTypedef -> decls := Ast.Class (parse_record_decl ~pub:true st) :: !decls
| Token.KwInterface -> decls := Ast.Interface (parse_interface ~pub:true st) :: !decls
| Token.KwFn -> decls := Ast.Fn (parse_fn_decl ~pub:true st) :: !decls
| _ -> unexpected st "`type`, `class`, `interface`, or `fn` after `pub`")
| Token.At ->
let ann = parse_type_annotations st in
skip_newlines st;
@ -1150,6 +1637,134 @@ let parse_program (st : state) : Ast.program =
done;
{ Ast.decls = List.rev !decls }
(* ---- const substitution (haxe-parity Task 2) ----------------------------
Runs once, over the whole freshly-parsed program, right before `parse`
returns it. Replaces every unshadowed `Ident NAME` with the literal
expr NAME's `const` declared, so typecheck/owner/emit see a plain
literal and need zero const-specific code anywhere downstream — the
same "desugar early, touch nothing later" shape string interpolation
already uses in this file. Scope-aware exactly like types.ml's own
local-shadows-a-`use`-alias fix (Task 1 review): a local, parameter,
`for` variable, or `self` binding of the same name always wins over a
const of that name, so `fn f(CHUNK: Int) { return CHUNK }` next to a
top-level `const CHUNK = 65536` still returns the parameter, not
65536. A const's own value is grammar-restricted to a literal
(parse_const_literal) — never an `Ident` — so one const's value can
never need substitution itself; no ordering/cycle question arises. *)
module StringMap = Map.Make (String)
module StringSet = Set.Make (String)
let rec subst_expr (consts : Ast.expr StringMap.t) (bound : StringSet.t) (e : Ast.expr) : Ast.expr =
match e.Ast.kind with
| Ast.IntLit _ | Ast.StrLit _ | Ast.BoolLit _ | Ast.DbStub _ -> e
| Ast.Ident name ->
if StringSet.mem name bound then e
else ( match StringMap.find_opt name consts with Some v -> { e with Ast.kind = v.Ast.kind } | None -> e)
| Ast.Field (base, fname) -> { e with Ast.kind = Ast.Field (subst_expr consts bound base, fname) }
| Ast.Index (base, idx) ->
{ e with Ast.kind = Ast.Index (subst_expr consts bound base, subst_expr consts bound idx) }
| Ast.Call (callee, args) ->
{ e with Ast.kind = Ast.Call (subst_expr consts bound callee, List.map (subst_expr consts bound) args) }
| Ast.Unary (op, operand) -> { e with Ast.kind = Ast.Unary (op, subst_expr consts bound operand) }
| Ast.Binary (op, l, r) ->
{ e with Ast.kind = Ast.Binary (op, subst_expr consts bound l, subst_expr consts bound r) }
| Ast.Ctor (cn, fields) ->
{ e with Ast.kind = Ast.Ctor (cn, List.map (fun (n, v) -> (n, subst_expr consts bound v)) fields) }
| Ast.Interp inner -> { e with Ast.kind = Ast.Interp (subst_expr consts bound inner) }
| Ast.Switch (subject, arms) ->
{ e with
Ast.kind =
Ast.Switch
( subst_expr consts bound subject,
List.map
(fun (a : Ast.switch_arm) ->
{ a with
Ast.values = List.map (subst_expr consts bound) a.Ast.values;
body = subst_block consts bound a.Ast.body;
})
arms )
}
(* `let` extends the rest of *this* block only, exactly like types.ml's
walk_stmt/walk_block: a nested block's own `let`s never leak back out
to the caller's bound set.
`subst_expr` now reaches into `Switch`'s own `stmt list` arm bodies
(haxe-parity Task 3), so it and `subst_block`/`subst_stmt` are one
`and`-chain from here on, not two separate `let rec` groups — the
same merge ast.ml's own `expr`/`stmt` needed for the same reason. *)
and subst_block (consts : Ast.expr StringMap.t) (bound : StringSet.t) (body : Ast.stmt list) :
Ast.stmt list =
let bound_ref = ref bound in
List.map
(fun (s : Ast.stmt) ->
let s' = subst_stmt consts !bound_ref s in
(match s.Ast.s_kind with
| Ast.Let { name; _ } -> bound_ref := StringSet.add name !bound_ref
| _ -> ());
s')
body
and subst_stmt (consts : Ast.expr StringMap.t) (bound : StringSet.t) (s : Ast.stmt) : Ast.stmt =
let e = subst_expr consts bound in
match s.Ast.s_kind with
| Ast.Let { name; ty; value } -> { s with Ast.s_kind = Ast.Let { name; ty; value = e value } }
| Ast.Assign { target; value } -> { s with Ast.s_kind = Ast.Assign { target = e target; value = e value } }
| Ast.If { cond; then_body; else_body } ->
{ s with
Ast.s_kind =
Ast.If
{ cond = e cond;
then_body = subst_block consts bound then_body;
else_body = Option.map (fun (p, b) -> (p, subst_block consts bound b)) else_body
}
}
| Ast.While { cond; body } ->
{ s with Ast.s_kind = Ast.While { cond = e cond; body = subst_block consts bound body } }
| Ast.For { var; iter; body } ->
let bound' = StringSet.add var bound in
{ s with Ast.s_kind = Ast.For { var; iter = e iter; body = subst_block consts bound' body } }
| Ast.Return opt -> { s with Ast.s_kind = Ast.Return (Option.map e opt) }
| Ast.ExprStmt ex -> { s with Ast.s_kind = Ast.ExprStmt (e ex) }
| Ast.Break | Ast.Continue -> s
| Ast.DoWhile { body; cond } ->
{ s with Ast.s_kind = Ast.DoWhile { body = subst_block consts bound body; cond = e cond } }
let params_bound (base : StringSet.t) (params : Ast.param list) : StringSet.t =
List.fold_left (fun acc (p : Ast.param) -> StringSet.add p.Ast.name acc) base params
let const_map (consts : Ast.const_decl list) : Ast.expr StringMap.t =
List.fold_left (fun acc (c : Ast.const_decl) -> StringMap.add c.Ast.name c.Ast.value acc) StringMap.empty consts
let subst_consts (prog : Ast.program) : Ast.program =
let top_consts =
const_map (List.filter_map (function Ast.Const c -> Some c | _ -> None) prog.Ast.decls)
in
let decls' =
List.map
(function
| Ast.Class c ->
(* class consts win over top-level ones on a name collision --
innermost scope wins, matching how a param/local also
outranks either. *)
let merged = StringMap.fold StringMap.add (const_map c.Ast.consts) top_consts in
let methods' =
List.map
(fun (m : Ast.method_decl) ->
let bound = params_bound (StringSet.singleton "self") m.Ast.params in
{ m with Ast.body = subst_block merged bound m.Ast.body })
c.Ast.methods
in
Ast.Class { c with Ast.methods = methods' }
| Ast.Fn fn ->
let bound = params_bound StringSet.empty fn.Ast.params in
Ast.Fn { fn with Ast.body = subst_block top_consts bound fn.Ast.body }
| (Ast.Interface _ | Ast.Use _ | Ast.Const _ | Ast.Union _) as d -> d)
prog.Ast.decls
in
{ Ast.decls = decls' }
let parse (collector : Diag.Collector.t) ~(file : string) (toks : Token.t list) : Ast.program =
let st = make collector ~file toks in
parse_program st
subst_consts (parse_program st)

View file

@ -20,11 +20,25 @@
(CLAUDE.md gotcha — this is the whole reason Task 3 exists as a
from-scratch lexer rather than a copy of rt's). *)
type str_part =
| SText of string (* literal text, escapes already applied *)
| SExpr of string (* raw, unlexed source of one `${...}`'s body *)
type kind =
(* literals *)
| Ident of string
| Int of int
| Str of string
(* haxe-parity Task 2: a string literal containing at least one
`${expr}` interpolation. Alternating text/expr segments, in source
order; SExpr carries the *raw, unlexed* source text between the
`${` and its matching `}` (nested braces/strings skipped verbatim
by the lexer's own scan) -- the parser re-tokenizes/re-parses it as
a real expression, which is where "desugars at parse time to
concatenation" actually happens (ast.ml/parser.ml). A plain string
with no `${` never produces this -- it still lexes as a bare Str,
byte-identical to every pre-existing fixture. *)
| InterpStr of str_part list
(* milestone-1 keywords *)
| KwType
| KwClass
@ -41,6 +55,41 @@ type kind =
| KwIn
| KwTrue
| KwFalse
(* haxe-parity Task 1 (modules): `use <path>` top-level import and the
`pub` visibility marker on class/interface/fn declarations. Real
keywords, not positionally-recognized idents like insert/select or
ref/multi/map -- neither name is used as an identifier anywhere in
the existing corpus/fixtures, so there is no rt-parity or
field-name collision to dodge (see lexer.ml's module doc for why
those other names stayed idents). *)
| KwUse
| KwPub
(* haxe-parity Task 2 (small control surface): break/continue/do-while,
const values, and/or booleans, and inline-fn rejection (the haxe
verdict table's own row: "const compile-time values; inline
*functions* rejected"). All real keywords -- none collides with an
existing corpus identifier (grepped before adding, same discipline
Task 1 used for use/pub). *)
| KwBreak
| KwContinue
| KwDo
| KwConst
| KwAnd
| KwOr
| KwInline
(* haxe-parity Task 3: `switch`/`case`/`default` — real keywords (none
collides with an existing corpus/sample identifier, grepped first,
same discipline Tasks 1/2 used for use/pub/break/etc.). *)
| KwSwitch
| KwCase
| KwDefault
(* haxe-parity Task 4: `typedef Name = { ... }` structural records. A
real keyword (grepped the corpus/sample first, same discipline as
every keyword above — `typedef` appears only as this declaration's
own leading word, never as an identifier). Union declarations reuse
the existing KwType (`type Name = A | B` vs. the struct form
`type Name { ... }` — disambiguated by the token after the name). *)
| KwTypedef
(* uppercase-only SQL-layer stubs (Task 5 parses these into a DbStub
span); lowercase "insert"/"select" are plain Ident, never these. *)
| KwInsert

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,9 @@
-- haxe-parity Task 1 (modules): `use fs` is declared but this file
-- never calls anything through the `fs` alias -- WO-W202. A warning,
-- not an error: the bare `woc <path>` exit-code contract (0 clean, 1
-- diagnostics-*with-an-error*-reported) means this still exits 0.
use fs
fn main() {
print("hello")
}

View file

@ -0,0 +1,3 @@
-- Hotfix (multi-file double-report): see b.wo and runner.ml's own
-- "multifile single-report" test block for what this pins.
class Item { n: Int }

View file

@ -0,0 +1,9 @@
-- `it.price` is the one real bug: Item (a.wo) has no `price` field,
-- only `n`. Before the hotfix, this body-level WO-E202 check re-ran
-- once per OTHER discovered file too (a.wo's own pass here), so a
-- 2-file program reported it twice -- once correctly at b.wo, once
-- phantom-stamped with a.wo's path at the same (nonexistent) line/col.
fn main() {
let it = Item{n:1}
print_int(it.price)
}

File diff suppressed because it is too large Load diff

View file

@ -47,13 +47,64 @@ claiming ownership of that work.
## Dead-code register
Ten `WO-E2xx` codes are declared as named constants in `types.ml` with no
call site anywhere in the front end — `grep '~code:'` finds exactly five
**Update (haxe-parity Task 1, modules):** `WO-E210` below has since been
wired — the module concept it was blocked on now exists
(`compiler/src/types.ml`'s `check_modules`) — so it is no longer one of the
ten; it has moved up into `docs/plan/oop-vm/01-error-catalog.md`'s main
table. The table below is left as this doc's own historical record of the
state Task 6b found, not edited to match current reality (same convention
this doc already uses elsewhere for its "historical record" blocks).
**Update (hotfix, 2026-08-11):** `WO-E209` below has also since been
wired — a real bug forced it: `print(7)` compiled clean and segfaulted
`wovm` (`print` wants a `Text`, a heap-string pointer; a bare `Int` is a
plain int64 register, which the VM's `str_check` dereferenced as a wild
pointer with no runtime tag to catch it first). It too is no longer one
of the ten; it has moved up into `docs/plan/oop-vm/01-error-catalog.md`'s
main table. The check (`compiler/src/types.ml`'s `check_builtin_call` and
`confident_typ`) covers exactly the arity-and-signature gap the row below
describes, but conservatively: an argument is only checked against a
builtin's expected type when it is *confidently* known — a literal,
`self`, a parameter's or class field's declared type, or a `let` whose
value traces back to one of those — never a guess, so an unresolved name
or an UNKNOWN-BUT-RESERVED stdlib call result stays unchecked rather than
risk a false positive. Same convention as the `WO-E210` note above: the
table below is left unedited.
**Update (haxe-parity Task 2, 2026-08-11):** `WO-E201` below has also
since been wired — the row's own gap ("`Binary`/`Unary` in `types.ml`
don't check operand types at all") is exactly what stayed true for
every operator *except* the two Task 2 added: `and`/`or` are `Bool`-only
by the language's own design (no truthiness), and their operands *are*
now checked, via the same "confident-type, stay silent when underivable"
contract `WO-E209` uses. Every other `Binary`/`Unary` operator (the
arithmetic ladder, comparisons' own operand types) remains exactly as
unchecked as this row describes — this is a new, narrow call site, not
a general fix of the row's own gap. It too has moved up into
`docs/plan/oop-vm/01-error-catalog.md`'s main table. Same convention as
the notes above: the table below is left unedited.
**Update (haxe-parity Task 3, 2026-08-12):** `WO-E208` below has also
since been wired — the row's own blocker ("no `switch` keyword exists
in `token.ml`/`lexer.ml`/`parser.ml` yet") is exactly what haxe-parity
Task 3 closed. The check (`compiler/src/types.ml`'s `typecheck_switch`)
is unconditional today, for every subject: no union type exists in
`typ` yet, so "exhaustiveness over a union, missing `default` only
when a variant is uncovered" — the row's own description — is not yet
a distinct code path, just the same E208 Task 4 will branch ahead of
(a `TUnion` arm, not a second check) once tagged unions land. `WO-E201`
picked up a second, unrelated call site in the same task: a `switch`
expression's arms disagree on their yielded type. Both have moved up
into `docs/plan/oop-vm/01-error-catalog.md`'s main table. Same
convention as the notes above: the table below is left unedited.
Ten `WO-E2xx` codes were declared as named constants in `types.ml` with no
call site anywhere in the front end — `grep '~code:'` found exactly five
sites (`WO-W201`, `WO-E202`, `WO-E206`, `WO-E207`, `WO-E225`); the other ten
declared constants are never referenced by a `Diag.error`/`Diag.warning`
declared constants were never referenced by a `Diag.error`/`Diag.warning`
call. `docs/plan/oop-vm/01-error-catalog.md`'s "Reserved, not yet emitted"
section already lists all ten; this table adds *why* each is dead and who,
if anyone, is expected to wire it:
section listed all ten at the time; this table adds *why* each was dead and
who, if anyone, was expected to wire it:
| Code | Meaning | Why it's dead |
|------|---------|----------------|

View file

@ -44,10 +44,74 @@ All integers little-endian; offsets are absolute file offsets.
| 30 | DB_STUB | trap T_DB "engine not linked" (spec: SQL-layer statements in milestone 1) |
| 31 | TRAP Bx | explicit trap with code Bx |
**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.
**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, int_to_text (haxe-parity Task 2), variant_tag (haxe-parity Task 4 — see "Enum payload variants" below).
**Trap codes:** DIV0, BORROW, STACK, OOM, DB, BOUNDS, KEY, EXPLICIT.
## Enum payload variants (haxe-parity compiler Task 4)
`.wob` v1 is unchanged — no new section, no new header field, no version
bump. A union with at least one payload variant (`type Status = Pending |
Failed(reason: Text)`) compiles to **one ordinary class-table entry per
variant**, named `"<Union>.<Variant>"` in the constant pool (source
identifiers can never contain a dot, so the composite name cannot collide
with a declared class — the same convention the method table already uses
for `"Class.method"`). A variant's payload fields are the entry's fields,
declaration order, ordinary kind bytes — so a variant object is dropped,
masked, and cycle-scanned exactly like any other instance, including
recursive payload frees, with zero collector changes.
**The variant tag IS the class-table index**, carried by the object
header's existing `class_id` field — nothing new is stored and `NEW`
needs no change. The one VM addition is builtin **14 `variant_tag`**:
register A = the header `class_id` of the object in register B, so a
`switch` over a payload union reads the tag once and compares it against
`LOADK`-ed class-id constants — no per-arm allocation. It traps
`T_BOUNDS` on a null receiver or a native (`WO_CLS_*`) class id, the same
defense `ICALL` keeps; a non-pointer register stays the compiler's to
prevent (untyped registers, the residual-check doctrine). `variant_tag`
is compiler-internal: it is not a source-callable name and does not
appear in [`08-builtin-surface.md`](08-builtin-surface.md).
An **all-bare union** (`type CronResult = Ok | ErrorFinal | Miss`) never
reaches this file's format at all: its values are plain integer ordinals
(0, 1, 2 … in declaration order) in `WO_K_SCALAR` positions, compared
with `EQ` — no class entries, no heap objects, no `variant_tag`.
**Payload move-out** (Task 4 fix rounds 1–2): a `switch` arm that yields
its own payload binding as the switch's value (`case Boxed(b): b;`) MOVES
the payload out of the variant object — **pointer-kind fields only**
(OWNED/GCREF/TEXT/MULTI/MAP). The convention needs no format or collector
change: the compiler emits a `SETF` writing zero into the moved field
right after the value lands in its new owner's register, and the shell's
ordinary recursive drop plan — which already skips zero slots for every
kind (`runtime/src/gc.c wo_drop_kind`) — thereby frees the shell only.
Escaping a **SCALAR** field (Int/Bool/Timestamp/Id/`ref`, a bare-union
tag) is a plain COPY: no ownership moves and the field is left intact —
nulling it would corrupt the subject with a value indistinguishable from
a legitimate 0. A DISCARDED yield (statement-position switch) does not
null either: the shell keeps the payload and frees it as usual.
**Re-reading a moved-out payload is nil**: the field holds the zero word,
so a later `switch` over the same subject GETFs 0 into the binding and
any use of it traps `T_BOUNDS` ("null receiver") — memory-safe and
defined, the residual-check doctrine's direction; a later task may
promote this to a compile-time partial-move diagnostic (WO-E301 family).
One companion rule on the caller side: an **owned heap temporary** passed
as a borrow argument — a record/class constructor literal, a variant
construction, or an owned-returning call (`peek(Pay{})`,
`get(Boxed(Pay{}))`) — is copied to a stable register below the call
window and `DROP`ped by the caller once the call returns (`take`
arguments are the callee's to drop; places are their scope's; `@gc` and
`Text` temporaries are excluded — the rc system's and the Copy-aliasing
story's, respectively). Recursive drop is correct both ways, because a
payload the callee moved out left the field nulled.
**Typedef records** (`typedef Name = { ... }`) are ordinary class-table
entries too, with one compiler-side convention the loader never sees: two
records with the same shape (same ordered fields, same types, same
defaults) share a single entry — structural aliasing decided entirely at
emit time.
## Single-binary trailer (`woc build`, plan 3 Task 6)
This section is **not part of the `.wob` format above** — `.wob` v1 is unchanged.

View file

@ -27,40 +27,68 @@ half of the story ("moved here" / "borrowed here" / etc.).
| 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-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-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 (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-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`](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, 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\`` |
| 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, `use`d 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
`type_mismatch_code` (WO-E201), `bad_arity_code` (WO-E203),
`unknown_fn_code` (WO-E204), `non_exhaustive_switch_code` (WO-E208),
`invalid_builtin_code` (WO-E209), `module_not_imported_code` (WO-E210),
`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 Task 7 nothing in the front end ever
— 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. Listed here so a
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.
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
@ -125,9 +153,10 @@ written when any of these fire (`woc --emit` writes nothing on exit 1).
| --- | --- | --- |
| 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-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); (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

View file

@ -38,6 +38,35 @@ 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/` and `compile-fail/` compile the fixture's own *directory*, not
just `fixture.wo`** (haxe-parity Task 1, modules) — `woc --emit
<fixture-dir> -o <scratch>.wob`, letting `woc`'s own multi-file discovery
find every `.wo` file under it. For a fixture with no other `.wo` file
beside `fixture.wo` (every fixture that predates modules, and the large
majority since) this is behavior-identical to compiling `fixture.wo`
alone. What it's *for*: a module fixture puts its extra module(s) in a
**subdirectory** (`greet/greet.wo`, `secret/secret.wo`, `a/a.wo`, ...) —
each subdirectory is its own module (a `.wo` file's module is its
directory), so this is how a `run`/`compile-fail` fixture exercises a
real `use` across module boundaries at all.
**Guarded, not open season**: exactly one top-level `.wo` file
(`fixture.wo` itself) is required directly inside the fixture's own
directory — `scripts/oop-e2e.sh`'s `assert_one_top_level_wo` counts
`<fixture-dir>/*.wo` (never recursing into subdirectories) and fails the
fixture by name, before compiling anything, if that count isn't exactly
1. Without this, a second `.wo` file dropped loose beside `fixture.wo`
(not a module fixture's intentional subdirectory — a mistake, or worse)
silently joins the compile as a second file in the *same* module (Task
8's own discovery contract: every same-directory file is unconditionally
visible to every other) — confirmed exploitable: an alphabetically-
earlier stray `fn main` hijacks the fixture's own entry point with zero
diagnostics, since free fns are excluded from the cross-file collision
check (`01-error-catalog.md`'s WO-E214 row is classes/interfaces only).
A subdirectory full of `.wo` files is unaffected by this guard — that's
a different module by construction, exactly the shape a module fixture
is supposed to have.
## `run/` — compiles, runs, exact stdout
**Files:** `fixture.wo`, `fixture.out`.

View file

@ -29,6 +29,7 @@ maps to one `BUILTIN` id of the format doc.
| `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` |
| `int_to_text(n)` | `int_to_text` | 1 | decimal rendering of an `Int`, as a fresh owned `Text` — haxe-parity Task 2's one fenced VM addition, the type-directed half of string interpolation (below); also directly callable |
`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
@ -96,6 +97,43 @@ type, which is also a typed destination.
- `self` occupies the callee's `r0`, so a method's argument count is
`1 + parameters`.
## Modules (haxe-parity Task 1)
A `.wo` file's **module is its directory** — no manifest, no declared
module name. Every file sharing a directory sees every other same-
directory file's declarations unconditionally (Task 8's existing
multi-file discovery, unchanged); a name declared in a *different*
directory needs `use` to become visible at all, and even then only if
it is marked `pub`.
- **`pub`** on a top-level `class`/`type`/`interface`/`fn` exports it
outside its own module. Default is private-to-module — visible to
every file in the same directory, invisible to every other module
regardless of `use` (`WO-E217` if referenced anyway). `pub` on a
class/interface method, or the `pub(read)` field-accessor marker
(Haxe's `(default, null)`), is a different, later feature — not this
one.
- **`use fs`** (a bare, single-segment name) is a **reserved stdlib
namespace**: exactly `fs`, `proc`, `net`, `time`, `json`, `env`, no
others, and always stdlib even if a same-named project directory
exists. A call through one (`fs.stat(...)`) typechecks as
UNKNOWN-BUT-RESERVED — no E207/E225/arity check, since the six
namespaces' members arrive in plan 9 — and is `WO-E406` only if such
a call survives all the way to emission; an unused `use fs` compiles
clean (modulo `WO-W202`, below).
- **`use shared/util`** (slash-separated segments) is **project-
relative**: it must name a directory this program's own discovery
actually finds, or `WO-E216`. The alias a call site uses is always
the path's *last* segment (`util.fn(...)`, not `shared.fn(...)`).
- **Resolution order** for a bare (unqualified) name: this file's own
module, unconditionally; then every `use`d module's `pub` surface. If
more than one used module exports the same `pub` name, that is
`WO-E218` — collisions diagnose rather than silently pick a winner.
A qualified reference (`alias.name(...)`) skips straight to its
named module; `WO-E217` if `name` exists there but isn't `pub`.
- A `use` clause never referenced (bare or qualified) anywhere in its
own file is `WO-W202` — a warning, so it does not fail the build.
## Program entry
The entry point is the **zero-argument free `fn main`**. A `main` that
@ -113,6 +151,8 @@ Lowered by the emitter, not added to the format:
| `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 |
| `a and b` | evaluate `a`; `JZ` past evaluating `b` (result stays `a`'s value); else evaluate `b` into the same register (haxe-parity Task 2) |
| `a or b` | evaluate `a`; `JZ` + `JMP` past evaluating `b` when `a` is already true; else evaluate `b` (haxe-parity Task 2) |
## Not lowerable in milestone 1
@ -125,6 +165,50 @@ Each is `WO-E403` at the offending site, never invented bytecode:
- 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.
- `break`/`continue` outside any loop (haxe-parity Task 2) — nothing
upstream tracks loop nesting to reject it earlier, so the emitter's
own "no legal jump target" gate is the only one.
- interpolating (`"${expr}"`) a value that is neither `Text` nor `Int`
(haxe-parity Task 2) — the brief's own scope; a class, `multi`, `map`,
or other scalar has no defined textification here.
## Small control surface (haxe-parity Task 2)
`break`/`continue`/`do...while`, `const`, `and`/`or`, and string
interpolation — the haxe keyword verdict table's low-risk batch, added
2026-08-11.
- **`break`/`continue`** reuse the owner pass's own `return`-drop
machinery, bounded to the nearest enclosing loop instead of the whole
function: an owned value still alive in the loop body is dropped at
the `break`/`continue` site itself, not left to leak (proven under
ASan, `tests/corpus/run/lang-break-owned-drop/`). `continue`'s actual
jump target depends on loop shape — `while`'s own condition check,
`for`'s increment step, or `do...while`'s condition check — but the
drop-set computation is identical either way.
- **`do { body } while cond`** — the body always runs at least once;
lowered onto the same `JZ`/`JMP` pair `while`/`for` already use, just
reordered.
- **`const NAME = <literal>`** (top-level, or bare — no `static` —
class-level) is resolved entirely by `parser.ml`, before typecheck
ever runs: every unshadowed reference is replaced by the literal it
names, so nothing downstream (types/owner/emit) has any const-specific
code at all. A local/parameter/`self` of the same name always shadows
it. `static const` is Task 7's own syntax (`static`), not recognized
here.
- **`and`/`or`** are real keywords (never `&&`/`||`), one precedence
level below comparison (`or` loosest, then `and`, then comparison —
so `a == 1 and b == 2` needs no parens). `Bool`-typed operands only,
no truthiness: a confidently-non-`Bool` operand is `WO-E201`. Short-
circuit, lowered to compare-and-jump above — no new opcode.
- **String interpolation** (`"${expr}"`) desugars at parse time to a
`..` (`Concat`) chain of text segments and embedded expressions; each
embedded expression's *textification* is decided at emit time, once
its type is known: `Text` passes through untouched, `Int` is wrapped
in `int_to_text` (above), anything else is `WO-E403` (see "Not
lowerable in milestone 1"). `\$` is a literal `$` (so `\${x}` stays
literal, never interpolates); a lone `$` not followed by `{` is also
literal, unconditionally.
## `?T`

View file

@ -150,6 +150,34 @@ int wo_builtin(wo_vm *vm, uint64_t *R, uint32_t ins, const char **msg) {
R[A] = wo_map_has(m, R[B + 1]) ? 1 : 0;
return 0;
}
case WO_B_INT_TO_TEXT: { /* haxe-parity compiler Task 2: string interpolation */
char buf[32];
int len = snprintf(buf, sizeof buf, "%lld", (long long)(int64_t)R[B]);
wo_str *s = wo_str_new(rt, buf, (uint32_t)len);
if (!s) {
*msg = "out of memory";
return WO_T_OOM;
}
R[A] = (uint64_t)(uintptr_t)s;
return 0;
}
case WO_B_VARIANT_TAG: { /* haxe-parity compiler Task 4: enum payload variants */
/* the tag IS the header's class_id (wob.h's convention). Null and
* native-class receivers trap BOUNDS — same defense ICALL keeps;
* a non-pointer register is the compiler's to prevent (untyped
* registers, the residual-check doctrine). */
if (!R[B]) {
*msg = "null receiver";
return WO_T_BOUNDS;
}
wo_hdr *o = (wo_hdr *)(uintptr_t)R[B];
if (o->class_id >= vm->mod->class_cnt) {
*msg = "variant tag of a native value";
return WO_T_BOUNDS;
}
R[A] = o->class_id;
return 0;
}
default: /* unreachable: loader validated the id */
*msg = "unknown builtin";
return WO_T_EXPLICIT;

View file

@ -40,7 +40,8 @@ static const uint8_t b_arity[WO_B_MAX + 1] = {
[WO_B_WORDS] = 1, [WO_B_MULTI_NEW] = 0, [WO_B_MULTI_PUSH] = 2,
[WO_B_MULTI_GET] = 2, [WO_B_COUNT] = 1, [WO_B_LATEST] = 1,
[WO_B_MAP_NEW] = 0, [WO_B_MAP_SET] = 3, [WO_B_MAP_GET] = 2,
[WO_B_MAP_HAS] = 2,
[WO_B_MAP_HAS] = 2, [WO_B_INT_TO_TEXT] = 1,
[WO_B_VARIANT_TAG] = 1,
};
static int vtab_cmp(const void *a, const void *b) {

View file

@ -138,8 +138,22 @@ enum {
WO_B_MAP_SET = 10,
WO_B_MAP_GET = 11, /* missing key traps WO_T_KEY */
WO_B_MAP_HAS = 12,
/* haxe-parity compiler Task 2: string interpolation's Int -> Text
* conversion (`"${count} lines"`). (i64) -> a fresh owned Text of
* its decimal rendering. */
WO_B_INT_TO_TEXT = 13,
/* haxe-parity compiler Task 4: enum payload variants. A payload
* union's variants are compiler-generated class-table entries; the
* variant TAG is the object header's existing class_id (no new
* header field, no new flag — docs/plan/oop-vm/00-wob-format.md,
* "enum payload variants"). (variant object) -> i64 tag: reads
* r[B]'s header class_id into r[A] so a switch over a payload
* union compares tags without a per-arm allocation. Traps
* WO_T_BOUNDS on a null receiver or a native class id — the same
* defense ICALL keeps for a miscompiled receiver. */
WO_B_VARIANT_TAG = 14,
};
#define WO_B_MAX 12u
#define WO_B_MAX 14u
/* ---- instruction encode/decode: op:8 A:8 then B:8 C:8 or Bx:16 ---- */
static inline uint32_t wo_ins_abc(uint8_t op, uint8_t a, uint8_t b, uint8_t c) {

View file

@ -81,17 +81,51 @@ extract_error_codes() {
grep -oE 'error (WO-E[0-9]+):' "$1" | sed -E 's/error (WO-E[0-9]+):/\1/'
}
# haxe-parity Task 1 review, IMPORTANT 3: compiling the fixture's own
# directory (not just fixture.wo) means woc's own multi-file discovery
# picks up *any* .wo file dropped there, not only fixture.wo -- a stray
# second .wo directly beside fixture.wo silently joins the compile as a
# second file in the SAME module (Task 8's own discovery contract: every
# file sharing a directory is unconditionally visible to every other),
# and its own free fns/classes are exactly as reachable as fixture.wo's
# own. Confirmed exploitable: an alphabetically-earlier stray `fn main`
# hijacks the fixture's entry point with zero diagnostics (free fns are
# excluded from the cross-file collision check, docs/plan/oop-vm/
# 01-error-catalog.md's WO-E214 row). A *subdirectory* full of .wo files
# is a different module on purpose -- that's the whole point of a
# module fixture (greet/, secret/, a/, b/, ...) -- so this only counts
# files directly inside $1, never recursing into subdirectories.
assert_one_top_level_wo() {
local dir="$1" name="$2"
local top_level=("$dir"/*.wo)
if [[ ${#top_level[@]} -ne 1 ]]; then
bad "$name" "expected exactly one top-level .wo file in $dir, found ${#top_level[@]} -- module fixtures belong in a subdirectory, not loose beside fixture.wo"
return 1
fi
return 0
}
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
assert_one_top_level_wo "$dir" "$name" || return
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"
# Compile the fixture's own directory, not just fixture.wo directly:
# for a fixture with no other .wo file beside fixture.wo (every
# pre-haxe-parity fixture), woc's directory discovery finds exactly
# that one file, so this is behavior-identical to compiling
# "$dir/fixture.wo" on its own. It's what lets a fixture exercise a
# real cross-module `use` (haxe-parity Task 1) by adding a nested
# module directory (e.g. `greet/greet.wo`) beside fixture.wo — woc's
# own multi-file discovery (Task 8) then compiles both as one
# program, exactly like a real project layout would.
timeout "$TIMEOUT" "$WOC" --emit "$dir" -o "$wob" >/dev/null 2>"$err"
rc=$?
if [[ $rc -eq 124 ]]; then
bad "$name" "woc timed out after ${TIMEOUT}s"
@ -211,6 +245,7 @@ compile_fail_fixture() {
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
assert_one_top_level_wo "$dir" "$name" || return
prefix="$(tmp_prefix "$name")"
wob="$prefix.wob" err="$prefix.err"
@ -221,7 +256,16 @@ compile_fail_fixture() {
return
fi
timeout "$TIMEOUT" "$WOC" --emit "$dir/fixture.wo" -o "$wob" >/dev/null 2>"$err"
# Compile the fixture's own directory, not just fixture.wo directly:
# for a fixture with no other .wo file beside fixture.wo (every
# pre-haxe-parity fixture), woc's directory discovery finds exactly
# that one file, so this is behavior-identical to compiling
# "$dir/fixture.wo" on its own. It's what lets a fixture exercise a
# real cross-module `use` (haxe-parity Task 1) by adding a nested
# module directory (e.g. `greet/greet.wo`) beside fixture.wo — woc's
# own multi-file discovery (Task 8) then compiles both as one
# program, exactly like a real project layout would.
timeout "$TIMEOUT" "$WOC" --emit "$dir" -o "$wob" >/dev/null 2>"$err"
rc=$?
if [[ $rc -eq 124 ]]; then

View file

@ -0,0 +1 @@
WO-E201

View file

@ -0,0 +1,10 @@
-- haxe-parity Task 2: `and`/`or` are Bool-only, no truthiness --
-- `count` is confidently Int (a literal-valued local), so this is a
-- real, statically-provable type error, not a guess.
fn main() -> Int {
let count = 1
if count and true {
print("unreachable")
}
return 0
}

View file

@ -0,0 +1 @@
WO-E403

View file

@ -0,0 +1,9 @@
-- haxe-parity Task 2: `break` outside any loop has no legal jump
-- target -- emit.ml's own "cannot lower" gate (WO-E403), the same
-- convention every other construct with nothing to lower to uses.
-- Nothing upstream (parser/types/owner) tracks loop nesting to reject
-- this earlier.
fn main() -> Int {
break
return 0
}

View file

@ -0,0 +1 @@
WO-E209

View file

@ -0,0 +1,20 @@
-- Hotfix round 2 (WO-E209): `takesSecret` is declared `-> Int`, so its
-- call result is exactly as confidently `Int` as a bare literal would
-- be -- passing it to `print` (wants `Text`) is the same class of bug
-- `print(7)` is, just one call deeper. Controller-verified real repro:
-- this compiled clean and segfaulted `wovm` before `confident_typ`
-- chased a free fn's own declared return type (round 1 only chased
-- literals/fields/params, missing this shape entirely).
class Box {
fn hidden() -> Int {
return 7
}
}
fn takesSecret(box: Box) -> Int {
return box.hidden()
}
fn main() {
print(takesSecret(Box{}))
}

View file

@ -0,0 +1 @@
WO-E209

View file

@ -0,0 +1,17 @@
-- Hotfix round 2 (WO-E209): a direct method call, not a free fn wrapping
-- one -- `Box.hidden` is declared `-> Int`, and the receiver `b` is
-- confidently `Box` because it was built the ordinary way (`let b =
-- Box{}`, a `Ctor`, which `confident_typ` also had to start chasing to
-- reach method calls off a receiver built this way rather than only a
-- parameter). `print(b.hidden())` is the same class of bug as
-- `print(7)`, dispatched through a method instead of a free fn.
class Box {
fn hidden() -> Int {
return 7
}
}
fn main() {
let b = Box{}
print(b.hidden())
}

View file

@ -0,0 +1 @@
WO-E209

View file

@ -0,0 +1,11 @@
-- Hotfix (WO-E209): `print` wants a `Text` -- a heap-string pointer,
-- .wob kind WO_K_TEXT -- and a bare Int literal is a WO_K_SCALAR
-- register holding a raw int64 (docs/plan/oop-vm/08-builtin-surface.md's
-- `print(t)` row). Before this check existed, this compiled clean and
-- segfaulted `wovm`: the VM's `str_check` (runtime/src/vm.c) dereferences
-- whatever register it's handed as a `wo_str*` with no runtime tag to
-- check first, so the literal `7` was a wild pointer read. The exact
-- repro that motivated wiring this diagnostic up.
fn main() {
print(7)
}

View file

@ -0,0 +1 @@
WO-E209

View file

@ -0,0 +1,9 @@
-- Hotfix (WO-E209): `now` takes zero arguments
-- (docs/plan/oop-vm/08-builtin-surface.md's `now()` row) -- this
-- exercises the wrong-arity half of the same diagnostic, caught at
-- typecheck time. Arity mismatches were already caught later, at
-- emission (`WO-E403`, `emit.ml`) -- that check stays as-is; this is an
-- earlier, additional gate over the same contract, not a replacement.
fn main() {
now(1)
}

View file

@ -0,0 +1 @@
WO-E103

View file

@ -0,0 +1,11 @@
-- haxe-parity Task 2: the keyword verdict table's `inline` row --
-- `const` values are adopted (see run/lang-const-usage); inline
-- *functions* are rejected outright, optimization being the
-- compiler's job, not the source language's.
inline fn double(n: Int) -> Int {
return n + n
}
fn main() -> Int {
return double(2)
}

View file

@ -0,0 +1 @@
WO-E101

View file

@ -0,0 +1,11 @@
-- haxe-parity Task 2 review fix (Important): a genuine sub-parse
-- failure inside "${...}" (`1 +`, not just trailing garbage) must
-- report at this string literal's own position with the malformed-
-- interpolation message -- not at the sub-lexer's own uncorrected
-- line/col (which used to land on some unrelated line of this real
-- file). See compiler/test/runner.ml's direct assertion for the exact
-- position/message pin; this fixture only pins the outcome (WO-E101).
fn main() -> Int {
print("prefix ${1 +} suffix")
return 0
}

View file

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

View file

@ -0,0 +1,15 @@
-- Hotfix repro (multi-file double-report): a body-level check (here
-- WO-E202, unknown field) must fire exactly once, tagged with THIS
-- file -- not once per OTHER discovered file too. `it.price` is the
-- one real bug (Item has no `price` field, only `n`). other/helper.wo
-- exists purely to make this a two-file program: the bug needs N>1
-- discovered files to reproduce at all (with one file, there is no
-- "other file's pass" to re-report under). See
-- .superpowers/sdd/2026-08-01-haxe-parity-language/hotfix-e209-report.md's
-- "Disclosed, NOT fixed" section for the original diagnosis.
class Item { n: Int }
fn main() {
let it = Item{n:1}
print_int(it.price)
}

View file

@ -0,0 +1,8 @@
-- Unrelated file, deliberately trivial and error-free -- its only job
-- is being a second discovered file in this program (see fixture.wo's
-- own comment for why). No Ctor/Call site here means no module `use`
-- gating gets exercised either -- this fixture is purely about the
-- double-report bug, not the module system.
fn helper() -> Int {
return 1
}

View file

@ -0,0 +1 @@
WO-E201

View file

@ -0,0 +1,16 @@
-- haxe-parity Task 3: arm-type unification. `default` yields `Int`
-- while the earlier `case` arms yield `Text` -- the switch's own type
-- is fixed by the first arm (types.ml's own "first wins" convention),
-- so this is a real, statically-provable mismatch, not a guess.
fn classify(n: Int) -> Text {
let label = switch n {
case 1: "one";
case 2: "two";
default: 0;
};
return label;
}
fn main() -> Int {
return 0;
}

View file

@ -0,0 +1,20 @@
-- haxe-parity Task 3, re-review fix (Critical 2 residual): the switch
-- subject is a BUILTIN CALL, not a variable -- `int_to_text(n)` is
-- confidently `Text` because the builtin's return type is declared.
-- Before the fix, types.ml's `builtin_confident_ret` had no
-- `int_to_text` entry (drifted from emit.ml's `builtin_ret`, which
-- does), so the E201 check stayed silent while the emitter still
-- chose EQS -- and EQS str_check'd the raw int case label at runtime:
-- a zero-diagnostic compile that segfaulted the VM. This fixture pins
-- the two tables back in sync.
fn classify(n: Int) -> Int {
let v = switch int_to_text(n) {
case 1: 0;
default: 1;
};
return v;
}
fn main() -> Int {
return 0;
}

View file

@ -0,0 +1 @@
WO-E201

View file

@ -0,0 +1,19 @@
-- haxe-parity Task 3, review fix (Critical 2): the reverse direction
-- of the Text/Int representation mismatch -- an `Int` subject against
-- a `Text` case label doesn't crash the VM (EQ just compares two
-- int64s), but it is equally wrong: the case value's own register
-- never holds the subject's representation, so the comparison is
-- silently always-false, and the case can never fire. `n` is
-- confidently `Int` (a declared parameter), so this is a real,
-- statically-provable mismatch, not a guess.
fn describe(n: Int) -> Text {
let v = switch n {
case "one": "matched";
default: "other";
};
return v;
}
fn main() -> Int {
return 0;
}

View file

@ -0,0 +1,18 @@
-- haxe-parity Task 4, fix round 1 (review Major): the inverse of the
-- union-subject direction — a case naming a KNOWN variant over an `Int`
-- subject silently ordinal-matched (`Lo` is tag 0, so `f(0)` took the
-- `case Lo:` arm). WO-E201, the same family as the existing
-- lang-switch-int-subject-text-case.
type K = Lo | Mid | Hi
fn f(n: Int) -> Int {
return switch n {
case Lo: 99;
default: 0;
}
}
fn main() -> Int {
print_int(f(0))
return 0
}

View file

@ -0,0 +1 @@
WO-E208

View file

@ -0,0 +1,15 @@
-- haxe-parity Task 3: scalars/Text require `default` -- no union type
-- exists yet (Task 4), so this is unconditional for every subject this
-- task supports; `n` is confidently `Int` (a declared parameter), so
-- this is a real, statically-provable gap, not a guess.
fn classify(n: Int) -> Text {
let label = switch n {
case 1: "one";
case 2: "two";
};
return label;
}
fn main() -> Int {
return 0;
}

View file

@ -0,0 +1 @@
WO-E201

View file

@ -0,0 +1,19 @@
-- haxe-parity Task 3, review fix (Critical 2): a `Text` subject
-- compared against an `Int` case label is not merely a type error --
-- unchecked, it is a real VM segfault: emit.ml's EQ-vs-EQS choice
-- reads only the SUBJECT's type, so `s: Text` picks EQS, and EQS's
-- own `str_check` dereferences whatever sits in the case value's
-- register as a `wo_str*` -- a raw int64 (`1`) read as a heap address.
-- `s` is confidently `Text` (a declared parameter), so this is a
-- real, statically-provable mismatch, not a guess.
fn describe(s: Text) -> Text {
let v = switch s {
case 1: "one";
default: "other";
};
return v;
}
fn main() -> Int {
return 0;
}

View file

@ -0,0 +1,3 @@
pub fn thing() -> Int {
return 1
}

View file

@ -0,0 +1,3 @@
pub fn thing() -> Int {
return 2
}

View file

@ -0,0 +1 @@
WO-E218

View file

@ -0,0 +1,11 @@
-- haxe-parity Task 1 (modules): `a` and `b` both export a `pub fn
-- thing`. Resolution goes file -> own module -> used modules -> stdlib
-- (this file's own module has no `thing` at all), and at the "used
-- modules" tier both `a` and `b` match — collisions diagnose rather
-- than shadow silently (WO-E218), never a first-used-wins pick.
use a
use b
fn main() {
print_int(thing())
}

View file

@ -0,0 +1 @@
WO-E217

View file

@ -0,0 +1,8 @@
-- haxe-parity Task 1 (modules): `secret.hidden()` names a real fn in a
-- real, `use`d module — but `hidden` is not `pub` (see secret/secret.wo),
-- so this is WO-E217, not a lookup failure.
use secret
fn main() {
print_int(secret.hidden())
}

View file

@ -0,0 +1,7 @@
-- haxe-parity Task 1 (modules): `hidden` has no `pub` marker, so it is
-- private to this module — visible to every file in `secret/` itself,
-- but not to a `use secret` caller elsewhere. That is exactly what
-- fixture.wo tries and WO-E217 catches.
fn hidden() -> Int {
return 1
}

View file

@ -0,0 +1 @@
WO-E406

View file

@ -0,0 +1,12 @@
-- haxe-parity Task 1 (modules): `fs` is a reserved stdlib namespace —
-- `use fs` resolves and `fs.stat(...)` typechecks as UNKNOWN-BUT-
-- RESERVED (no E207/E225/arity error; the six namespaces' members
-- arrive in plan 9). A call through it that survives all the way to
-- emission is WO-E406, not silently accepted and not a generic
-- WO-E403 "cannot resolve the receiver" -- there is nothing wrong with
-- the reference, only nothing to lower it to yet.
use fs
fn main() {
print_int(fs.stat("x"))
}

View file

@ -0,0 +1 @@
WO-E216

View file

@ -0,0 +1,8 @@
-- haxe-parity Task 1 (modules): `nosuchmodule` is neither one of the
-- six reserved stdlib namespaces (fs, proc, net, time, json, env) nor a
-- directory this program actually discovers -- WO-E216.
use nosuchmodule
fn main() {
print("x")
}

View file

@ -0,0 +1 @@
WO-E203

View file

@ -0,0 +1,10 @@
-- haxe-parity Task 4: constructing a payload variant with the wrong
-- number of payload arguments is WO-E203 (bad arity -- reserved since
-- plan 2 Task 6, this is its first real emission site). `Failed`
-- declares exactly one payload field.
type Status = Pending | Failed(reason: Text)
fn main() -> Int {
let st = Failed("a", "b")
return 0
}

View file

@ -0,0 +1 @@
WO-E201

View file

@ -0,0 +1,12 @@
-- haxe-parity Task 4, fix round 1 (review Major): two bare unions share
-- the ordinal-tag representation, so `X == P` across DIFFERENT unions
-- was silently true (both ordinal 0). WO-E201 — values of different
-- unions never compare equal.
type A = X | Yv
type B = P | Qv
fn main() -> Int {
if X == P { print("cross-eq") }
return 0
}

View file

@ -0,0 +1 @@
WO-E208

View file

@ -0,0 +1,16 @@
-- haxe-parity Task 4: WO-E208's union exhaustiveness rule -- a switch
-- over a union with no `default` must cover every variant; the
-- diagnostic names the missing ones (`Hi` here). Scalars/Text keep the
-- unconditional default-required rule (lang-switch-missing-default).
type Kind = Lo | Mid | Hi
fn f(k: Kind) -> Int {
return switch k {
case Lo: 1;
case Mid: 2;
}
}
fn main() -> Int {
return 0
}

View file

@ -0,0 +1 @@
WO-E201

View file

@ -0,0 +1,21 @@
-- haxe-parity Task 4, fix round 1 (review Critical 2): a variant-named
-- case over a `?Union` subject can NEVER match (the subject is not
-- narrowed by `switch` — `?T` forced handling is Task 6's), so it
-- compiled clean and always took `default`. WO-E201, pointing at the
-- nil case first.
type St = Pending | Failed(m: Text)
typedef R = { ?st: St }
fn f(r: R) -> Text {
return switch r.st {
case Pending: "p";
default: "nil";
}
}
fn main() -> Int {
let a = R { st: Pending }
print(f(a))
return 0
}

View file

@ -0,0 +1,3 @@
3
1
2

View file

@ -0,0 +1,35 @@
-- pre-existing emit_ctor register collision, found during haxe-parity
-- Task 3's re-review (out-of-scope encounter, fixed as its own hotfix):
-- a constructor in TAIL position (`return Box{...}`) gets its dst from
-- emit_tail's allocate-then-un-reserve convention, so dst sits AT
-- f_temp -- and emit_ctor's per-field value temp then lands in dst
-- itself, clobbering the just-NEW'd object pointer before SETF reads
-- it (NEW r0; LOADK r0,k; SETF r0,f0,r0 -- an int64 stored through as
-- a heap pointer, VM segfault with zero diagnostics). A `let`-bound
-- ctor never collides (its dst is a local, below f_temp), which is why
-- the whole milestone-1 corpus missed it. Same defect family as the
-- Task 3 emit_switch placeholder-dst collision, different call site.
class Box {
n: Int
}
class Pair {
a: Int
b: Int
}
fn make() -> Box {
return Box{n: 3};
}
fn make_pair() -> Pair {
return Pair{a: 1, b: 2};
}
fn main() -> Int {
print_int(make().n);
let p = make_pair();
print_int(p.a);
print_int(p.b);
return 0;
}

View file

@ -0,0 +1,3 @@
and-precedence-ok
and-short-circuit-ok
or-short-circuit-ok

View file

@ -0,0 +1,26 @@
-- haxe-parity Task 2: `and`/`or` -- real keywords, own precedence level
-- (looser than comparison, so `a == 1 and b == 2` needs no parens),
-- short-circuit lowering to compare-and-jump (JZ + JMP, no new opcode).
-- The short-circuit proof is real, not asserted: `1 / zero` traps
-- (division by zero) if it is ever actually evaluated, so a broken
-- (always-evaluate-both-sides) lowering would crash this fixture
-- outright instead of merely printing a wrong answer.
fn main() -> Int {
let a = 1
let b = 2
if a == 1 and b == 2 {
print("and-precedence-ok")
}
let zero = 0
if false and (1 / zero == 0) {
print("SHOULD-NOT-PRINT-and")
} else {
print("and-short-circuit-ok")
}
if true or (1 / zero == 0) {
print("or-short-circuit-ok")
} else {
print("SHOULD-NOT-PRINT-or")
}
return 0
}

View file

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

View file

@ -0,0 +1,159 @@
-- haxe-parity Task 2: break/continue drop-set correctness.
-- `Big` has 130 Int fields (runtime/test's own malloc-path trick,
-- test_rc.c's doc comment: field_cnt*8 bytes exceeds the arena's
-- 1024-byte size-class ceiling, so a missed DROP is a hard ASan leak,
-- not a silent, unobservable miss). At i == 1, `big` is still live
-- (never returned or moved) when `break` fires -- this is the DROP
-- under test: owner.ml's DBreak table entry, emitted at the break site
-- itself, must destroy it there, not leave it to leak. Verified under
-- `runtime/build/wovm_asan` (see task-2-report.md for the RED/GREEN
-- ASan evidence); this fixture's own oop-e2e.sh check (plain wovm)
-- pins the *output*, not the leak-freedom -- the two are complementary,
-- not redundant (a correct DROP is also silently correct output-wise;
-- only ASan's leak checker actually proves the destructor ran).
class Big {
f0: Int
f1: Int
f2: Int
f3: Int
f4: Int
f5: Int
f6: Int
f7: Int
f8: Int
f9: Int
f10: Int
f11: Int
f12: Int
f13: Int
f14: Int
f15: Int
f16: Int
f17: Int
f18: Int
f19: Int
f20: Int
f21: Int
f22: Int
f23: Int
f24: Int
f25: Int
f26: Int
f27: Int
f28: Int
f29: Int
f30: Int
f31: Int
f32: Int
f33: Int
f34: Int
f35: Int
f36: Int
f37: Int
f38: Int
f39: Int
f40: Int
f41: Int
f42: Int
f43: Int
f44: Int
f45: Int
f46: Int
f47: Int
f48: Int
f49: Int
f50: Int
f51: Int
f52: Int
f53: Int
f54: Int
f55: Int
f56: Int
f57: Int
f58: Int
f59: Int
f60: Int
f61: Int
f62: Int
f63: Int
f64: Int
f65: Int
f66: Int
f67: Int
f68: Int
f69: Int
f70: Int
f71: Int
f72: Int
f73: Int
f74: Int
f75: Int
f76: Int
f77: Int
f78: Int
f79: Int
f80: Int
f81: Int
f82: Int
f83: Int
f84: Int
f85: Int
f86: Int
f87: Int
f88: Int
f89: Int
f90: Int
f91: Int
f92: Int
f93: Int
f94: Int
f95: Int
f96: Int
f97: Int
f98: Int
f99: Int
f100: Int
f101: Int
f102: Int
f103: Int
f104: Int
f105: Int
f106: Int
f107: Int
f108: Int
f109: Int
f110: Int
f111: Int
f112: Int
f113: Int
f114: Int
f115: Int
f116: Int
f117: Int
f118: Int
f119: Int
f120: Int
f121: Int
f122: Int
f123: Int
f124: Int
f125: Int
f126: Int
f127: Int
f128: Int
f129: Int
}
fn main() -> Int {
let i = 0
while i < 3 {
let big = Big { f0: 1, f1: 1, f2: 1, f3: 1, f4: 1, f5: 1, f6: 1, f7: 1, f8: 1, f9: 1, f10: 1, f11: 1, f12: 1, f13: 1, f14: 1, f15: 1, f16: 1, f17: 1, f18: 1, f19: 1, f20: 1, f21: 1, f22: 1, f23: 1, f24: 1, f25: 1, f26: 1, f27: 1, f28: 1, f29: 1, f30: 1, f31: 1, f32: 1, f33: 1, f34: 1, f35: 1, f36: 1, f37: 1, f38: 1, f39: 1, f40: 1, f41: 1, f42: 1, f43: 1, f44: 1, f45: 1, f46: 1, f47: 1, f48: 1, f49: 1, f50: 1, f51: 1, f52: 1, f53: 1, f54: 1, f55: 1, f56: 1, f57: 1, f58: 1, f59: 1, f60: 1, f61: 1, f62: 1, f63: 1, f64: 1, f65: 1, f66: 1, f67: 1, f68: 1, f69: 1, f70: 1, f71: 1, f72: 1, f73: 1, f74: 1, f75: 1, f76: 1, f77: 1, f78: 1, f79: 1, f80: 1, f81: 1, f82: 1, f83: 1, f84: 1, f85: 1, f86: 1, f87: 1, f88: 1, f89: 1, f90: 1, f91: 1, f92: 1, f93: 1, f94: 1, f95: 1, f96: 1, f97: 1, f98: 1, f99: 1, f100: 1, f101: 1, f102: 1, f103: 1, f104: 1, f105: 1, f106: 1, f107: 1, f108: 1, f109: 1, f110: 1, f111: 1, f112: 1, f113: 1, f114: 1, f115: 1, f116: 1, f117: 1, f118: 1, f119: 1, f120: 1, f121: 1, f122: 1, f123: 1, f124: 1, f125: 1, f126: 1, f127: 1, f128: 1, f129: 1 }
if i == 1 {
break
}
print_int(big.f0)
i = i + 1
}
print("done")
return 0
}

View file

@ -0,0 +1,3 @@
hi
hi
box

View file

@ -0,0 +1,26 @@
-- haxe-parity Task 2: `const NAME = literal` -- top-level and
-- (bare, non-static) class-level, both usable in expressions. Resolved
-- by parser.ml's own post-parse substitution: by the time typecheck/
-- owner/emit see this file, GREETING_COUNT and LABEL are already the
-- literals they name.
const GREETING_COUNT = 2
class Box {
const LABEL = "box"
n: Int
fn describe() -> Text {
return LABEL
}
}
fn main() -> Int {
let i = 0
while i < GREETING_COUNT {
print("hi")
i = i + 1
}
let b = Box { n: 1 }
print(b.describe())
return 0
}

View file

@ -0,0 +1,5 @@
1
2
4
5
done

View file

@ -0,0 +1,24 @@
-- haxe-parity Task 2: `continue` in a `for` loop -- re-enters right
-- before the increment step (not `while`'s condition-check target),
-- so the loop still advances past the skipped element instead of
-- looping forever on it.
class Nums {
items: multi Int
}
fn main() -> Int {
let box = Nums { items: multi_new() }
push(box.items, 1)
push(box.items, 2)
push(box.items, 3)
push(box.items, 4)
push(box.items, 5)
for n in box.items {
if n == 3 {
continue
}
print_int(n)
}
print("done")
return 0
}

View file

@ -0,0 +1 @@
100

View file

@ -0,0 +1,159 @@
-- haxe-parity Task 4, fix round 2 (review NEW 2): an OWNED heap
-- temporary passed as a borrow argument — a record/class constructor
-- literal or an owned-returning call, not just a variant construction
-- (fix round 1's scope) — is reaped by the caller after the call.
-- Before: `peek(Pay{})` in a loop leaked one object per iteration
-- (reviewer's m5c probe: maxrss 10,780 KB vs a 1,532 KB control over
-- 300k iterations; arena-backed and LSan-invisible, so this fixture
-- uses `Big` — 130 Int fields, past the arena's 1024-byte ceiling —
-- to make any miss a hard ASan failure). `take` arguments stay the
-- callee's to drop (the m6a/m6c-proven path) and places stay their
-- scope's; both are pinned by runner.ml assertions.
typedef Big = {
f0: Int = 1
f1: Int = 1
f2: Int = 1
f3: Int = 1
f4: Int = 1
f5: Int = 1
f6: Int = 1
f7: Int = 1
f8: Int = 1
f9: Int = 1
f10: Int = 1
f11: Int = 1
f12: Int = 1
f13: Int = 1
f14: Int = 1
f15: Int = 1
f16: Int = 1
f17: Int = 1
f18: Int = 1
f19: Int = 1
f20: Int = 1
f21: Int = 1
f22: Int = 1
f23: Int = 1
f24: Int = 1
f25: Int = 1
f26: Int = 1
f27: Int = 1
f28: Int = 1
f29: Int = 1
f30: Int = 1
f31: Int = 1
f32: Int = 1
f33: Int = 1
f34: Int = 1
f35: Int = 1
f36: Int = 1
f37: Int = 1
f38: Int = 1
f39: Int = 1
f40: Int = 1
f41: Int = 1
f42: Int = 1
f43: Int = 1
f44: Int = 1
f45: Int = 1
f46: Int = 1
f47: Int = 1
f48: Int = 1
f49: Int = 1
f50: Int = 1
f51: Int = 1
f52: Int = 1
f53: Int = 1
f54: Int = 1
f55: Int = 1
f56: Int = 1
f57: Int = 1
f58: Int = 1
f59: Int = 1
f60: Int = 1
f61: Int = 1
f62: Int = 1
f63: Int = 1
f64: Int = 1
f65: Int = 1
f66: Int = 1
f67: Int = 1
f68: Int = 1
f69: Int = 1
f70: Int = 1
f71: Int = 1
f72: Int = 1
f73: Int = 1
f74: Int = 1
f75: Int = 1
f76: Int = 1
f77: Int = 1
f78: Int = 1
f79: Int = 1
f80: Int = 1
f81: Int = 1
f82: Int = 1
f83: Int = 1
f84: Int = 1
f85: Int = 1
f86: Int = 1
f87: Int = 1
f88: Int = 1
f89: Int = 1
f90: Int = 1
f91: Int = 1
f92: Int = 1
f93: Int = 1
f94: Int = 1
f95: Int = 1
f96: Int = 1
f97: Int = 1
f98: Int = 1
f99: Int = 1
f100: Int = 1
f101: Int = 1
f102: Int = 1
f103: Int = 1
f104: Int = 1
f105: Int = 1
f106: Int = 1
f107: Int = 1
f108: Int = 1
f109: Int = 1
f110: Int = 1
f111: Int = 1
f112: Int = 1
f113: Int = 1
f114: Int = 1
f115: Int = 1
f116: Int = 1
f117: Int = 1
f118: Int = 1
f119: Int = 1
f120: Int = 1
f121: Int = 1
f122: Int = 1
f123: Int = 1
f124: Int = 1
f125: Int = 1
f126: Int = 1
f127: Int = 1
f128: Int = 1
f129: Int = 1
}
fn mk() -> Big { return Big {} }
fn peek(b: Big) -> Int { return b.f0 }
fn main() -> Int {
let i = 0
let acc = 0
while i < 50 {
acc = acc + peek(Big {})
acc = acc + peek(mk())
i = i + 1
}
print_int(acc)
return 0
}

View file

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

View file

@ -0,0 +1,14 @@
-- haxe-parity Task 2: `do { body } while cond` -- body runs at least
-- once. `n` starts already past the condition (5, not < 3) so the
-- printed "0" below only happens because the body ran unconditionally
-- before the condition was ever checked; a `while`-shaped (check-first)
-- lowering would print nothing at all.
fn main() -> Int {
let n = 5
do {
print_int(n)
n = n + 1
} while n < 3
print("done")
return 0
}

View file

@ -0,0 +1,3 @@
log-watcher: 3 lines
price is ${100} exactly
a lone $ stays literal

View file

@ -0,0 +1,13 @@
-- haxe-parity Task 2: string interpolation. Text interpolants pass
-- through untouched; an Int interpolant (`count`) desugars through the
-- new `int_to_text` builtin. `\$` produces a literal `$` (so a whole
-- `${...}` sequence stays literal text when the `$` was escaped); a
-- lone `$` not followed by `{` also stays literal, unconditionally.
fn main() -> Int {
let count = 3
let name = "log-watcher"
print("${name}: ${count} lines")
print("price is \${100} exactly")
print("a lone $ stays literal")
return 0
}

View file

@ -0,0 +1,4 @@
skip
1
skip
done

View file

@ -0,0 +1,166 @@
-- haxe-parity Task 3: `switch` arm-local drop correctness. `Big` has
-- 130 Int fields (the same runtime/test malloc-path trick
-- tests/corpus/run/lang-break-owned-drop/fixture.wo already uses:
-- field_cnt*8 bytes exceeds the arena's 1024-byte size-class ceiling,
-- so a missed DROP is a hard ASan leak, not a silent, unobservable
-- miss). `big` is created inside `case 1:` only (i == 1, one of three
-- loop iterations), never moved, never returned -- this is the DROP
-- under test: owner.ml's `analyze_switch` must record a DScope drop
-- for it at that ARM's own end (the switch's own drop-scope contract,
-- not the enclosing `while`'s), and emit.ml's `emit_switch` must
-- actually place it there. Proven under `runtime/build/wovm_asan`
-- (task-3-report.md has the RED/GREEN evidence: RED was a real bug
-- this fixture itself found, not a hypothetical -- a statement-position
-- switch's own placeholder `dst` register collided with `big`'s own
-- register, so the arm's trailing `print_int(big.f0)` silently
-- overwrote `big`'s only reference before the DROP ran); this
-- fixture's own oop-e2e.sh check (plain wovm) pins the *output*, not
-- the leak-freedom -- the two are complementary, not redundant.
class Big {
f0: Int
f1: Int
f2: Int
f3: Int
f4: Int
f5: Int
f6: Int
f7: Int
f8: Int
f9: Int
f10: Int
f11: Int
f12: Int
f13: Int
f14: Int
f15: Int
f16: Int
f17: Int
f18: Int
f19: Int
f20: Int
f21: Int
f22: Int
f23: Int
f24: Int
f25: Int
f26: Int
f27: Int
f28: Int
f29: Int
f30: Int
f31: Int
f32: Int
f33: Int
f34: Int
f35: Int
f36: Int
f37: Int
f38: Int
f39: Int
f40: Int
f41: Int
f42: Int
f43: Int
f44: Int
f45: Int
f46: Int
f47: Int
f48: Int
f49: Int
f50: Int
f51: Int
f52: Int
f53: Int
f54: Int
f55: Int
f56: Int
f57: Int
f58: Int
f59: Int
f60: Int
f61: Int
f62: Int
f63: Int
f64: Int
f65: Int
f66: Int
f67: Int
f68: Int
f69: Int
f70: Int
f71: Int
f72: Int
f73: Int
f74: Int
f75: Int
f76: Int
f77: Int
f78: Int
f79: Int
f80: Int
f81: Int
f82: Int
f83: Int
f84: Int
f85: Int
f86: Int
f87: Int
f88: Int
f89: Int
f90: Int
f91: Int
f92: Int
f93: Int
f94: Int
f95: Int
f96: Int
f97: Int
f98: Int
f99: Int
f100: Int
f101: Int
f102: Int
f103: Int
f104: Int
f105: Int
f106: Int
f107: Int
f108: Int
f109: Int
f110: Int
f111: Int
f112: Int
f113: Int
f114: Int
f115: Int
f116: Int
f117: Int
f118: Int
f119: Int
f120: Int
f121: Int
f122: Int
f123: Int
f124: Int
f125: Int
f126: Int
f127: Int
f128: Int
f129: Int
}
fn main() -> Int {
let i = 0;
while i < 3 {
switch i {
case 1:
let big = Big { f0: 1, f1: 1, f2: 1, f3: 1, f4: 1, f5: 1, f6: 1, f7: 1, f8: 1, f9: 1, f10: 1, f11: 1, f12: 1, f13: 1, f14: 1, f15: 1, f16: 1, f17: 1, f18: 1, f19: 1, f20: 1, f21: 1, f22: 1, f23: 1, f24: 1, f25: 1, f26: 1, f27: 1, f28: 1, f29: 1, f30: 1, f31: 1, f32: 1, f33: 1, f34: 1, f35: 1, f36: 1, f37: 1, f38: 1, f39: 1, f40: 1, f41: 1, f42: 1, f43: 1, f44: 1, f45: 1, f46: 1, f47: 1, f48: 1, f49: 1, f50: 1, f51: 1, f52: 1, f53: 1, f54: 1, f55: 1, f56: 1, f57: 1, f58: 1, f59: 1, f60: 1, f61: 1, f62: 1, f63: 1, f64: 1, f65: 1, f66: 1, f67: 1, f68: 1, f69: 1, f70: 1, f71: 1, f72: 1, f73: 1, f74: 1, f75: 1, f76: 1, f77: 1, f78: 1, f79: 1, f80: 1, f81: 1, f82: 1, f83: 1, f84: 1, f85: 1, f86: 1, f87: 1, f88: 1, f89: 1, f90: 1, f91: 1, f92: 1, f93: 1, f94: 1, f95: 1, f96: 1, f97: 1, f98: 1, f99: 1, f100: 1, f101: 1, f102: 1, f103: 1, f104: 1, f105: 1, f106: 1, f107: 1, f108: 1, f109: 1, f110: 1, f111: 1, f112: 1, f113: 1, f114: 1, f115: 1, f116: 1, f117: 1, f118: 1, f119: 1, f120: 1, f121: 1, f122: 1, f123: 1, f124: 1, f125: 1, f126: 1, f127: 1, f128: 1, f129: 1 };
print_int(big.f0);
default:
print("skip");
}
i = i + 1;
}
print("done");
return 0;
}

View file

@ -0,0 +1,3 @@
1
1
1

View file

@ -0,0 +1,161 @@
-- haxe-parity Task 3, review fix (Critical 3): `let w = switch ...`
-- with NO `: Type` annotation used to leak every single iteration --
-- owner.ml's `expr_ty` returned `None` for a `Switch` (a deliberate,
-- but wrong, "not chased" call from the original task), and
-- `analyze_let`'s own fallback for `expr_ty = None` is `Scalar "Int"`
-- (Copy-classified, never dropped) -- so an unannotated switch-valued
-- `let` binding a plain class (no union involved) was silently never
-- freed. `Big` has 130 Int fields (the same arena-ceiling trick
-- lang-break-owned-drop/lang-switch-arm-drop already use: 1040 bytes
-- crosses the 1024-byte size-class ceiling, so a missed DROP is a
-- hard, observable ASan leak, not a silent miss lost in the arena's
-- own small-object reuse). RED (fix disabled): 3168 bytes leaked
-- across the 3 loop iterations, one object each, confirmed under
-- `runtime/build/wovm_asan` (task-3-report.md has the transcript).
-- GREEN (fix restored, this fixture): clean under both plain `wovm`
-- and `wovm_asan`.
class Big {
f0: Int
f1: Int
f2: Int
f3: Int
f4: Int
f5: Int
f6: Int
f7: Int
f8: Int
f9: Int
f10: Int
f11: Int
f12: Int
f13: Int
f14: Int
f15: Int
f16: Int
f17: Int
f18: Int
f19: Int
f20: Int
f21: Int
f22: Int
f23: Int
f24: Int
f25: Int
f26: Int
f27: Int
f28: Int
f29: Int
f30: Int
f31: Int
f32: Int
f33: Int
f34: Int
f35: Int
f36: Int
f37: Int
f38: Int
f39: Int
f40: Int
f41: Int
f42: Int
f43: Int
f44: Int
f45: Int
f46: Int
f47: Int
f48: Int
f49: Int
f50: Int
f51: Int
f52: Int
f53: Int
f54: Int
f55: Int
f56: Int
f57: Int
f58: Int
f59: Int
f60: Int
f61: Int
f62: Int
f63: Int
f64: Int
f65: Int
f66: Int
f67: Int
f68: Int
f69: Int
f70: Int
f71: Int
f72: Int
f73: Int
f74: Int
f75: Int
f76: Int
f77: Int
f78: Int
f79: Int
f80: Int
f81: Int
f82: Int
f83: Int
f84: Int
f85: Int
f86: Int
f87: Int
f88: Int
f89: Int
f90: Int
f91: Int
f92: Int
f93: Int
f94: Int
f95: Int
f96: Int
f97: Int
f98: Int
f99: Int
f100: Int
f101: Int
f102: Int
f103: Int
f104: Int
f105: Int
f106: Int
f107: Int
f108: Int
f109: Int
f110: Int
f111: Int
f112: Int
f113: Int
f114: Int
f115: Int
f116: Int
f117: Int
f118: Int
f119: Int
f120: Int
f121: Int
f122: Int
f123: Int
f124: Int
f125: Int
f126: Int
f127: Int
f128: Int
f129: Int
}
fn main() -> Int {
let i = 0;
while i < 3 {
let w = switch i {
case 0: Big { f0: 1, f1: 1, f2: 1, f3: 1, f4: 1, f5: 1, f6: 1, f7: 1, f8: 1, f9: 1, f10: 1, f11: 1, f12: 1, f13: 1, f14: 1, f15: 1, f16: 1, f17: 1, f18: 1, f19: 1, f20: 1, f21: 1, f22: 1, f23: 1, f24: 1, f25: 1, f26: 1, f27: 1, f28: 1, f29: 1, f30: 1, f31: 1, f32: 1, f33: 1, f34: 1, f35: 1, f36: 1, f37: 1, f38: 1, f39: 1, f40: 1, f41: 1, f42: 1, f43: 1, f44: 1, f45: 1, f46: 1, f47: 1, f48: 1, f49: 1, f50: 1, f51: 1, f52: 1, f53: 1, f54: 1, f55: 1, f56: 1, f57: 1, f58: 1, f59: 1, f60: 1, f61: 1, f62: 1, f63: 1, f64: 1, f65: 1, f66: 1, f67: 1, f68: 1, f69: 1, f70: 1, f71: 1, f72: 1, f73: 1, f74: 1, f75: 1, f76: 1, f77: 1, f78: 1, f79: 1, f80: 1, f81: 1, f82: 1, f83: 1, f84: 1, f85: 1, f86: 1, f87: 1, f88: 1, f89: 1, f90: 1, f91: 1, f92: 1, f93: 1, f94: 1, f95: 1, f96: 1, f97: 1, f98: 1, f99: 1, f100: 1, f101: 1, f102: 1, f103: 1, f104: 1, f105: 1, f106: 1, f107: 1, f108: 1, f109: 1, f110: 1, f111: 1, f112: 1, f113: 1, f114: 1, f115: 1, f116: 1, f117: 1, f118: 1, f119: 1, f120: 1, f121: 1, f122: 1, f123: 1, f124: 1, f125: 1, f126: 1, f127: 1, f128: 1, f129: 1 };
default: Big { f0: 1, f1: 1, f2: 1, f3: 1, f4: 1, f5: 1, f6: 1, f7: 1, f8: 1, f9: 1, f10: 1, f11: 1, f12: 1, f13: 1, f14: 1, f15: 1, f16: 1, f17: 1, f18: 1, f19: 1, f20: 1, f21: 1, f22: 1, f23: 1, f24: 1, f25: 1, f26: 1, f27: 1, f28: 1, f29: 1, f30: 1, f31: 1, f32: 1, f33: 1, f34: 1, f35: 1, f36: 1, f37: 1, f38: 1, f39: 1, f40: 1, f41: 1, f42: 1, f43: 1, f44: 1, f45: 1, f46: 1, f47: 1, f48: 1, f49: 1, f50: 1, f51: 1, f52: 1, f53: 1, f54: 1, f55: 1, f56: 1, f57: 1, f58: 1, f59: 1, f60: 1, f61: 1, f62: 1, f63: 1, f64: 1, f65: 1, f66: 1, f67: 1, f68: 1, f69: 1, f70: 1, f71: 1, f72: 1, f73: 1, f74: 1, f75: 1, f76: 1, f77: 1, f78: 1, f79: 1, f80: 1, f81: 1, f82: 1, f83: 1, f84: 1, f85: 1, f86: 1, f87: 1, f88: 1, f89: 1, f90: 1, f91: 1, f92: 1, f93: 1, f94: 1, f95: 1, f96: 1, f97: 1, f98: 1, f99: 1, f100: 1, f101: 1, f102: 1, f103: 1, f104: 1, f105: 1, f106: 1, f107: 1, f108: 1, f109: 1, f110: 1, f111: 1, f112: 1, f113: 1, f114: 1, f115: 1, f116: 1, f117: 1, f118: 1, f119: 1, f120: 1, f121: 1, f122: 1, f123: 1, f124: 1, f125: 1, f126: 1, f127: 1, f128: 1, f129: 1 };
};
print_int(w.f0);
i = i + 1;
}
return 0;
}

View file

@ -0,0 +1,3 @@
one
two
many

View file

@ -0,0 +1,27 @@
-- haxe-parity Task 3, review fix (Critical 1): `default` textually
-- BEFORE some `case` arms used to silently kill every arm after it --
-- `default` has no comparison of its own, so lowering the arms in raw
-- source order meant nothing ever jumped into a `case` written after
-- it, and `default`'s own body jumped straight past it to the
-- switch's exit. Reviewer-reproduced repro shape, verbatim: with the
-- bug, `classify(2)` returned "many" (default's own value) instead of
-- "two". Fixed by lowering `default` last regardless of source
-- position (ast.ml's `switch_lowering_order`); this fixture pins the
-- corrected *runtime* behavior (a `--dump-owner`/typecheck assertion
-- can't see this — only actually running the compare-and-jump chain
-- proves the arm was reachable again).
fn classify(n: Int) -> Text {
let v = switch n {
default: "many";
case 1: "one";
case 2: "two";
};
return v;
}
fn main() -> Int {
print(classify(1));
print(classify(2));
print(classify(3));
return 0;
}

View file

@ -0,0 +1,3 @@
one
two
many

View file

@ -0,0 +1,27 @@
-- haxe-parity Task 3: `switch` in statement position -- "one construct,
-- not two" (the brief's own words): this is the exact same `Ast.Switch`
-- node as the value-yielding fixture, just reached as a bare
-- `ExprStmt`, its value discarded. Each arm ends in a plain
-- `print(...)` (an `ExprStmt`, not `return`) so this exercises the
-- real discard-mode write-then-ignore path -- the register-aliasing
-- bug this task's own ASan fixture (lang-switch-arm-drop) found lived
-- exactly here (a statement-position switch's own placeholder `dst`
-- colliding with an arm's own register), so a plain, ownership-free
-- version of the same shape is worth pinning on its own.
fn describe(n: Int) {
switch n {
case 1:
print("one");
case 2:
print("two");
default:
print("many");
}
}
fn main() -> Int {
describe(1);
describe(2);
describe(3);
return 0;
}

View file

@ -0,0 +1,9 @@
OK
Gone
Gone
Server Error
Unknown
hi alice
hi bob
hi bob
hi stranger

View file

@ -0,0 +1,38 @@
-- haxe-parity Task 3: `switch` as an expression -- value-yielding over
-- both subjects this task covers (Int, Text), proving arm selection
-- (each case fires only for its own value, multi-value `case a, b:`
-- fires for either) and the required `default` fires for anything
-- else. `status_of`/`greet` mirror the sample's own shape
-- (docs/examples/log-watcher/mcp.wo's `status_text`/`dispatch`) closely
-- enough to be a real proof, not a toy: a `let`-bound switch result,
-- multiple values per case, `default` last.
fn status_of(code: Int) -> Text {
let text = switch code {
case 200: "OK";
case 404, 410: "Gone";
case 500: "Server Error";
default: "Unknown";
};
return text;
}
fn greet(name: Text) -> Text {
return switch name {
case "alice": "hi alice";
case "bob", "bobby": "hi bob";
default: "hi stranger";
};
}
fn main() -> Int {
print(status_of(200));
print(status_of(404));
print(status_of(410));
print(status_of(500));
print(status_of(1));
print(greet("alice"));
print(greet("bob"));
print(greet("bobby"));
print(greet("carol"));
return 0;
}

View file

@ -0,0 +1,5 @@
7
seven
1
one
noted

Some files were not shown because too many files have changed in this diff Show more