writeonce/compiler/src/owner.ml
shoney.arickathil c8c34c118c feat(compiler): update-through-row + delete statement (9b cont.)
- `e.field = v` where e is a table row lowers to DB_UPDATE_FIELD
  (class, id, field, value) — the row's indexes maintained at the choke
  point; heap-object field assignment still emits SETF unchanged
- `delete <row>` expression: DB_DELETE(class, id), yields the id so it
  composes in `try delete x catch (e) nil` (restrict/trap surfaces
  catchably); Delete AST node threaded through type/owner/dump/emit
- disassembly-caught bug fixed: in tail position dst == the builtin
  window's first reg, so moving the id into dst clobbered the class id
  — reserve dst past the window (the emit_ctor guard)
- run/db-update-delete fixture; oop-e2e up, woc-test 566/0,
  log-watcher 7/0
- employee seed/list/staff/raise/drop now compile+run; only `report`
  (group-by aggregation + projection record) remains

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 05:24:58 +02:00

2061 lines
92 KiB
OCaml

(* owner.ml — the ownership pass (mutable value semantics), Task 7.
This is the novel core of the design (spec section 3 rules 1-6 and
section 4's "borrow enforcement split"). One forward dataflow walk per
function body decides two things at once:
1. the four MVS *rules*, each reported as a two-site diagnostic
(WO-E301..WO-E304 — the codes are documented at their
definitions below);
2. the four *tables* plan 3's emitter consumes — moves, drops,
rc sites, residual sites — rendered by Dump.dump_owner and
pinned by goldens under compiler/test/golden/owner/.
---- what a "borrow" is in this grammar --------------------------------
There is no borrow *expression* syntax: `mut`/`take` are parameter
conventions only (parser.ml's parse_param). So a borrow is created in
exactly four ways, and every one of them is second-class (spec: "a
borrow cannot escape its scope"):
- a parameter declared without `take` (`p: T` shared, `mut p: T`
exclusive) — a borrow of the *caller's* value, live for the whole
body. Its source place is unknown here; the caller's own call site
is where aliasing between two such borrows is checked.
- `self` — always a borrow (spec rule 6: shared if the body only
reads, exclusive if it writes; inferred, see method_writes_self).
- `let r = <owned place with a projection>` (`let r = b.inner`) —
milestone 1 has no syntax for moving a value *out of* a field or
element, so this binds a borrow of that place, not a partial move.
Consequence: `return r` is a borrow escape, and moving the place
out from under `r` is a use-while-borrowed.
- `for it in items` — `it` borrows an element of `items`, and the
borrow is live for the whole loop body.
Argument passing creates borrows too, but only for the duration of the
call itself; that is the "region" of a call expression, and all of a
call's argument borrows plus its receiver borrow are live
simultaneously. Conflicts inside one region are checked pairwise.
---- states, joins, loops ---------------------------------------------
Per owned local: Live | Moved <site> | Borrowed <site> (the brief's
three states). Join at an if/else merge takes Moved over Live (a value
moved on one branch is treated as moved after the merge — the standard
conservative rule; a diverging branch, i.e. one that returned, is
dropped from the join instead of poisoning it). Loops are analyzed to
a fixpoint the cheap way the brief prescribes: run the body once with
diagnostics and table recording *off* (ctx.recording = false), join
that result with the loop's entry state, then run it again for real.
Two passes are enough because the lattice is three states tall and the
only transition the body can make is Live -> Moved; the second pass
therefore sees every loop-carried move (that is what catches
`while c { consume(take b) }`).
---- what is exempt ---------------------------------------------------
`@gc` values (Types.is_gc_class) are exempt from every rule — aliasing
them is legal by design (spec rule 5); they appear only in the rc
table. Builtin scalars and `ref T` ids are copied, never moved, and
are exempt too. `?T` follows T exactly
(nil is just a value; null-narrowing does not change ownership state),
so oclass_of unwraps Nullable first. An unknown type name is treated
as Copy: types.ml already reported WO-E225 for it and a cascade of
ownership errors on top would be noise.
---- deliberate milestone-1 simplifications ---------------------------
Each of these is a place where a *later* milestone adds precision, not
a place where this pass is wrong-by-accident:
- No partial moves. A move site must name a whole local (`x`), never
a projection (`x.f`, `x[0]`); see the `let` case above.
- Alias provability is syntactic *after canonicalization*: a place
written through a borrow binding is first rewritten to the storage
that borrow names (see canon), then two places overlap only if they
share a root and every projection pair matches. Distinct roots are
assumed disjoint — sound because a borrow can never be stored, so
two distinct locals can only alias through parameters, and that
aliasing is checked at the caller's call site. Canonicalization is
what makes `swap(r, s)` after `let r = bag.items[i]; let s =
bag.items[k]` behave identically to the direct
`swap(bag.items[i], bag.items[k])`.
- Two element accesses through *runtime* indices are neither proven
equal nor proven distinct: they become residual sites, which is
exactly the case runtime/src/borrow.c exists for.
- Self-mutability (spec rule 6) is inferred from direct assignments
to `self.…` in the method body only; a method that mutates self
*indirectly*, by calling another mutating method on self, is read
as read-only here. Types.method_info carries a `mutates` field but
it is hardcoded `false` (types.ml never infers it), so this pass
computes its own rather than trusting it.
- Reassigning a live owned local (`x = other`) drops the overwritten
value; that DROP is recorded in the drops table as OVERWRITE. The
brief names the table "scope-end drop sets", but an overwritten
value dies just as deterministically as one falling out of scope,
and leaving it out would leak in plan 3.
- A value moved on only one branch of an `if` is normalized rather
than tracked per path: the branch that kept it drops it at its own
end (JOIN-DROP), so the joined Moved state is true on both paths and
nothing leaks. The cost is that the value dies earlier on that path
than a drop-flag implementation would allow. Two drop caveats
survive that fix, both deliberate: a `while`/`for` body that moves
an outer local is an error anyway (the fixpoint's second pass
reports WO-E301), so no normalization is attempted there; and a
value whose *type* is unknown to the pass — the result of an
unresolved builtin call — classifies as Copy and so is never
dropped at all, which stays true until builtin signatures land. *)
open Ast
(* ============================================================
Diagnostics (WO-E3xx). Task 8 catalogs these.
============================================================ *)
(* WO-E301 — a local is read (or moved again) after its value was moved
away. Primary site: the use. Related site: the move. *)
let use_after_move_code = Diag.ownership_prefix ^ "01"
(* WO-E302 — a value is moved while a live borrow of it (or of something
it contains) still exists. Primary site: the move. Related: the
borrow. *)
let move_while_borrowed_code = Diag.ownership_prefix ^ "02"
(* WO-E303 — conflicting borrows of one place where the analysis can
prove the two accesses alias, and at least one is exclusive (`mut`).
Primary site: the second borrow. Related: the first. *)
let conflicting_borrow_code = Diag.ownership_prefix ^ "03"
(* WO-E304 — a borrow escapes its scope: returned, stored into a field,
or moved out to a `take` parameter (spec rule 3). Primary site: the
escape. Related: where the borrow was created. *)
let borrow_escape_code = Diag.ownership_prefix ^ "04"
(* ============================================================
Ownership classes and places
============================================================ *)
(* Copy — scalars, `ref T`, unknown types: copied,
never moved, no borrow rules, no drops.
Owned — a non-@gc class/type instance, `multi T`, `map<K,V>`: one
owner, moves, borrow-checked, dropped at scope end.
Gc — a @gc class instance: freely aliased, refcounted. *)
type oclass =
| Copy
| Owned
| Gc
(* A projection step. An index is split by what the analysis can prove
about it: a literal (PConst) is comparable with other literals; a
runtime index (PDyn) carries a *rendering* of the index expression, so
two syntactically identical runtime indices compare equal (same
element) and two different ones compare unknown (residual site). See
idx_text for why the fallback rendering is node-unique. *)
type proj =
| PField of string
| PConst of int
| PDyn of string
(* root is a local name ("self" included). ppos is the *root token's*
position — a caret under `bag` reads better than one under the `.` or
`[` that Ast.Field/Ast.Index carry. pnode is the outermost expression
node's id: the emitter's key for this site. *)
type place = {
root : string;
projs : proj list;
ppos : Ast.pos;
pnode : int;
}
let proj_text = function
| PField f -> "." ^ f
| PConst n -> "[" ^ string_of_int n ^ "]"
| PDyn s -> "[" ^ s ^ "]"
let place_text (p : place) : string =
p.root ^ String.concat "" (List.map proj_text p.projs)
(* How two places relate. Overlap = provably the same storage, or one
contains the other (a prefix). Disjoint = provably different storage.
Unknown = the analysis cannot decide; the VM must (residual site). *)
type rel =
| Overlap
| Disjoint
| Unknown
let rec relate_projs (a : proj list) (b : proj list) : rel =
match (a, b) with
| [], _ | _, [] -> Overlap (* a prefix contains the longer path *)
| PField x :: ta, PField y :: tb -> if x = y then relate_projs ta tb else Disjoint
| PConst i :: ta, PConst j :: tb -> if i = j then relate_projs ta tb else Disjoint
| PDyn x :: ta, PDyn y :: tb -> if x = y then relate_projs ta tb else Unknown
| PConst _ :: _, PDyn _ :: _ | PDyn _ :: _, PConst _ :: _ -> Unknown
| _ -> Unknown (* field vs index: ill-typed, stay conservative *)
let relate (a : place) (b : place) : rel =
if a.root <> b.root then Disjoint else relate_projs a.projs b.projs
(* ============================================================
The four tables (the plan-3 emitter contract)
============================================================ *)
(* Which MOVE is a real ownership transfer. Copy-classed assignments,
returns and arguments are *not* here: for those the emitter's MOVE is
a plain register copy. @gc transfers are not here either — they are rc
sites. *)
type move_kind =
| MvLet
| MvAssign
| MvReturn
| MvArg of string (* callee parameter name *)
| MvCtorField of string (* field being initialized *)
type move_site = {
mv_node : int;
mv_pos : Ast.pos;
mv_place : string;
mv_kind : move_kind;
}
(* An entry in a drop set / live mask. Owned values need DROP; @gc
handles need a decrement, so masks tag them. *)
type local_kind =
| LOwned
| LGc
(* One name in a drop set / live mask. di_node is the *declaring* node id
(a param's id, a `let`'s statement id, a `for`'s statement id, the
method id for `self`) — carried because a name alone is ambiguous under
shadowing: two nested scopes may each bind `x`, and the emitter must
know which register it is being told to destroy. For an OVERWRITE entry
there is no declaring node: di_name is the assigned place's text and
di_node is that place expression's own node. *)
type drop_item = {
di_name : string;
di_kind : local_kind;
di_node : int;
}
(* DScope — every owned local of a scope that is still live where the
scope ends (label names which block: BODY/THEN/ELSE/
WHILE/FOR). Suppressed when the scope diverges (every path
returned), because that scope end is unreachable.
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
both paths leave the merge in the same state (see
branch_join_drops and the module doc's simplifications list).
The label names the branch whose end they belong to; an `if`
with no `else` anchors its implicit else at the `if` itself.
DLiveMask — the frame's drop map at a call / DB_STUB site: everything
live there, @gc handles included, taken *after* the call's
own argument transfers because a moved-in value is the
callee's responsibility, not this frame's. *)
type drop_kind =
| DScope of string
| DReturn
| DOverwrite
| DBranchJoin of string
| DLiveMask
| DBreak
| DContinue
type drop_site = {
dr_node : int;
dr_pos : Ast.pos;
dr_kind : drop_kind;
dr_items : drop_item list;
}
type rc_op =
| RcAcquire
| RcRelease
(* rc_group ties a binding's ACQUIRE to its RELEASE(s): both are elided
together when the pair is provably balanced inside one scope. Escape
sites (a @gc value returned, stored, or moved into a `take`) use group
-1 — an increment that outlives the scope can never be elided. *)
type rc_site = {
rc_node : int;
rc_pos : Ast.pos;
rc_op : rc_op;
rc_place : string;
rc_group : int;
mutable rc_elided : bool;
}
type acc_kind =
| AShared
| AExcl
| AMove
(* Where static proof failed: the emitter wraps the region in
BORROW_S/BORROW_X + RELEASE (runtime/src/borrow.c) and the VM traps on
a real violation. *)
type residual_site = {
rs_node : int;
rs_pos : Ast.pos;
(* each side: its rendered place, its role, and the node of the
expression the place came from — what the emitter needs to know
*which* operand to wrap in BORROW_S/BORROW_X *)
rs_a : string;
rs_a_kind : acc_kind;
rs_a_node : int;
rs_b : string;
rs_b_kind : acc_kind;
rs_b_node : int;
}
type tables = {
moves : move_site list;
drops : drop_site list;
rcs : rc_site list;
residuals : residual_site list;
}
(* ============================================================
Flow state
============================================================ *)
type state =
| Live
| Moved of Ast.pos
| Borrowed of Ast.pos
type local = {
l_name : string;
l_ty : Ast.field_ty;
l_class : oclass;
l_node : int; (* declaring node: param id / let stmt id / for stmt id / method id for self *)
l_pos : Ast.pos;
(* true when this frame holds the value (owned) or a counted handle
(@gc) and must destroy it at scope end. False for borrows and for
Copy-classed locals. *)
l_holds : bool;
(* for a borrow bound to a place in *this* frame (`let r = b.inner`,
`for it in items`), the place borrowed; None for parameter borrows,
whose source lives in the caller. Also set for a @gc alias, where it
names the place the extra reference came from (rc elision needs it).
This field is also what makes a place *canonical* — see canon. *)
l_src : place option;
(* only meaningful for a borrow: whether it is shared or exclusive. A
`mut` parameter is an exclusive borrow; every borrow a `let` or a
`for` can bind is shared, because the grammar has no `let mut`. Read
when naming the *other* side of a residual site, so that side's role
comes from data rather than from a literal at the reporting site. *)
l_bkind : acc_kind;
mutable l_state : state;
}
type scope = {
sc_node : int;
sc_pos : Ast.pos;
sc_label : string;
mutable sc_locals : local list; (* reverse declaration order = destruction order *)
}
(* Table accumulators, shared across all functions of a program. *)
type sink = {
mutable s_moves : move_site list;
mutable s_drops : drop_site list;
mutable s_rcs : rc_site list;
mutable s_res : residual_site list;
}
type ctx = {
file : string;
syms : Types.symbols;
coll : Diag.Collector.t;
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
no scope-end drops and drops out of if/else joins *)
mutable diverged : bool;
(* rc bookkeeping, resolved into rc_elided at the end of the function *)
mutable fn_rcs : rc_site list;
rc_groups : (int, string option) Hashtbl.t; (* group -> source root, None = fresh allocation *)
rc_escaped : (int, unit) Hashtbl.t;
(* roots that are assigned to, or exclusively borrowed, anywhere in
this function — an rc pair whose source root is clobbered cannot be
elided, because the original reference may die inside the scope *)
clobbered : (string, unit) Hashtbl.t;
}
(* ============================================================
Type/class resolution (reuses Types' symbol tables; derives nothing
types.ml already exports)
============================================================ *)
let rec unwrap_nullable (ft : Ast.field_ty) : Ast.field_ty =
match ft with Nullable inner -> unwrap_nullable inner | t -> t
let oclass_of (ctx : ctx) (ft : Ast.field_ty) : oclass =
match unwrap_nullable ft with
(* `Text` is a HEAP value (runtime/src/obj.h's wo_str), so a binding that
holds a fresh one owns it and must drop it at scope end. It was Copy
until 2026-08-14 — grouped with Int/Bool because types.ml calls it a
builtin scalar — and the consequence was that no Text local was ever
dropped: every concatenation, interpolation and stdlib read accumulated
in the arena for the life of the process. Invisible in the corpus (small
strings live in the arena, which is freed wholesale at exit) and fatal in
a daemon (the workload's supervisor leaked a 1 MiB `fs.read_all` result
per rescan, which is a plain malloc and so ASan-visible). A Text read out
of a PLACE is still a borrow — analyze_let's own place logic decides
that, exactly as it does for a record field. *)
| Scalar n when n = "Text" || n = Types.json_value_type -> Owned
| Scalar n ->
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 (
(* 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
| Backlink _ -> Copy (* a virtual collection of row ids read on demand *)
| Multi _ | Map _ -> Owned
| Nullable _ -> Copy (* unreachable: unwrapped above *)
let field_ty_of (ctx : ctx) (class_name : string) (fname : string) : Ast.field_ty option =
match Types.StringMap.find_opt class_name ctx.syms.Types.classes with
| None -> None
| Some (cls : Types.class_info) ->
let rec find = function
| [] -> None
| (n, ft, _, _) :: tl -> if n = fname then Some ft else find tl
in
find cls.Types.fields
let elem_ty (ft : Ast.field_ty) : Ast.field_ty option =
match unwrap_nullable ft with
| Multi n -> Some (Scalar n)
| Map (_, v) -> Some (Scalar v)
| _ -> None
let find_local (ctx : ctx) (name : string) : local option =
let rec go = function
| [] -> None
| sc :: tl -> (
match List.find_opt (fun l -> l.l_name = name) sc.sc_locals with
| Some l -> Some l
| None -> go tl)
in
go ctx.scopes
(* Does this method write `self`? Spec rule 6's inference, direct
assignments only (see the module doc's simplifications). *)
let rec root_name_of (e : Ast.expr) : string option =
match e.kind with
| Ident n -> Some n
| Field (base, _) | Index (base, _) -> root_name_of base
| _ -> None
let rec body_writes_self (body : Ast.stmt list) : bool =
List.exists stmt_writes_self body
and stmt_writes_self (s : Ast.stmt) : bool =
match s.s_kind with
| Assign { target; _ } -> root_name_of target = Some "self"
| 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; _ } | 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
exclusively. Unresolved callees (a builtin such as `latest`, an
undeclared name) resolve to None and are then treated as taking every
argument by shared borrow: the conservative choice for *errors* (never
invent a move) and the honest one for the tables (no transfer to
record). *)
type callee = {
ce_params : (string * Ast.param_conv) list;
ce_ret : Ast.field_ty option;
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")
(* 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
(* haxe-parity Task 6: `nil` is the zero word — contextual on its
destination, and never something this frame owns. *)
| NilLit -> None
(* a checked decode's value is a fresh instance of the named type (or
nil) — owned, so the binding that holds it gets its drop *)
| As (_, ty) -> Some (Nullable ty)
(* haxe-parity Task 5: a `try` yields its try arm's type (types.ml has
already required the catch arm to agree). *)
| Try t -> expr_ty ctx t.body
| 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 -> (
(* 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
(* `..` (CONCAT) always produces a FRESH Text, and interpolation desugars to
exactly such a chain — so this is what gives every interpolated or
concatenated binding an owner and a drop. Left as None before
2026-08-14, which made those bindings Copy and leaked every one. *)
| Binary (Concat, _, _) -> Some (Scalar "Text")
| Binary _ -> None (* arithmetic/comparison: Copy either way *)
| Ctor (cn, _) -> Some (Scalar cn)
| Insert _ -> Some (Scalar "Int") (* the new row's id — Copy, nothing to drop *)
| Query _ -> Some (Multi "Int") (* a query yields a fresh multi of ids — owned *)
| Delete _ -> Some (Scalar "Int") (* the deleted id — Copy *)
| 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
match callee.kind with
| Ident name -> (
match Types.StringMap.find_opt name ctx.syms.Types.free_fns with
| Some (f : Types.free_fn_info) ->
Some
{ ce_params = params_of f.Types.params; ce_ret = f.Types.ret; ce_recv_excl = false }
(* A BUILTIN's return type, from the table types.ml and emit.ml already
read. `split`/`split_ws`/`slice` hand back a fresh `multi` and
`substr`/`trim`/`join`/… a fresh Text; without this they resolved to
nothing, the binding fell back to Copy, and every one of those
containers leaked (measured: the workload's supervisor mode). A
user-declared fn of the same name wins above, the shadowing rule the
builtin surface already states. *)
| None -> (
let arg0 = None in
match Types.builtin_confident_ret name arg0 with
| Some t -> (
match Types.field_ty_of_typ t with
| Some ft -> Some { ce_params = []; ce_ret = Some ft; ce_recv_excl = false }
| None -> None)
| None -> None))
| Field (base, mname) -> (
match expr_ty ctx base with
| Some bt -> (
match unwrap_nullable bt with
| Scalar cn -> (
match Types.StringMap.find_opt cn ctx.syms.Types.classes with
| None -> None
| Some (cls : Types.class_info) -> (
match List.find_opt (fun (m : Types.method_info) -> m.Types.name = mname) cls.Types.methods with
| None -> None
| Some m ->
Some
{ ce_params = params_of m.Types.params; ce_ret = m.Types.ret;
ce_recv_excl = body_writes_self m.Types.body }))
| _ -> None)
(* The base is not a value: it names a reserved stdlib module
(`fs.read_all(path, cap)`) or a class with a static member
(`Tools.needle(cmd)`). Both shapes were unresolved here until
2026-08-14, and an unresolved callee is not a missing *type* — it is a
missing LIFETIME: analyze_let's None-fallback classifies the binding
`Scalar "Int"`, oclass_of calls that Copy, and the fresh Text or
`multi` the call returned is never dropped. That was the measured
>1 MB leak in eight seconds of the workload's supervisor mode (one
`fs.read_all` result per cron file). The tables read here are the same
ones types.ml and emit.ml already read; a local of the same name
shadows the module, exactly as it does everywhere else. *)
| None -> (
match base.kind with
| Ident head when find_local ctx head = None -> (
match Types.stdlib_member head mname with
| Some sm ->
Some
{ ce_params = [];
ce_ret = ( match sm.Types.sm_ret with Some t -> Types.field_ty_of_typ t | None -> None);
ce_recv_excl = false }
| None -> (
match Types.StringMap.find_opt head ctx.syms.Types.classes with
| None -> None
| Some (cls : Types.class_info) -> (
match
List.find_opt
(fun (m : Types.method_info) -> m.Types.name = mname && m.Types.is_static)
cls.Types.methods
with
| None -> None
| Some m ->
Some
{ ce_params = params_of m.Types.params; ce_ret = m.Types.ret;
ce_recv_excl = false })))
| _ -> None))
| _ -> None
(* ============================================================
Places
============================================================ *)
(* A runtime index's identity. Simple place-shaped indices render to
readable text so `items[i]` twice is recognized as the same element;
anything else falls back to a node-unique token, so two complex
indices are never mistaken for each other (Unknown -> residual site,
the safe side) even when they look identical in source. *)
let rec idx_text (e : Ast.expr) : string =
match e.kind with
| IntLit n -> string_of_int n
| Ident n -> n
| Field (base, f) -> idx_text base ^ "." ^ f
| _ -> "#" ^ string_of_int e.id
let idx_proj (e : Ast.expr) : proj =
match e.kind with IntLit n -> PConst n | _ -> PDyn (idx_text e)
(* Builtins that hand back a POINTER INTO their container rather than a fresh
value: binding one binds a borrow of that container, not a second owner.
`pop`/`shift` are deliberately absent — they remove the element, so the
caller really does take ownership. Now that Text is Owned (oclass_of), this
distinction is what keeps `let v = get(m, k)` from dropping a string the
map still holds. *)
let borrowing_builtin (name : string) : bool =
List.mem name [ "get"; "latest"; "key_at"; "val_at" ]
let rec place_of (e : Ast.expr) : place option =
match e.kind with
| Ident n -> Some { root = n; projs = []; ppos = e.pos; pnode = e.id }
| Call ({ kind = Ident bname; _ }, (container :: rest)) when borrowing_builtin bname -> (
match place_of container with
| Some p ->
let proj = match rest with idx :: _ -> idx_proj idx | [] -> PDyn ("#" ^ string_of_int e.id) in
Some { p with projs = p.projs @ [ proj ]; pnode = e.id }
| None -> None)
| Field (base, f) -> (
match place_of base with
| Some p -> Some { p with projs = p.projs @ [ PField f ]; pnode = e.id }
| None -> None)
| Index (base, idx) -> (
match place_of base with
| Some p -> Some { p with projs = p.projs @ [ idx_proj idx ]; pnode = e.id }
| None -> None)
| _ -> None
let place_ty (ctx : ctx) (p : place) : Ast.field_ty option =
match find_local ctx p.root with
| None -> None
| Some l ->
List.fold_left
(fun acc pr ->
match acc with
| None -> None
| Some t -> (
match (pr, unwrap_nullable t) with
| PField f, Scalar cn -> field_ty_of ctx cn f
| (PConst _ | PDyn _), t' -> elem_ty t'
| _ -> None))
(Some l.l_ty) p.projs
let place_class (ctx : ctx) (p : place) : oclass =
match place_ty ctx p with Some t -> oclass_of ctx t | None -> Copy
(* ============================================================
Canonical places
============================================================ *)
(* A place written through a borrow binding names the *same storage* as the
place that borrow came from, so alias questions must be asked about the
canonical form, never the syntax. `let r = bag.items[i]` makes `r`
canonically `bag.items[i]`, and `r.n` canonically `bag.items[i].n`;
substitution is transitive (`let s = r.inner`). Without this,
`swap(r, s)` would compare roots `r` and `s`, conclude Disjoint, and
emit neither an error nor a residual site — the exact hole a syntactic
root check leaves open, and the one case where nobody (not the compiler,
not the VM) would be enforcing the rule.
Termination: each substitution step records the root it replaced, so a
self-referential chain created by shadowing (`let r = r.inner` in an
inner scope, where the inner `r` shadows the outer one that the source
place names) stops instead of looping. Such a chain stays partially
canonicalized; it is the one imprecise case, and it cannot hang. *)
let canon_walk (ctx : ctx) (p : place) : place * string list =
let rec go seen (p : place) =
if List.mem p.root seen then (p, seen)
else
match find_local ctx p.root with
| Some { l_src = Some src; _ } ->
go (p.root :: seen) { p with root = src.root; projs = src.projs @ p.projs }
| _ -> (p, p.root :: seen)
in
go [] p
let canon (ctx : ctx) (p : place) : place = fst (canon_walk ctx p)
(* Every root a place is reached *through*: the binding names it was written
with, plus the root it finally lands on. `is_own_binding`'s test lives on
this list, so a reborrow chain (`let t = r` where `r` borrows
`bag.items[i]`) is treated the same as a direct binding — otherwise using
`t` would be reported as conflicting with `r`, a borrow it *is*. *)
let canon_roots (ctx : ctx) (p : place) : string list = snd (canon_walk ctx p)
let relate_places (ctx : ctx) (a : place) (b : place) : rel =
relate (canon ctx a) (canon ctx b)
(* `outer` is a proper container of `inner` (`bag` of `bag.items[i]`), not
the same storage. *)
let is_strict_prefix (outer : place) (inner : place) : bool =
outer.root = inner.root
&& List.length outer.projs < List.length inner.projs
&& relate_projs outer.projs inner.projs = Overlap
(* ============================================================
Diagnostics
============================================================ *)
let report (ctx : ctx) ~code ~(pos : Ast.pos) ~message ~(rel : Ast.pos) ~label : unit =
if ctx.recording then
Diag.Collector.add ctx.coll
(Diag.error ~code ~file:ctx.file ~line:pos.line ~col:pos.col ~message
~related:[ Diag.related_site ~file:ctx.file ~line:rel.line ~col:rel.col ~label ]
())
(* "borrowed here" wording, chosen from how the borrow was created —
the second half of every two-site borrow error. *)
let borrow_label (l : local) : string =
match l.l_src with
| Some src -> Printf.sprintf "`%s` borrows `%s` here" l.l_name (place_text src)
| None ->
Printf.sprintf "`%s` is borrowed here — declare it `take %s: T` to pass ownership in" l.l_name
l.l_name
(* ============================================================
Table recording
============================================================ *)
let record_move (ctx : ctx) (p : place) (kind : move_kind) : unit =
if ctx.recording then
ctx.sink.s_moves <-
{ mv_node = p.pnode; mv_pos = p.ppos; mv_place = place_text p; mv_kind = kind }
:: ctx.sink.s_moves
let record_drop (ctx : ctx) ~node ~(pos : Ast.pos) ~kind ~items : unit =
if ctx.recording && items <> [] then
ctx.sink.s_drops <-
{ dr_node = node; dr_pos = pos; dr_kind = kind; dr_items = items } :: ctx.sink.s_drops
let record_rc (ctx : ctx) ~node ~(pos : Ast.pos) ~op ~place ~group : unit =
if ctx.recording then
ctx.fn_rcs <-
{ rc_node = node; rc_pos = pos; rc_op = op; rc_place = place; rc_group = group;
rc_elided = false }
:: ctx.fn_rcs
let record_residual (ctx : ctx) ~node ~(pos : Ast.pos) ~(a : place) ~a_kind ~(b : place) ~b_kind :
unit =
if ctx.recording then
ctx.sink.s_res <-
(* canonical text, so two accesses that reach the same container
through different borrow bindings read as what they are; the node
ids stay the precise key *)
{ rs_node = node; rs_pos = pos; rs_a = place_text (canon ctx a); rs_a_kind = a_kind;
rs_a_node = a.pnode; rs_b = place_text (canon ctx b); rs_b_kind = b_kind;
rs_b_node = b.pnode }
:: ctx.sink.s_res
let clobber (ctx : ctx) (root : string) : unit = Hashtbl.replace ctx.clobbered root ()
(* ============================================================
Scopes, live sets, snapshots
============================================================ *)
let push_scope (ctx : ctx) ~node ~pos ~label : unit =
ctx.scopes <- { sc_node = node; sc_pos = pos; sc_label = label; sc_locals = [] } :: ctx.scopes
let declare (ctx : ctx) (l : local) : unit =
match ctx.scopes with
| [] -> ()
| sc :: _ -> sc.sc_locals <- l :: sc.sc_locals
let is_live_holder (l : local) : bool =
l.l_holds && match l.l_state with Live -> true | Moved _ | Borrowed _ -> false
(* Every live holder, innermost scope first, destruction order inside
each scope. *)
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)
|> List.map (fun l -> { di_name = l.l_name; di_kind = LOwned; di_node = l.l_node })
let mask_items (ls : local list) : drop_item list =
List.map
(fun l ->
{ di_name = l.l_name; di_kind = (if l.l_class = Gc then LGc else LOwned); di_node = l.l_node })
ls
(* Releases for the @gc handles a scope exit destroys. *)
let release_gc (ctx : ctx) ~node ~pos (ls : local list) : unit =
List.iter
(fun l ->
if l.l_class = Gc then
record_rc ctx ~node ~pos ~op:RcRelease ~place:l.l_name ~group:l.l_node)
ls
let pop_scope (ctx : ctx) : unit =
match ctx.scopes with
| [] -> ()
| sc :: rest ->
if not ctx.diverged then begin
let live = List.filter is_live_holder sc.sc_locals in
record_drop ctx ~node:sc.sc_node ~pos:sc.sc_pos ~kind:(DScope sc.sc_label)
~items:(owned_items live);
release_gc ctx ~node:sc.sc_node ~pos:sc.sc_pos live
end;
ctx.scopes <- rest
type snapshot = (local * state) list
let snapshot (ctx : ctx) : snapshot =
List.concat_map (fun sc -> List.map (fun l -> (l, l.l_state)) sc.sc_locals) ctx.scopes
let restore (s : snapshot) : unit = List.iter (fun (l, st) -> l.l_state <- st) s
let earlier (a : Ast.pos) (b : Ast.pos) : Ast.pos =
if (a.line, a.col) <= (b.line, b.col) then a else b
let join_state (a : state) (b : state) : state =
match (a, b) with
| Moved p1, Moved p2 -> Moved (earlier p1 p2)
| Moved p, _ | _, Moved p -> Moved p
| Borrowed p, _ | _, Borrowed p -> Borrowed p
| Live, Live -> Live
(* Both snapshots come from the same physical local records (branch-local
locals are gone by the time a branch is joined), so keys are compared
with physical equality. *)
let join (a : snapshot) (b : snapshot) : snapshot =
List.map
(fun (l, sa) ->
let sb = try List.assq l b with Not_found -> sa in
(l, join_state sa sb))
a
(* ============================================================
Borrow bookkeeping
============================================================ *)
(* Borrows of a place in this frame that are still live: let-bound
aliases and loop cursors. Parameter borrows are excluded — their
source is the caller's, and the caller checks it. @gc aliases are
excluded because @gc is exempt. *)
let outstanding_borrows (ctx : ctx) : (local * place) list =
List.concat_map
(fun sc ->
List.filter_map
(fun l ->
match (l.l_src, l.l_class, l.l_state) with
| Some src, Owned, Borrowed _ -> Some (l, src)
| _ -> None)
sc.sc_locals)
ctx.scopes
(* An access through a borrow's own binding (`it.touch()` inside
`for it in items`) is what the borrow is *for*, so it never conflicts
with that same borrow — nor with any borrow further up the chain the
access is reached through. Checked on the roots the place was *written*
with, since after canonicalization an access and its own borrow are the
same place by construction. *)
let is_own_binding (ctx : ctx) (l : local) (p : place) : bool =
List.mem l.l_name (canon_roots ctx p)
(* [skip_roots] names borrow bindings that another access in the same
region already stands for; without it, `swap(r, s)` (both borrow
bindings) would be reported once by the region's pairwise check and then
twice more here, once per (access, other binding) pair. *)
let check_against_borrows (ctx : ctx) ~(node : int) ~(pos : Ast.pos) ?(skip_roots = [])
(p : place) (kind : acc_kind) : unit =
List.iter
(fun ((l : local), src) ->
if not (is_own_binding ctx l p || List.mem l.l_name skip_roots) then
match relate_places ctx p src with
| Overlap ->
let code, message =
match kind with
| AMove ->
( move_while_borrowed_code,
Printf.sprintf "cannot move `%s` while `%s` is borrowed" (place_text p)
(place_text src) )
| AExcl | AShared ->
(* "mutate" covers both exclusive accesses this reaches: a
`mut` argument and an assignment to the place *)
( conflicting_borrow_code,
Printf.sprintf "cannot mutate `%s` while `%s` is borrowed" (place_text p)
(place_text src) )
in
report ctx ~code ~pos ~message ~rel:l.l_pos ~label:(borrow_label l)
| Unknown ->
record_residual ctx ~node ~pos ~a:p ~a_kind:kind ~b:src ~b_kind:l.l_bkind
| Disjoint -> ())
(outstanding_borrows ctx)
(* ============================================================
Moves and escapes
============================================================ *)
let root_local (ctx : ctx) (p : place) : local option = find_local ctx p.root
let is_borrow_root (ctx : ctx) (p : place) : local option =
match root_local ctx p with
| Some l -> ( match l.l_state with Borrowed _ -> Some l | Live | Moved _ -> None)
| None -> None
(* Reading a place: the one place use-after-move is reported. When the
move site *is* the use site, the move happened on an earlier turn
through a loop (the fixpoint's second pass found it) — say so, because
"moved here" pointing at the line you are already looking at is
otherwise baffling. *)
let use_place (ctx : ctx) (p : place) : unit =
match root_local ctx p with
| Some { l_state = Moved mpos; l_name; _ } ->
let same_site = mpos.line = p.ppos.line && mpos.col = p.ppos.col in
report ctx ~code:use_after_move_code ~pos:p.ppos
~message:
(if same_site then
Printf.sprintf "use of `%s` after it was moved on the previous loop iteration" l_name
else Printf.sprintf "use of `%s` after it was moved" l_name)
~rel:mpos
~label:
(if same_site then Printf.sprintf "`%s` is moved here, once per iteration" l_name
else Printf.sprintf "`%s` moved here" l_name)
| _ -> ()
let escape (ctx : ctx) (l : local) ~(pos : Ast.pos) ~message : unit =
report ctx ~code:borrow_escape_code ~pos ~message ~rel:l.l_pos ~label:(borrow_label l)
(* A @gc value reaching a location that outlives this scope: one
increment at the escape site, never elidable. If the escaping value is
a whole local, its own binding pair can no longer be elided either. *)
let gc_escape (ctx : ctx) (p : place) : unit =
record_rc ctx ~node:p.pnode ~pos:p.ppos ~op:RcAcquire ~place:(place_text p) ~group:(-1);
if p.projs = [] then
match root_local ctx p with
| Some l -> Hashtbl.replace ctx.rc_escaped l.l_node ()
| None -> ()
(* Whether passing/assigning this place would be a *real* transfer — the
positive half of `transfer`'s decision below, needed one step earlier by
analyze_call: an argument that cannot transfer (a borrow, a projection,
an already-moved local) must be classified as a read, or the region's
pairwise check reports a bogus move conflict on top of the escape error
`transfer` is about to give. *)
(* A `Text` stored into a field or a record is COPIED by the VM (SETF's own
rule, the same one push/set follow) — so it is neither a move out of the
source nor a borrow escaping its scope. Without this, `fn rename(name: Text)
{ self.name = name }` — the most ordinary line in the workload — is
WO-E304, and the only way to write it would be `take name: Text`. Copying
is what keeps a field's owner the object itself. *)
let stores_by_copy (ctx : ctx) (p : place) : bool =
match place_ty ctx p with
| Some t -> ( match unwrap_nullable t with
| Scalar n -> n = "Text" || n = Types.json_value_type
| _ -> false)
| None -> false
let is_real_transfer (ctx : ctx) (p : place) : bool =
p.projs = []
&& match root_local ctx p with Some l -> l.l_holds && l.l_state = Live | None -> false
(* The one gate every ownership transfer goes through. Returns true when
a real transfer happened (the caller then records the move site with
the right kind). `what` names the escape flavour for the E304 message.
Order of decisions matters: a moved-from local was already reported by
use_place, a borrow can never be moved out of, and a projection is not
a move at all in milestone 1 (see the module doc). *)
let transfer (ctx : ctx) (p : place) ~(what : string) : bool =
match place_class ctx p with
| Copy -> false
| Gc ->
gc_escape ctx p;
false
| Owned -> (
match is_borrow_root ctx p with
| Some l ->
escape ctx l ~pos:p.ppos
~message:
(Printf.sprintf "borrow of `%s` %s — borrows cannot outlive their scope" (place_text p)
what);
false
| None -> (
match root_local ctx p with
| None -> false
| Some l -> (
match l.l_state with
| Moved _ -> false (* already reported at the read *)
| Borrowed _ -> false (* unreachable: is_borrow_root covered it *)
| Live ->
if p.projs <> [] then false (* no partial moves in milestone 1 *)
else begin
check_against_borrows ctx ~node:p.pnode ~pos:p.ppos p AMove;
l.l_state <- Moved p.ppos;
clobber ctx p.root;
true
end)))
(* ============================================================
The walk
============================================================ *)
(* One access inside a call's region. Its position is the accessed
place's own root token (place.ppos), so no separate field. *)
type access = {
ac_place : place;
ac_kind : acc_kind;
}
let rec read_expr (ctx : ctx) (e : Ast.expr) : unit =
match e.kind with
| IntLit _ | StrLit _ | BoolLit _ -> ()
| Ident _ | Field _ | Index _ ->
(match place_of e with Some p -> use_place ctx p | None -> ());
read_place_parts ctx e
| Call (callee, args) -> analyze_call ctx e callee args
| Ctor (cn, fields) -> analyze_ctor ctx cn fields
| Insert (_, fields) ->
(* iteration 9 Task 3: the engine copies every field value at the row
API (the two-worlds bulkhead), so an insert BORROWS its values —
no transfer, no E304, the source keeps what it had. Trap-capable
(unique violations arrive with Task 4), so the drop map is
recorded exactly like DbStub's. *)
List.iter (fun (_, fe) -> read_expr ctx fe) fields;
record_drop ctx ~node:e.id ~pos:e.pos ~kind:DLiveMask ~items:(mask_items (live_holders ctx))
| Unary (_, o) -> read_expr ctx o
| Binary (_, a, b) ->
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 | NilLit -> ()
| As (inner, _) -> read_expr ctx inner
| Try t -> analyze_try ctx e t.body t.ename t.handler
| 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))
| Delete t ->
read_expr ctx t;
record_drop ctx ~node:e.id ~pos:e.pos ~kind:DLiveMask ~items:(mask_items (live_holders ctx))
| Query q ->
(* iteration 9b: the sub-expressions only READ (engine field-reads copy
out at the boundary); the query is trap-capable (engine faults), so
the frame needs its drop map here, exactly like DbStub. *)
(match q.q_src with QNav e2 -> read_expr ctx e2 | QTable _ -> ());
List.iter (read_expr ctx) q.q_wheres;
(match q.q_group with Some (_, k) -> read_expr ctx k | None -> ());
(match q.q_order with Some (k, _) -> read_expr ctx k | None -> ());
(match q.q_take with Some t -> read_expr ctx t | None -> ());
read_expr ctx q.q_select;
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
(`f().x`). *)
and read_place_parts (ctx : ctx) (e : Ast.expr) : unit =
match e.kind with
| Ident _ -> ()
| Field (base, _) -> read_place_parts ctx base
| Index (base, idx) ->
read_place_parts ctx base;
read_expr ctx idx
| _ -> read_expr ctx e
and analyze_ctor (ctx : ctx) (cn : string) (fields : (string * Ast.expr) list) : unit =
List.iter
(fun (fname, fe) ->
read_expr ctx fe;
match place_of fe with
| None -> ()
| Some p ->
if stores_by_copy ctx p then () (* the field gets its own copy *)
else if transfer ctx p ~what:(Printf.sprintf "cannot be stored in `%s.%s`" cn fname) then
record_move ctx p (MvCtorField fname))
fields
(* A call is one region: the receiver borrow and every argument borrow
are live at the same time, so they are checked pairwise against each
other and against any outstanding borrow in the frame. Reads happen
first (against the pre-call state), then the conflict checks, then the
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 =
match callee.kind with
| Ident _ -> None
| Field (base, _) ->
read_expr ctx base;
let excl = match resolved with Some c -> c.ce_recv_excl | None -> false in
(match place_of base with
| Some p ->
(* same ordering as the argument case below: a receiver a method
writes to is clobbered whatever its class *)
if excl then clobber ctx p.root;
if place_class ctx p = Owned then
Some { ac_place = p; ac_kind = (if excl then AExcl else AShared) }
else None
| None -> None)
| _ ->
read_expr ctx callee;
None
in
List.iter (fun a -> read_expr ctx a) args;
(* argument conventions, positionally; unresolved or over-supplied
arguments fall back to a shared borrow *)
let conv_of i =
match resolved with
| None -> (Printf.sprintf "arg%d" (i + 1), Borrow)
| Some c -> ( match List.nth_opt c.ce_params i with Some p -> p | None -> ("_", Borrow))
in
let arg_accesses =
List.concat
(List.mapi
(fun i a ->
match place_of a with
| None -> []
| Some p -> (
let _, conv = conv_of i in
(* Clobbering is decided *before* the ownership class, because
it is not an ownership question: a `mut` argument means the
callee may replace what the place holds, and for a @gc place
that is exactly what invalidates rc elision (the alias would
be the last reference and its increment was elided). Getting
this order wrong is a use-after-free, not an imprecision. *)
if conv = Mut then clobber ctx p.root;
match (place_class ctx p, conv) with
| Copy, _ | Gc, _ -> []
| Owned, Take ->
let kind = if is_real_transfer ctx p then AMove else AShared in
[ { ac_place = p; ac_kind = kind } ]
| Owned, Mut -> [ { ac_place = p; ac_kind = AExcl } ]
| Owned, Borrow -> [ { ac_place = p; ac_kind = AShared } ]))
args)
in
let accesses = (match recv with Some r -> [ r ] | None -> []) @ arg_accesses in
(* pairwise, in argument order *)
let rec pairs = function
| [] -> ()
| a :: tl ->
List.iter (fun b -> check_pair ctx call_e a b) tl;
pairs tl
in
pairs accesses;
(* An exclusive borrow also has to clear the frame's outstanding borrows
(moves do that inside `transfer`) — minus the bindings this region's
own accesses already stand for, which the pairwise check just
covered. *)
let region_roots = List.map (fun a -> a.ac_place.root) accesses in
List.iter
(fun a ->
if a.ac_kind = AExcl then
check_against_borrows ctx ~node:call_e.id ~pos:a.ac_place.ppos ~skip_roots:region_roots
a.ac_place AExcl)
accesses;
(* transfers last *)
(* `push`'s value argument (builtin `multi_push`) stores a @gc reference
inside the container permanently — an escape exactly like a ctor
field or a `take` argument. `push` is never a resolved callee (it has
no declared params), so `conv_of` defaults it to Borrow and the
ordinary Take-gated transfer above never fires for it; without this
the container holds the reference with no matching RC_INC, and the
collector frees the value out from under the container it still sits
in. Narrow to `push`'s own value slot (index 1) and to Gc places only
— an Owned element's move-on-push is a separate, pre-existing gap
this task does not touch. *)
(* Keyed on "`push` is not a user-declared fn", NOT on "the callee did not
resolve": since 2026-08-14 resolve_callee answers for builtins too (their
return types are what give a `split`/`slice` binding its drop), and the
old `resolved = None` test silently stopped firing — the pushed @gc value
lost its RC_INC, the collector freed it while the container still held it,
and both `gc/` fixtures died with a use-after-free. *)
let is_push_gc_value i =
i = 1
&& Types.StringMap.find_opt "push" ctx.syms.Types.free_fns = None
&& match callee.kind with Ident "push" -> true | _ -> false
in
List.iteri
(fun i a ->
match place_of a with
| None -> ()
| Some p ->
let pname, conv = conv_of i in
if conv = Take then
(if transfer ctx p ~what:(Printf.sprintf "cannot be passed to `take %s`" pname) then
record_move ctx p (MvArg pname))
else if is_push_gc_value i && place_class ctx p = Gc then gc_escape ctx p)
args;
record_drop ctx ~node:call_e.id ~pos:call_e.pos ~kind:DLiveMask
~items:(mask_items (live_holders ctx))
(* The pairwise equivalent of is_own_binding: one side reaches the storage
through a borrow binding, the other names the place that borrow came
from (`bag.eat(it)` inside `for it in bag.items`). Using a borrow
alongside the thing it borrows from is what the binding is for, so it is
not a conflict — and after canonicalization the two would otherwise
always Overlap. Two *different* bindings of the same container (`r` and
`s`) are not this case and stay checked. *)
and reuses_binding (ctx : ctx) (a : place) (b : place) : bool =
(* narrow on purpose: only when one side is written through a borrow
binding *and* the other side names a proper container of what that
borrow points at. Two bindings of the same container (`r` and `s`), or
a binding against the very element it borrows, are not this case and
stay checked. *)
let through (p : place) (other : place) =
match find_local ctx p.root with
| Some { l_src = Some _; _ } -> is_strict_prefix (canon ctx other) (canon ctx p)
| _ -> false
in
through a b || through b a
and check_pair (ctx : ctx) (call_e : Ast.expr) (a : access) (b : access) : unit =
if a.ac_kind = AShared && b.ac_kind = AShared then ()
else if reuses_binding ctx a.ac_place b.ac_place then ()
else
match relate_places ctx a.ac_place b.ac_place with
| Disjoint -> ()
| Unknown ->
record_residual ctx ~node:call_e.id ~pos:call_e.pos ~a:a.ac_place ~a_kind:a.ac_kind
~b:b.ac_place ~b_kind:b.ac_kind
| Overlap -> (
match (a.ac_kind, b.ac_kind) with
| AMove, AMove ->
report ctx ~code:use_after_move_code ~pos:b.ac_place.ppos
~message:
(Printf.sprintf "use of `%s` after it was moved" (place_text b.ac_place))
~rel:a.ac_place.ppos
~label:(Printf.sprintf "`%s` moved here" (place_text a.ac_place))
| AMove, _ ->
report ctx ~code:move_while_borrowed_code ~pos:a.ac_place.ppos
~message:
(Printf.sprintf "cannot move `%s` while `%s` is borrowed in the same call"
(place_text a.ac_place) (place_text b.ac_place))
~rel:b.ac_place.ppos
~label:(Printf.sprintf "`%s` borrowed here" (place_text b.ac_place))
| _, AMove ->
report ctx ~code:move_while_borrowed_code ~pos:b.ac_place.ppos
~message:
(Printf.sprintf "cannot move `%s` while `%s` is borrowed in the same call"
(place_text b.ac_place) (place_text a.ac_place))
~rel:a.ac_place.ppos
~label:(Printf.sprintf "`%s` borrowed here" (place_text a.ac_place))
| _ ->
report ctx ~code:conflicting_borrow_code ~pos:b.ac_place.ppos
~message:
(Printf.sprintf "cannot borrow `%s` as `mut` twice in the same call"
(place_text b.ac_place))
~rel:a.ac_place.ppos
~label:(Printf.sprintf "`%s` first borrowed here" (place_text a.ac_place)))
(* ---- statements ------------------------------------------------------ *)
(* Join normalization. A value moved on one branch of an `if` and not the
other leaves the merge in two different real states while the join can
record only one — and it records Moved, so nothing would ever drop the
value on the path that did *not* move it: a leak. Rather than spend a
runtime drop flag, the non-moving branch drops it at its own end, which
makes the joined Moved state true on both paths. Sound because the join
already marked it Moved, so a later use is WO-E301 either way. *)
and branch_join_drops (ctx : ctx) ~(node : int) ~(label : string) ~(pos : Ast.pos)
~(moving : snapshot) ~(other : snapshot) : unit =
let items =
List.filter_map
(fun ((l : local), st_other) ->
let st_moving = try List.assq l moving with Not_found -> st_other in
match (st_other, st_moving) with
| Live, Moved _ when l.l_holds && l.l_class = Owned ->
Some { di_name = l.l_name; di_kind = LOwned; di_node = l.l_node }
| _ -> None)
other
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
| Assign { target; value } -> analyze_assign ctx s target value
| ExprStmt e -> read_expr ctx e
| Return opt -> analyze_return ctx s opt
| If { cond; then_body; else_body } ->
read_expr ctx cond;
let entry = snapshot ctx in
let div0 = ctx.diverged in
analyze_block ctx ~node:s.s_id ~pos:s.s_pos ~label:"THEN" then_body;
let after_then = snapshot ctx in
let div_then = ctx.diverged in
restore entry;
ctx.diverged <- div0;
let after_else, div_else =
match else_body with
| None -> (entry, div0)
| Some (epos, body) ->
analyze_block ctx ~node:s.s_id ~pos:epos ~label:"ELSE" body;
let sn = snapshot ctx in
let dv = ctx.diverged in
restore entry;
(sn, dv)
in
(* a branch that returned contributes no state to the merge *)
if div_then && div_else then begin
restore after_then;
ctx.diverged <- true
end
else if div_then then begin
restore after_else;
ctx.diverged <- div0
end
else if div_else then begin
restore after_then;
ctx.diverged <- div0
end
else begin
(* neither branch returned: normalize a one-sided move by dropping on
the side that kept the value. An `if` without `else` anchors its
implicit else at the `if` token — there is no other position to
name. *)
let else_anchor = match else_body with Some (epos, _) -> epos | None -> s.s_pos in
branch_join_drops ctx ~node:s.s_id ~label:"ELSE" ~pos:else_anchor ~moving:after_then
~other:after_else;
branch_join_drops ctx ~node:s.s_id ~label:"THEN" ~pos:s.s_pos ~moving:after_else
~other:after_then;
restore (join after_then after_else);
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);
ctx.loop_stack <- List.tl ctx.loop_stack
| For { var; var2; iter; body } ->
read_expr ctx iter;
let src = place_of iter in
let iter_ty = expr_ty ctx iter in
(* `for k, v in m` binds the map's key and value types; the one-name
form binds the element type (elem_ty). Both cursors are BORROWS of
what the container owns, so neither is ever dropped by the body. *)
let key_ty, item_ty =
match (iter_ty, var2) with
| Some (Map (k, v)), Some _ -> (Some (Scalar k), Scalar v)
| Some (Nullable (Map (k, v))), Some _ -> (Some (Scalar k), Scalar v)
| Some t, _ -> (None, ( match elem_ty t with Some e -> e | None -> t))
| None, _ -> (None, Scalar "Int")
in
(* A cursor over Text elements holds a COPY, not a borrow: the emitter
copies each element as it loads it (the same boundary rule containers,
fields and returns follow), so the body owns its cursor and drops it per
iteration. That is also what lets `for k, v in m { v.field = … }` work —
a borrowing key cursor made every mutation through the value cursor a
WO-E303 against the container's own borrow. Any other element type is
still a borrow: records are not copied. *)
let cursor (n : string) (t : Ast.field_ty) : local =
let copied =
match unwrap_nullable t with
| Scalar cn -> cn = "Text" || cn = Types.json_value_type
| _ -> false
in
if copied then
{ l_name = n; l_ty = t; l_class = Owned; l_node = s.s_id; l_pos = s.s_pos; l_holds = true;
l_src = None; l_bkind = AShared; l_state = Live }
else
{ l_name = n; l_ty = t; l_class = oclass_of ctx t; l_node = s.s_id; l_pos = s.s_pos;
l_holds = false; l_src = src; l_bkind = AShared; l_state = Borrowed s.s_pos }
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";
(* the cursor borrows an element of the iterable for the whole
body: moving the container out from under it is E302 *)
(match (key_ty, var2) with
| Some kt, Some v2 ->
declare ctx (cursor var kt);
declare ctx (cursor v2 item_ty)
| _ -> declare ctx (cursor var item_ty));
List.iter (analyze_stmt ctx) body;
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
state. The exit state joins the entry again — a `while` may run zero
times — and a `return` inside the body does not make the loop diverge
for the same reason. *)
and fixpoint (ctx : ctx) (run : unit -> unit) : unit =
let entry = snapshot ctx in
let div0 = ctx.diverged in
let was_recording = ctx.recording in
ctx.recording <- false;
run ();
ctx.recording <- was_recording;
ctx.diverged <- div0;
restore (join entry (snapshot ctx));
run ();
let after = snapshot ctx in
restore (join entry after);
ctx.diverged <- div0
(* `~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. *)
(* haxe-parity Task 5: `try body catch (e) handler`. The two arms are
alternate flows joining at one point — the same shape `if`/`switch`
already have, so the same join machinery applies: whatever one arm
moved out, the arm that still holds it drops at its own end
(branch_join_drops), and the state after the whole expression is the
join of both.
What is genuinely different from a branch is WHERE the catch arm starts
from: a trap can be raised anywhere inside the body, so the handler may
run after *any prefix* of it. Taking the entry state as the handler's
starting point is the conservative reading — it never claims the body's
moves happened — and the join then makes the surviving path responsible
for the drop. The frame also needs a live mask at the try itself (like
every other trap-capable site): that mask is what the VM's unwind uses
to release the body's own values before landing in the handler.
`e` is a local of the predeclared `Error` record, owned by the handler
scope (the record and its two Texts are freshly allocated at landing),
so pop_scope records its drop like any other owned local's. *)
and analyze_try (ctx : ctx) (e : Ast.expr) (body : Ast.expr) (ename : string)
(handler : Ast.stmt list) : unit =
record_drop ctx ~node:e.id ~pos:e.pos ~kind:DLiveMask ~items:(mask_items (live_holders ctx));
let entry = snapshot ctx in
let div0 = ctx.diverged in
read_expr ctx body;
let body_sn = snapshot ctx in
let body_div = ctx.diverged in
restore entry;
ctx.diverged <- div0;
let ety = Ast.Scalar Types.error_record_name in
let ebind =
{ l_name = ename; l_ty = ety; l_class = oclass_of ctx ety; l_node = e.id; l_pos = e.pos;
l_holds = (match oclass_of ctx ety with Copy -> false | _ -> true); l_src = None;
l_bkind = AShared; l_state = Live }
in
analyze_block ctx ~pre:[ ebind ] ~node:e.id ~pos:e.pos ~label:"CATCH" handler;
let catch_sn = snapshot ctx in
let catch_div = ctx.diverged in
if (not body_div) && not catch_div then begin
branch_join_drops ctx ~node:e.id ~label:"TRYBODY" ~pos:e.pos ~moving:catch_sn ~other:body_sn;
branch_join_drops ctx ~node:e.id ~label:"CATCHJOIN" ~pos:e.pos ~moving:body_sn ~other:catch_sn
end;
restore (if body_div then catch_sn else if catch_div then body_sn else join body_sn catch_sn);
ctx.diverged <- body_div && catch_div
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
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 t -> t
| None -> ( match expr_ty ctx value with Some t -> t | None -> Scalar "Int")
in
let cls = oclass_of ctx vty in
(* A container read that yields a Text is COPIED by the emitter — the fourth
ownership boundary, and the one that keeps `let cl = headers["x"]` from
borrowing the map for the rest of the scope (which then forbade moving
that map into a record, WO-E302). For any other element type the read is
still a borrow of the container: records are not copied. *)
let reads_container =
match value.Ast.kind with
| Ast.Index _ -> true
| Ast.Call ({ Ast.kind = Ast.Ident bn; _ }, _) -> borrowing_builtin bn
| _ -> false
in
let copies_out = reads_container && (match unwrap_nullable vty with
| Scalar n -> n = "Text" || n = Types.json_value_type
| _ -> false) in
let vplace = if copies_out then None else place_of value in
(* A `@gc` value read out of a container is neither a copy nor a new
reference: the container holds the count, and the reader only looks. It
must NOT take the Gc-alias path below (which records an RC_INC/RC_DEC
pair) — doing so double-released the element and segfaulted both `gc/`
fixtures. Reading it as a plain borrow of the container is what the pass
did before container reads became places, now with the right type. *)
let gc_container_read = reads_container && cls = Gc in
let holds, src, state =
match (cls, vplace) with
| Gc, _ when gc_container_read -> (false, vplace, Borrowed s.s_pos)
(* Binding a Text from a PLACE copies it — the same boundary rule as a
field store, a container element, a loop cursor and a return. The
source stays live and keeps its own value; this binding owns the copy
and drops it at scope end. Without it `let range = part` MOVED `part`,
and the next line's `index_of(part, "/")` was a use-after-move. *)
| (Owned | Gc), Some p when stores_by_copy ctx p -> (true, None, Live)
| Copy, _ -> (false, None, Live)
| Owned, None -> (true, None, Live) (* fresh value: constructor or call result *)
| Owned, Some p -> (
(* a whole owned local moves; a projection binds a *borrow* of that
place instead — milestone 1 has no partial moves, so
`let r = b.inner` is an alias, not a transfer *)
match root_local ctx p with
| None -> (true, None, Live) (* root is not a local: treat as a fresh value *)
| Some l -> (
match l.l_state with
| Moved _ -> (true, None, Live) (* use-after-move already reported *)
| Borrowed _ -> (false, Some p, Borrowed s.s_pos) (* reborrow *)
| Live ->
if p.projs = [] && l.l_holds then begin
if transfer ctx p ~what:"cannot be moved out" then record_move ctx p MvLet;
(true, None, Live)
end
else (false, Some p, Borrowed s.s_pos)))
| Gc, None ->
(* fresh allocation: its release is the allocation's own, never
elidable *)
Hashtbl.replace ctx.rc_groups s.s_id None;
(true, None, Live)
| Gc, Some p ->
Hashtbl.replace ctx.rc_groups s.s_id (Some p.root);
record_rc ctx ~node:s.s_id ~pos:s.s_pos ~op:RcAcquire ~place:(place_text p) ~group:s.s_id;
(true, Some p, Live)
in
declare ctx
{ l_name = name; l_ty = vty; l_class = cls; l_node = s.s_id; l_pos = s.s_pos; l_holds = holds;
l_src = src; l_bkind = AShared; l_state = state }
and analyze_assign (ctx : ctx) (s : Ast.stmt) (target : Ast.expr) (value : Ast.expr) : unit =
read_expr ctx value;
let tplace = place_of target in
let vplace = place_of value in
(* `a = a` (or `self.box = self.box`) replaces a place with itself: the
old value and the new one are the same storage, so recording a drop for
the "overwritten" value would destroy the value the assignment just
stored — a double free with no diagnostic. Overlap is deliberately a
hair wider than the exact test (which would be `canon tp = canon vp`):
it also holds when one side *contains* the other, as in `a = a.inner`.
Such a shape cannot type-check upstream today, so the two tests agree
on every reachable program; if containment ever became expressible,
suppressing the drop would still be the safe direction — the
overwritten value and the stored one overlap, so dropping either is
the double free. *)
let self_assign =
match (tplace, vplace) with
| Some tp, Some vp -> relate_places ctx tp vp = Overlap
| _ -> false
in
(* Writing a place is an exclusive access to it (so it may not be
borrowed elsewhere), and the value it held dies here. Table entries and
the diagnostic point at the place written, not at the statement (they
coincide today — an assignment starts at its target — but the place is
what the entry is about). *)
(match tplace with
| None -> read_expr ctx target
| Some p ->
clobber ctx p.root;
check_against_borrows ctx ~node:s.s_id ~pos:p.ppos p AExcl;
if p.projs = [] then begin
match root_local ctx p with
| Some l when l.l_holds && l.l_state = Live && not self_assign ->
if l.l_class = Owned then
record_drop ctx ~node:s.s_id ~pos:p.ppos ~kind:DOverwrite
~items:[ { di_name = place_text p; di_kind = LOwned; di_node = p.pnode } ]
else if l.l_class = Gc then begin
Hashtbl.replace ctx.rc_escaped l.l_node ();
record_rc ctx ~node:s.s_id ~pos:p.ppos ~op:RcRelease ~place:(place_text p) ~group:(-1)
end
| _ -> ()
end
else begin
(* a projection target is also a read of its root *)
use_place ctx p;
read_place_parts ctx target;
match if self_assign then Copy else place_class ctx p with
| Owned ->
record_drop ctx ~node:s.s_id ~pos:p.ppos ~kind:DOverwrite
~items:[ { di_name = place_text p; di_kind = LOwned; di_node = p.pnode } ]
| Gc -> record_rc ctx ~node:s.s_id ~pos:p.ppos ~op:RcRelease ~place:(place_text p) ~group:(-1)
| Copy -> ()
end);
(* the incoming value *)
(match vplace with
| None -> ()
| Some vp ->
let into_field = match tplace with Some tp -> tp.projs <> [] | None -> true in
let what =
if into_field then
Printf.sprintf "cannot be stored in `%s`"
(match tplace with Some tp -> place_text tp | None -> "a field")
else "cannot be moved out"
in
if into_field && stores_by_copy ctx vp then () (* the field gets its own copy *)
else if transfer ctx vp ~what then record_move ctx vp MvAssign);
(* Whatever the value was — a fresh constructor, a call result, another
local — assigning to a whole local re-initializes it: a local that had
been moved out of is live again afterwards. *)
match tplace with
| Some tp when tp.projs = [] -> (
match root_local ctx tp with Some l when l.l_holds -> l.l_state <- Live | _ -> ())
| _ -> ()
and analyze_return (ctx : ctx) (s : Ast.stmt) (opt : Ast.expr option) : unit =
(match opt with
| None -> ()
| Some e -> (
read_expr ctx e;
match place_of e with
| None -> ()
| Some p ->
(* Returning a Text is the third ownership boundary that COPIES (the
other two are a container element and a field): the caller gets its
own string, the callee's borrow stays the borrow it was. emit.ml
emits that copy. Without this the workload's own level classifier —
`for lv in [...] { … return lv }` — cannot be written at all. *)
if stores_by_copy ctx p then ()
else if transfer ctx p ~what:(Printf.sprintf "escapes `%s`" ctx.fn_name) then
record_move ctx p MvReturn));
let live = live_holders ctx in
record_drop ctx ~node:s.s_id ~pos:s.s_pos ~kind:DReturn ~items:(owned_items live);
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
============================================================ *)
let param_local (ctx : ctx) (p : Ast.param) : local =
let cls = oclass_of ctx p.ty in
match cls with
| Copy -> { l_name = p.name; l_ty = p.ty; l_class = Copy; l_node = p.id; l_pos = p.pos;
l_holds = false; l_src = None; l_bkind = AShared; l_state = Live }
| Owned | Gc -> (
match p.conv with
| Take ->
{ l_name = p.name; l_ty = p.ty; l_class = cls; l_node = p.id; l_pos = p.pos; l_holds = true;
l_src = None; l_bkind = AShared; l_state = Live }
| Borrow | Mut ->
(* @gc parameters are aliases, not borrows: no borrow rules apply,
and the caller's reference keeps the object alive for the call *)
let state = if cls = Gc then Live else Borrowed p.pos in
{ l_name = p.name; l_ty = p.ty; l_class = cls; l_node = p.id; l_pos = p.pos; l_holds = false;
l_src = None; l_bkind = (if p.conv = Mut then AExcl else AShared); l_state = state })
let resolve_rc (ctx : ctx) : unit =
List.iter
(fun r ->
if r.rc_group >= 0 then
r.rc_elided <-
(not (Hashtbl.mem ctx.rc_escaped r.rc_group))
&& (match Hashtbl.find_opt ctx.rc_groups r.rc_group with
| Some (Some root) -> not (Hashtbl.mem ctx.clobbered root)
| _ -> false))
ctx.fn_rcs;
ctx.sink.s_rcs <- ctx.fn_rcs @ ctx.sink.s_rcs
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 = []; 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";
(* `self` is always a borrow (spec rule 6) *)
(match self_class with
| None -> ()
| Some cn ->
let ty = Scalar cn in
let cls = oclass_of ctx ty in
declare ctx
{ l_name = "self"; l_ty = ty; l_class = cls; l_node = m.id; l_pos = m.pos; l_holds = false;
l_src = None; l_bkind = (if body_writes_self m.body then AExcl else AShared);
l_state = (if cls = Gc then Live else Borrowed m.pos) });
List.iter (fun p -> declare ctx (param_local ctx p)) m.params;
List.iter (analyze_stmt ctx) m.body;
pop_scope ctx;
resolve_rc ctx
(* ============================================================
Entry point
============================================================ *)
let pos_key (p : Ast.pos) = (p.line, p.col)
let analyze ~(file : string) (prog : Ast.program) (syms : Types.symbols)
(coll : Diag.Collector.t) : tables =
let sink = { s_moves = []; s_drops = []; s_rcs = []; s_res = [] } in
List.iter
(function
| 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.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
used for ordering (ast.ml: expression ids are unique but not
parent-before-child). *)
let sort_by key l = List.stable_sort (fun a b -> compare (key a) (key b)) (List.rev l) in
{ moves = sort_by (fun m -> pos_key m.mv_pos) sink.s_moves;
drops = sort_by (fun d -> pos_key d.dr_pos) sink.s_drops;
rcs = sort_by (fun r -> pos_key r.rc_pos) sink.s_rcs;
residuals = sort_by (fun r -> pos_key r.rs_pos) sink.s_res }