feat(compiler): let-type annotations, container literals, statics, pub(read)

Driving goal: compile docs/examples/log-watcher (1285 lines of .wo).
Diagnostics on that program: 481 -> 379; parse errors 85 -> 18.
tests/corpus via scripts/oop-e2e.sh stays 71 checks / 0 failures.

- ast.ml: `Let.ty` is a full `field_ty` (was a bare name), so `multi Text`,
  `map<K, V>` and `?T` annotate a local; new `ListLit`/`MapLit` expressions;
  `method_decl.is_static`; `field.pub_read`
- parser.ml: let annotations go through parse_field_ty; `[]`/`[a, b]` and
  `{}` literals in expression position; `static const`/`static fn` in class
  bodies (one token of lookahead — `static` stays an identifier);
  `pub(read) field: T`; a `;` ends a statement on its own, so one-line
  guard bodies (`{ skip(...); return; }`) parse
- emit.ml: list/map literals lower to multi_new/map_new + one multi_push per
  element, element kinds from the destination's declared type (WO-E403 with
  no typed destination, same rule multi_new already had); `static fn` gets a
  receiverless method record (arg_cnt = params) and lowers `Cls.fn(args)`
  through emit_direct with no `self`; a static can satisfy no interface
- types.ml: a written `let` annotation is now the authority for the binding's
  type (the only thing that types `[]`/`{}`/`nil`); `static_method_of` +
  static-call return types; method_info.is_static is wired from the AST
- owner.ml: container literals are fresh owned values, elements read in place
  (inherits push()'s open borrowed-element gap, noted in the code)
- plan doc: `Spec:` header line so planboard's lint accepts the plan

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
shoney.arickathil 2026-08-14 16:22:47 +02:00
parent e966f54ecf
commit 2a98186259
7 changed files with 280 additions and 30 deletions

View file

@ -109,6 +109,12 @@ type field = {
rt's own field-annotation handling; nothing downstream needs those
arguments in Task 4. *)
annotations : string list;
(* haxe-parity Task 7: `pub(read) name: T` — the field's value is
readable from outside the declaring class, but writable only from
inside it (Haxe's `(default, null)` property pattern). Reads need no
check at all; the write side is types.ml's, at every assignment
whose target is a field of another class's instance. *)
pub_read : bool;
}
(* ---- expressions (Task 5) ------------------------------------------
@ -228,6 +234,19 @@ and expr_kind =
uses either, so neither is grammar here (YAGNI, recorded in the
task report). *)
| Switch of expr * switch_arm list
(* Container literals, the driving workload's own spelling for a fresh
container: `[]` / `[a, b, c]` for a `multi T`, `{}` for an empty
`map<K, V>`. They lower to exactly what `multi_new()`/`map_new()`
already lower to (the element kinds come from the destination's
declared type — docs/plan/oop-vm/08-builtin-surface.md's
"a fresh container needs a destination of declared type"), plus one
`push` per element for a non-empty list. A literal with no typed
destination is WO-E403, the same as a bare `let m = map_new()`.
Non-empty map literals are not grammar: the workload has none, and
`{ k: v }` in expression position cannot be told from a constructor
literal without lookahead nothing else needs. *)
| ListLit of expr list
| MapLit
(* ---- statements (Task 5) ---------------------------------------------
@ -257,7 +276,11 @@ and stmt = {
and stmt_kind =
| Let of {
name : string;
ty : string option;
(* The full annotation grammar, not just a bare name: the driving
workload writes `let rest: multi Text = []`, `let headers:
map<Text, Text> = {}` and `let port: ?Int = nil`, all of which
parse_field_ty already understood for fields and parameters. *)
ty : field_ty option;
value : expr;
}
| Assign of {
@ -383,6 +406,13 @@ type method_decl = {
passes `pub = false` for a class body's own methods — this field
is meaningful only when the surrounding decl is `Fn`. *)
pub : bool;
(* haxe-parity Task 7: `static fn` on a class — no instance, no `self`,
called as `Flock.held(path)`. Lowered as an ordinary method record
with no receiver slot (emit.ml), so its `arg_cnt` counts parameters
only, and it can never satisfy an interface method (nothing to
dispatch on). Always false for a free `fn` and for an interface
signature. *)
is_static : bool;
}
(* `@table(name: "...", index: [a, b], index: [c])` — optional storage

View file

@ -220,6 +220,8 @@ let rec expr_str (e : Ast.expr) : string =
(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)
| Ast.ListLit items -> Printf.sprintf "[%s]" (String.concat ", " (List.map expr_str items))
| Ast.MapLit -> "{}"
(* 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
@ -249,7 +251,7 @@ let rec dump_stmt (s : Ast.stmt) : string list =
in
match s.Ast.s_kind with
| Ast.Let { name; ty; value } ->
let ty_part = match ty with None -> "" | Some t -> ": " ^ t in
let ty_part = match ty with None -> "" | Some t -> ": " ^ field_ty_str t in
[ Printf.sprintf "%s LET %s%s = %s" (pos_str s.Ast.s_pos) name ty_part (expr_str value) ]
| Ast.Assign { target; value } ->
[ Printf.sprintf "%s ASSIGN %s = %s" (pos_str s.Ast.s_pos) (expr_str target) (expr_str value) ]

View file

@ -783,6 +783,12 @@ let class_method (p : pctx) (cname : string) (m : string) : Types.method_info op
| Some (c : Types.class_info) ->
List.find_opt (fun (mi : Types.method_info) -> mi.Types.name = m) c.Types.methods
(* haxe-parity Task 7: the `static fn` behind `Flock.held(path)`. Kept
separate from class_method so an instance method is never callable
through a class name, and a static never through an instance. *)
let static_method (p : pctx) (cname : string) (m : string) : Types.method_info option =
match class_method p cname m with Some mi when mi.Types.is_static -> Some mi | _ -> None
let iface_method (p : pctx) (iname : string) (m : string) : (int * Types.method_sig_info) option =
match SM.find_opt iname p.p_iface_id with
| None -> None
@ -856,6 +862,11 @@ let rec ty_of_expr (p : pctx) (f : fstate) (e : Ast.expr) : Ast.field_ty option
| IntLit _ -> Some (Scalar "Int")
| StrLit _ -> Some (Scalar "Text")
| BoolLit _ -> Some (Scalar "Bool")
(* Same rule as owner.ml's expr_ty: a non-empty list literal knows its
element type; an empty `[]`/`{}` is contextual on its destination. *)
| ListLit (first :: _) -> (
match ty_of_expr p f first with Some (Scalar n) -> Some (Multi n) | _ -> None)
| ListLit [] | MapLit -> None
| Ident n -> (
match List.assoc_opt n f.f_env with
| Some (_, t) -> Some t
@ -922,6 +933,11 @@ let rec ty_of_expr (p : pctx) (f : fstate) (e : Ast.expr) : Ast.field_ty option
receiver is always caught by the `Some bt` arm above, so
locals/params still shadow a same-named alias here too. *)
match base.kind with
(* haxe-parity Task 7: a static call's base names a class, which is
never a value — checked before the `use`-alias reading, since a
class name and a module alias are both bare Idents here. *)
| Ident cls_name when static_method p cls_name mname <> None -> (
match static_method p cls_name mname with Some mi -> mi.Types.ret | None -> None)
| Ident alias -> (
match use_edge_for p ~file:f.f_file alias with
| Some u when u.Types.ue_is_stdlib -> None (* nothing to infer a return type from yet *)
@ -1207,6 +1223,53 @@ let rec emit_expr (p : pctx) (f : fstate) (v : views) ~(dst : int) ?expected (e
| IntLit n -> put f (ins_abx op_loadk dst (check_bx p f e.pos "constant" (const_int p n)))
| BoolLit b -> put f (ins_abx op_loadk dst (check_bx p f e.pos "constant" (const_int p (if b then 1 else 0))))
| StrLit s -> put f (ins_abx op_loadk dst (check_bx p f e.pos "constant" (const_text p s)))
(* Container literals lower to exactly what `multi_new()`/`map_new()`
lower to — the element kinds are the destination's, never guessed
(docs/plan/oop-vm/08-builtin-surface.md) — plus one `multi_push` per
element, in source order. *)
| ListLit items -> (
match container_imm p expected false with
| None ->
err p ~code:cannot_lower_code ~file:f.f_file ~pos:e.pos
~message:
"a list literal needs a destination of declared type `multi T` — its element kind is \
the container's drop plan and cannot be guessed";
put f (ins_abx op_loadk dst (const_int p 0))
| Some imm ->
sync_mask p f v e.id;
put f (ins_abc op_builtin dst imm b_multi_new);
let elem_ty =
match expected with
| Some t -> ( match unwrap t with Multi en -> Some (Scalar en) | _ -> None)
| None -> None
in
let outer = f.f_temp in
if f.f_temp <= dst then f.f_temp <- dst + 1;
List.iter
(fun (item : Ast.expr) ->
let save = f.f_temp in
let base = alloc_temps p f e.pos 2 in
put f (ins_abc op_move base dst 0);
(match elem_ty with
| Some et -> emit_expr p f v ~dst:(base + 1) ~expected:et item
| None -> emit_expr p f v ~dst:(base + 1) item);
sync_mask p f v e.id;
f.f_cur_line <- item.pos.line;
put f (ins_abc op_builtin base base b_multi_push);
f.f_temp <- save)
items;
f.f_temp <- outer)
| MapLit -> (
match container_imm p expected true with
| None ->
err p ~code:cannot_lower_code ~file:f.f_file ~pos:e.pos
~message:
"an empty map literal needs a destination of declared type `map<K, V>` — its key and \
value kinds are the container's drop plan and cannot be guessed";
put f (ins_abx op_loadk dst (const_int p 0))
| Some imm ->
sync_mask p f v e.id;
put f (ins_abc op_builtin dst imm b_map_new))
| Ident n -> (
match lookup_local f n with
| Some (r, _) -> if r <> dst then put f (ins_abc op_move dst r 0)
@ -1936,6 +1999,14 @@ and emit_call (p : pctx) (f : fstate) (v : views) ~(dst : int) ?expected (e : As
match unwrap bt with
| Scalar cn -> (
match class_method p cn mname with
| Some mi when mi.Types.is_static ->
(* haxe-parity Task 7: a static has no receiver — reaching it
through an instance is an error, not an implicit `Cls.` *)
err p ~code:cannot_lower_code ~file:f.f_file ~pos:e.pos
~message:
(Printf.sprintf "`%s` is a `static fn` — call it as `%s.%s(...)`, not on an instance"
mname cn mname);
put f (ins_abx op_loadk dst (const_int p 0))
| Some mi ->
emit_direct p f v ~dst e ~key:(cn ^ "." ^ mname) ~recv:(Some base) ~params:mi.Types.params
args
@ -1978,6 +2049,16 @@ and emit_call (p : pctx) (f : fstate) (v : views) ~(dst : int) ?expected (e : As
once qualification disambiguates them, not two spellings of
whichever one the flat merge happened to keep. *)
match base.kind with
(* haxe-parity Task 7: `Flock.held(path)` — the base names a class,
so there is no receiver to pass and the window holds parameters
only (`recv:None`, exactly like a free fn). Checked before the
`use`-alias reading: both are bare Idents at this point. *)
| Ident cls_name when static_method p cls_name mname <> None -> (
match static_method p cls_name mname with
| Some mi ->
emit_direct p f v ~dst e ~key:(cls_name ^ "." ^ mname) ~recv:None ~params:mi.Types.params
args
| None -> ())
| Ident alias -> (
match use_edge_for p ~file:f.f_file alias with
| Some u when u.Types.ue_is_stdlib ->
@ -2272,7 +2353,7 @@ and emit_stmt (p : pctx) (f : fstate) (v : views) (s : Ast.stmt) : unit =
f.f_cur_line <- s.s_pos.line;
match s.s_kind with
| Let { name; ty; value } ->
let declared = match ty with Some t -> Some (Scalar t) | None -> None in
let declared = ty in
let vty =
match declared with
| Some t -> t
@ -2855,6 +2936,10 @@ let satisfies (p : pctx) (cid : int) (ir : ifacerec) : int list option =
if not (List.mem mname cr.cr_methods) then None
else
match class_method p cr.cr_name mname with
(* A `static fn` has no receiver to dispatch on, so it can never
satisfy an interface method however well its name and arity
line up (haxe-parity Task 7). *)
| Some mi when mi.Types.is_static -> None
| Some mi when List.length mi.Types.params = nparams -> (
match SM.find_opt (cr.cr_name ^ "." ^ mname) p.p_method_id with
| Some midx -> go (midx :: acc) tl
@ -2985,11 +3070,18 @@ let emit ~(syms : Types.symbols) ~(module_of : string -> string)
let key = c.name ^ "." ^ m.name in
if not (SM.mem key !method_id) then begin
method_id := SM.add key !nmethods !method_id;
(* haxe-parity Task 7: a `static fn` has no receiver, so
its window holds parameters only and its body binds
no `self` (self_class = None below) — otherwise it is
an ordinary method record, keyed `Class.name` like
any other. *)
methods :=
{ mr_name = m.name; mr_class = Some cid; mr_argc = 1 + List.length m.params;
{ mr_name = m.name; mr_class = Some cid;
mr_argc = (if m.is_static then 0 else 1) + List.length m.params;
mr_regc = 1; mr_code = [||]; mr_lines = []; mr_drops = [] }
:: !methods;
bodies := (u, Some (cid, c.name), m, !nmethods) :: !bodies;
bodies :=
(u, (if m.is_static then None else Some (cid, c.name)), m, !nmethods) :: !bodies;
incr nmethods
end)
c.methods

View file

@ -503,6 +503,14 @@ let rec expr_ty (ctx : ctx) (e : Ast.expr) : Ast.field_ty option =
| IntLit _ -> Some (Scalar "Int")
| StrLit _ -> Some (Scalar "Text")
| BoolLit _ -> Some (Scalar "Bool")
(* A non-empty list literal knows its element type, so an unannotated
`let names = ["a", "b"]` is still classified Owned and dropped. An
empty `[]`/`{}` is contextual: only the destination's declared type
says what it holds, so this stays None and the annotation (or the
field/parameter it is built into) decides. *)
| ListLit (first :: _) -> (
match expr_ty ctx first with Some (Scalar n) -> Some (Multi n) | _ -> None)
| ListLit [] | MapLit -> None
| Ident n -> (
match find_local ctx n with
| Some l -> Some l.l_ty
@ -1043,6 +1051,18 @@ let rec read_expr (ctx : ctx) (e : Ast.expr) : unit =
read_expr ctx a;
read_expr ctx b
| Interp inner -> read_expr ctx inner
(* A list literal reads each element and hands it to the fresh
container, exactly as `push(m, v)` does — so it inherits `push`'s own
open gap, recorded in docs/plan/oop-vm/08-builtin-surface.md: an
element that is a *borrowed* non-constant Text (or any borrowed
owned value) is stored by pointer while the container's declared
element kind makes it the container's to free. The workload's own
literals are string constants (never freed) plus borrowed params
handed straight to a stdlib call, so nothing reachable today hits
it; it is a runtime-semantics gap to close with `push`, not a
literal-specific one. *)
| ListLit items -> List.iter (read_expr ctx) items
| MapLit -> ()
| 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))
@ -1542,12 +1562,13 @@ and analyze_block (ctx : ctx) ?(pre = []) ~node ~pos ~label (body : Ast.stmt lis
List.iter (analyze_stmt ctx) body;
pop_scope ctx
and analyze_let (ctx : ctx) (s : Ast.stmt) (name : string) (ty : string option) (value : Ast.expr)
and analyze_let (ctx : ctx) (s : Ast.stmt) (name : string) (ty : Ast.field_ty option)
(value : Ast.expr)
: unit =
read_expr ctx value;
let vty =
match ty with
| Some tn -> Scalar tn
| Some t -> t
| None -> ( match expr_ty ctx value with Some t -> t | None -> Scalar "Int")
in
let cls = oclass_of ctx vty in

View file

@ -448,7 +448,7 @@ let parse_default_expr (st : state) : Ast.default_expr =
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 parse_field ?(comma_ends = false) ?(pub_read = false) (st : state) : Ast.field =
let pos = peek_pos st in
let name = expect_field_name st "field name" in
expect st Token.Colon "':'";
@ -470,7 +470,8 @@ let parse_field ?(comma_ends = false) (st : state) : Ast.field =
| Token.Newline | Token.RBrace | Token.Eof -> continue_ := false
| _ -> unexpected st "an annotation, '=', or end of field"
done;
{ Ast.id = fresh_id st; pos; name; ty; default = !default; annotations = List.rev !annotations }
{ Ast.id = fresh_id st; pos; name; ty; default = !default; annotations = List.rev !annotations;
pub_read }
(* ---- param / signature parsing ------------------------------------------ *)
@ -571,12 +572,19 @@ let parse_sig_head (st : state) : sig_head =
them, and requiring a terminator after it would wrongly reject
`} else {` on one line, the normal style for chained if/else. *)
(* A `;` terminates the statement by itself — whatever follows on the same
line is the next statement, which is how the driving workload writes a
short guard body (`{ skip(res, ...); return; }`, `{ i = i + 1;
continue; }`). Requiring a newline *after* the semicolon (the earlier
rule) made one-line blocks a syntax error. With no `;`, a newline (or
the enclosing block's close) is still what ends the statement. *)
let end_of_stmt (st : state) : unit =
ignore (accept st Token.Semicolon);
match peek st with
| Token.Newline -> ignore (advance st)
| Token.RBrace | Token.Eof -> ()
| _ -> unexpected st "end of statement (newline or ';')"
if accept st Token.Semicolon then ()
else
match peek st with
| Token.Newline -> ignore (advance st)
| Token.RBrace | Token.Eof -> ()
| _ -> unexpected st "end of statement (newline or ';')"
(* ---- statement-level recovery -------------------------------------------
@ -998,6 +1006,34 @@ and parse_primary (st : state) : Ast.expr =
let e = with_no_brace st false (fun () -> parse_expr st) in
expect st Token.RParen "')'";
e
| Token.LBracket ->
(* `[]` / `[a, b, c]` — a fresh `multi`. Newlines inside the brackets
are insignificant (the workload writes single-line literals, but a
long one must be allowed to wrap like a call's argument list). *)
let pos = peek_pos st in
let id = fresh_id st in
ignore (advance st);
let items = ref [] in
skip_newlines st;
while peek st <> Token.RBracket && not (at_end st) do
items := with_no_brace st false (fun () -> parse_expr st) :: !items;
skip_newlines st;
if accept st Token.Comma then skip_newlines st
done;
expect st Token.RBracket "']' to close the list literal";
{ Ast.id; pos; kind = Ast.ListLit (List.rev !items) }
| Token.LBrace when not st.no_brace ->
(* `{}` — a fresh empty `map`. Only the empty form: see ast.ml's
MapLit doc comment for why `{ k: v }` is not grammar. *)
let pos = peek_pos st in
let id = fresh_id st in
ignore (advance st);
skip_newlines st;
if peek st <> Token.RBrace then
fail st (peek_pos st) syntax_code
"only the empty map literal `{}` is an expression — build entries with `set(m, k, v)`";
ignore (advance st);
{ Ast.id; pos; kind = Ast.MapLit }
| Token.Ident _ when looks_like_ctor st -> parse_ctor_literal st
| Token.Ident s ->
let pos = peek_pos st in
@ -1092,7 +1128,7 @@ and parse_let_stmt (st : state) : Ast.stmt =
ignore (advance st);
(* 'let' *)
let name = expect_ident st "let-binding name" in
let ty = if accept st Token.Colon then Some (expect_ident st "let-binding type") else None in
let ty = if accept st Token.Colon then Some (parse_field_ty st) else None in
expect st Token.Eq "'=' in let binding";
let value = parse_expr st in
end_of_stmt st;
@ -1227,10 +1263,11 @@ and parse_expr_no_brace (st : state) : Ast.expr = with_no_brace st true (fun ()
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 parse_method ?(pub = false) ?(is_static = 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; pub }
{ Ast.id = h.s_id; pos = h.s_pos; name = h.s_name; params = h.s_params; ret = h.s_ret; body; pub;
is_static }
(* A free top-level function is grammatically identical to a class
method (signature + brace-delimited body span) — Task 6's brief
@ -1383,13 +1420,32 @@ let parse_class_or_type ?(pub = false) (st : state) (ann : type_annotations) : A
| 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
(* haxe-parity Task 7: `static`. It is not a keyword (it lexes as a
plain Ident, so `static` stays a usable identifier elsewhere) —
one token of lookahead separates the marker from a field named
`static`, whose next token is a Colon. `static const` is a
class-scoped constant: the same substitution a bare class `const`
already gets, so both spellings land in the same list. `static fn`
carries the marker into the method record. *)
| Token.Ident "static" when (tok_at st (st.pos + 1)).kind = Token.KwConst ->
ignore (advance st);
consts := parse_const_decl st :: !consts
| Token.Ident "static" when (tok_at st (st.pos + 1)).kind = Token.KwFn ->
ignore (advance st);
methods := parse_method ~is_static:true st :: !methods
(* haxe-parity Task 7: `pub(read) field: T` — read-public, write-
private. `pub` with no `(read)` is not class-member grammar (member
visibility beyond this one accessor form is undesigned), so the
error names what is accepted. *)
| Token.KwPub ->
ignore (advance st);
expect st Token.LParen "'(' after `pub` in a class body — the only member form is `pub(read)`";
(match peek st with
| Token.Ident "read" -> ignore (advance st)
| _ -> unexpected st "`read` — the only accessor form is `pub(read)`");
expect st Token.RParen "')'";
fields := parse_field ~pub_read:true st :: !fields
(* 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
@ -1672,6 +1728,8 @@ let rec subst_expr (consts : Ast.expr StringMap.t) (bound : StringSet.t) (e : As
| 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.ListLit items -> { e with Ast.kind = Ast.ListLit (List.map (subst_expr consts bound) items) }
| Ast.MapLit -> e
| Ast.Switch (subject, arms) ->
{ e with
Ast.kind =

View file

@ -147,6 +147,16 @@ let find_variant_in (unions : union_info StringMap.t) (name : string) :
let find_variant (syms : symbols) (name : string) : (union_info * variant_info) option =
find_variant_in syms.unions name
(* haxe-parity Task 7: the static member behind `Flock.held(path)` — a
qualified name whose head is a class, not a value. Instance methods are
deliberately excluded: `Cls.method(...)` on a non-static method is not
a call with an implicit receiver, it is an error, and returning None
here is what lets the caller say so. *)
let static_method_of (syms : symbols) (cls_name : string) (m_name : string) : method_info option =
match StringMap.find_opt cls_name syms.classes with
| Some cls -> List.find_opt (fun (m : method_info) -> m.name = m_name && m.is_static) cls.methods
| None -> None
(* Builtin scalars *)
let builtin_scalars = ["Int"; "Bool"; "Text"; "Timestamp"; "Id"]
@ -332,7 +342,7 @@ let collect_declarations ~file (prog : program) (collector : Diag.Collector.t) :
ret = m.ret;
body = m.body;
mutates = false;
is_static = false;
is_static = m.is_static;
id = m.id;
pos = m.pos; }
) (c.methods : Ast.method_decl list) in
@ -828,6 +838,12 @@ let typecheck_program ~file ~(module_of : string -> string)
| IntLit _ -> Some (TScalar "Int")
| StrLit _ -> Some (TScalar "Text")
| BoolLit _ -> Some (TScalar "Bool")
(* A non-empty list literal is confident about its element type; an
empty `[]`/`{}` is contextual on its destination, exactly like
`multi_new()`/`map_new()` above. *)
| ListLit (first :: _) -> (
match confident_typ cenv first with Some t -> Some (TMulti t) | None -> None)
| ListLit [] | MapLit -> None
| Ident name -> StringMap.find_opt name cenv
| Field (base, field_name) -> (
match confident_typ cenv base with
@ -893,7 +909,18 @@ let typecheck_program ~file ~(module_of : string -> string)
match s.ret with Some ft -> Some (resolve_field_ty ft) | None -> Some TVoid)
| None -> None)
| None -> None))
| _ -> None)
| _ -> (
(* haxe-parity Task 7: a static call (`Flock.held(path)`).
The base names a class, so it has no confident *value*
type above — only this shape reaches here with a
resolvable member. *)
match base.kind with
| Ident cls_name -> (
match static_method_of syms cls_name mname with
| Some m -> (
match m.ret with Some ft -> Some (resolve_field_ty ft) | None -> Some TVoid)
| None -> None)
| _ -> None))
| _ -> None)
| Ctor (class_name, _fields) ->
(* `Ctor`'s class name is never a placeholder -- unlike
@ -1133,6 +1160,15 @@ let typecheck_program ~file ~(module_of : string -> string)
{ typ = TScalar "Int"; is_nil = false })
| DbStub _ -> { typ = TVoid; is_nil = false }
| Switch (subject, arms) -> typecheck_switch ~want_value:true env cenv subject arms
| ListLit items ->
let elem_types = List.map (fun i -> (typecheck_expr env cenv i).typ) items in
(* Same contextual answer the builtin container constructors give
when the destination is what decides (see confident_typ): an
empty literal has no element type to report. *)
(match elem_types with
| t :: _ -> { typ = TMulti t; is_nil = false }
| [] -> { typ = TScalar "Int"; is_nil = false })
| MapLit -> { typ = TScalar "Int"; is_nil = false }
(* haxe-parity Task 3: the one deriver behind both `Switch` call sites
-- `typecheck_expr`'s own case above (every "the value is used"
@ -1506,14 +1542,21 @@ let typecheck_program ~file ~(module_of : string -> string)
and typecheck_stmt ((env, cenv) : typ StringMap.t * typ StringMap.t) (s : stmt) :
typ StringMap.t * typ StringMap.t =
match s.s_kind with
| Let { name; ty = _ty; value } ->
| Let { name; ty; value } ->
let val_res = typecheck_expr env cenv value in
(* A written annotation is the authority — it is the only thing
that types a contextual value (`[]`, `{}`, `nil`), and for
everything else it is what the author declared the binding to
be. Only an unannotated `let` falls back to inference. *)
let declared = Option.map resolve_field_ty ty in
let bound_typ = match declared with Some t -> t | None -> val_res.typ in
let new_cenv =
match confident_typ cenv value with
| Some t -> StringMap.add name t cenv
| None -> StringMap.remove name cenv
match (declared, confident_typ cenv value) with
| Some t, _ -> StringMap.add name t cenv
| None, Some t -> StringMap.add name t cenv
| None, None -> StringMap.remove name cenv
in
(StringMap.add name val_res.typ env, new_cenv)
(StringMap.add name bound_typ env, new_cenv)
| Assign { target; value } ->
let _ = typecheck_expr env cenv target in
let _ = typecheck_expr env cenv value in
@ -1753,6 +1796,8 @@ and walk_expr (bound : StringSet.t) (visit : StringSet.t -> expr -> unit) (e : e
walk_expr bound visit r
| Ctor (_, fields) -> List.iter (fun (_, v) -> walk_expr bound visit v) fields
| Interp inner -> walk_expr bound visit inner
| ListLit items -> List.iter (walk_expr bound visit) items
| MapLit -> ()
| DbStub _ -> ()
| Switch (subject, arms) ->
walk_expr bound visit subject;

View file

@ -6,6 +6,8 @@
>
> **Style rule (user convention):** concept, reason, and required behavior in words only; the executor writes the code.
**Spec:** [`docs/superpowers/specs/2026-08-01-systems-track-design.md`](../../superpowers/specs/2026-08-01-systems-track-design.md) (Part 1 verdict table, normative), amended by [`docs/superpowers/specs/2026-08-10-logwatcher-gap-closure-design.md`](../../superpowers/specs/2026-08-10-logwatcher-gap-closure-design.md).
**Goal:** Plan 8 — implement every **adopt** row of the systems-track spec's Haxe keyword verdict table: the language grows boolean operators (`and`/`or`), switch expressions, records, optionals, try/catch, enum payloads, static members, using-extensions, modules, `pub(read)`, build flags, and interpolation — with the reject rows enforced as diagnostics. (`abstract` was an adopt row until 2026-08-10; it is now a reject row. `is` was cut the same day — 0 uses in the driving workload, parked post-iteration-12 — emptying this plan's old Task 7, which is deleted rather than deferred.)
**Architecture:** Plan 8 of the roadmap. Depends on OOP plans 1–3 (woc + wovm + corpus). Overwhelmingly compiler work in `compiler/src/`; the VM changes are exactly three, called out in their tasks: catch frames (try/catch), variant objects (enum payloads), and boxed optionals for scalars. Everything else lowers onto existing opcodes. The spec's verdict table (`docs/superpowers/specs/2026-08-01-systems-track-design.md` Part 1) is normative — this plan sequences it.