feat(compiler): is_gc_class is inference-first (7b Phase 2a)

GC-ness now comes from the inference pass, not only the annotation. A class is
traced if the structural SCC put it in `syms.traced`, OR (temporary bridge
until demand-promotion lands) it still carries `@gc`.

- Types.symbols gains a `traced : StringSet.t`; is_gc_class reads it (union'd
  with the surviving @gc annotation). All symbols literals + both merges carry
  the field.
- typecheck_all injects the classification once (Gcinfer.classify -> traced)
  into the merged table AND every module table, before typecheck/owner/emit.
- emit.ml routes the class gc-flag and the union/drop decision through
  is_gc_class instead of the raw `.is_gc`, so structurally-inferred gc classes
  get the runtime flag. Field-kind derivation already routed through is_gc_class.
- gcinfer.traced_names exposes the traced set for injection.

Effect: docs/examples/gc-cycle now COMPILES with no annotation (the WO-E301
use-after-move at the ring-closing store is gone) — traced classes alias
freely. Bytecode is byte-identical to writing `@gc class Node`.

Verified: woc-test 566/0 (goldens unchanged — every current @gc class stays gc
via the annotation branch, and no golden has a structural-gc-non-annotated
class); oop-e2e 79/0 (gc corpus green).

Not in this slice: demand-promotion (the acyclic-aliased PriceCache case still
needs the @gc bridge) and @gc-in-source-as-error (Phase 2b); the ring RUNNING
(the RC runtime doesn't implement nullable-gcref `?Node` fields — Phase 3).
WO-W201 still fires on gc-cycle (retired in Phase 4).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
shoney.arickathil 2026-08-18 16:51:06 +02:00
parent fadf52cd65
commit f130999018
5 changed files with 52 additions and 21 deletions

View file

@ -244,10 +244,12 @@ let merge_symbols (syms_list : Woc_lib.Types.symbols list) : Woc_lib.Types.symbo
typedefs = SM.union keep_first acc.typedefs s.typedefs;
unions = SM.union keep_first acc.unions s.unions;
modules = acc.modules @ s.modules;
traced = Woc_lib.Types.StringSet.union acc.traced s.traced;
})
Woc_lib.Types.{
classes = SM.empty; interfaces = SM.empty; free_fns = SM.empty;
typedefs = SM.empty; unions = SM.empty; modules = [];
traced = Woc_lib.Types.StringSet.empty;
}
syms_list
@ -358,6 +360,14 @@ let typecheck_all (collector : Woc_lib.Diag.Collector.t) ~(root : string)
(* haxe-parity Task 5: the predeclared `Error` record joins the merged
table only — see Types.with_builtin_records for why not per-file. *)
let syms = Woc_lib.Types.with_builtin_records (merge_symbols (List.map snd per_file_syms)) in
(* iteration 7b Phase 2: classify GC-ness once (structural SCC over the class
graph, union'd with surviving @gc annotations) and inject the traced set
into the merged table AND every module table, so Types.is_gc_class answers
from inference everywhere (field kinds, owner exemptions, the class flag). *)
let traced = Woc_lib.Gcinfer.traced_names (Woc_lib.Gcinfer.classify syms) in
let syms = { syms with Woc_lib.Types.traced } in
Hashtbl.fold (fun k v acc -> (k, { v with Woc_lib.Types.traced }) :: acc) module_syms []
|> List.iter (fun (k, v) -> Hashtbl.replace module_syms k v);
(* `~file_syms` (hotfix, multi-file double-report): `per_file_syms` and
`parsed` are both `List.map`s over the same original file list, in
the same order, so pairing them positionally is exact -- each

View file

@ -3255,7 +3255,7 @@ and call_window (p : pctx) (f : fstate) (v : views) (e : Ast.expr) ~(recv : Ast.
| Some u -> u.Types.u_has_payload
| None -> (
match Types.StringMap.find_opt n p.p_syms.Types.classes with
| Some (ci : Types.class_info) -> not ci.Types.is_gc
| Some (ci : Types.class_info) -> not (Types.is_gc_class p.p_syms ci.Types.name)
| None -> false))
| _ -> false)
| None -> false)
@ -4454,7 +4454,7 @@ let emit ~(syms : Types.symbols) ~(module_of : string -> string)
cols)
cfg.Ast.indexes
| None -> ());
{ cr_name = c.name; cr_gc = c.is_gc;
{ cr_name = c.name; cr_gc = Types.is_gc_class syms c.name;
cr_fields =
Array.of_list
(List.filter_map

View file

@ -75,6 +75,11 @@ type result = {
order : string list; (* all class names, sorted — deterministic dump order *)
}
(* the traced class names as a set, for injection into `Types.symbols.traced`
(what `Types.is_gc_class` consults). *)
let traced_names (r : result) : Types.StringSet.t =
SMap.fold (fun k _ acc -> Types.StringSet.add k acc) r.traced Types.StringSet.empty
let classify (syms : Types.symbols) : result =
let classes = syms.Types.classes in
let nodes =

View file

@ -125,6 +125,9 @@ and symbols = {
typedefs : typedef_info StringMap.t;
unions : union_info StringMap.t; (* haxe-parity Task 4 *)
modules : string list;
traced : StringSet.t;
(* iteration 7b: classes inferred `gc` (Gcinfer). Injected after
declaration collection; is_gc_class reads it. Empty until then. *)
}
(* Variant lookup by bare name, across every union in scope — how a
@ -327,11 +330,13 @@ and has_recursive_structure_type (ty : Ast.field_ty) (cls_name : string) : bool
let has_unique_field (cls : class_info) : bool =
List.exists (fun (_, _, _, anns) -> List.mem "unique" anns) cls.fields
(* iteration 7b: GC-ness is inference-first. A class is traced if the inference
pass put it in `syms.traced` (structural cycle, or a Phase-2 demand
promotion), OR — as a temporary bridge until demand promotion lands — it
still carries the `@gc` annotation for the acyclic-but-aliased case. *)
let is_gc_class (syms : symbols) name =
try
let cls = StringMap.find name syms.classes in
cls.is_gc
with Not_found -> false
StringSet.mem name syms.traced
|| (try (StringMap.find name syms.classes).is_gc with Not_found -> false)
let gc_suggestion_code = Diag.warning_prefix ^ "201" (* WO-W201 *)
@ -604,7 +609,8 @@ let collect_declarations ~file (prog : program) (collector : Diag.Collector.t) :
) prog.decls;
{ classes = !classes; interfaces = !interfaces; free_fns = !free_fns;
typedefs = !typedefs; unions = !unions; modules = !modules }
typedefs = !typedefs; unions = !unions; modules = !modules;
traced = StringSet.empty }
(* ============================================================
Pass 2: Body Typechecking
@ -2126,9 +2132,11 @@ let merge_syms_for_module (syms_list : symbols list) : symbols =
typedefs = StringMap.union keep_first acc.typedefs s.typedefs;
unions = StringMap.union keep_first acc.unions s.unions;
modules = acc.modules @ s.modules;
traced = StringSet.union acc.traced s.traced;
})
{ classes = StringMap.empty; interfaces = StringMap.empty; free_fns = StringMap.empty;
typedefs = StringMap.empty; unions = StringMap.empty; modules = [] }
typedefs = StringMap.empty; unions = StringMap.empty; modules = [];
traced = StringSet.empty }
syms_list
(* Generic structural walk over every Ctor/Call site in a program's

View file

@ -183,23 +183,31 @@ inferred). The developer writes no memory annotations for either.
## Run status
**This is a target sample for iteration 7b, which is not yet implemented.** It
does **not** build on today's toolchain — and the compile error is precisely
the motivation. `woc --emit docs/examples/gc-cycle` today reports:
Iteration 7b is landing in phases (plan:
[`../../superpowers/plans/2026-08-18-inferred-gc-mark-sweep.md`](../../superpowers/plans/2026-08-18-inferred-gc-mark-sweep.md)).
**Phase 1 (landed).** The inference pass classifies each class; `woc --dump-gc
docs/examples/gc-cycle` prints:
```
main.wo: error WO-E301: use of `a` after it was moved
c.next = a; <- `a` moved here
Node gc (cycle Node -> Node)
Segment owned
```
Under the current model a class instance is `owned` and single-owner, so storing
`b` into `a.next` **moves** it and closing the ring with `c.next = a` re-uses a
moved value. Iteration 7b classifies `Node` as **traced** — and *traced classes
alias freely* (spec §3 rule 5), so the ring becomes legal and the collector,
not ownership, reclaims it. The pieces still to build: the inference pass
(`gcinfer.ml`), `--dump-gc`, the `.wob` opcode-27/28 retirement, the sweep list,
and the incremental collector. Today's runtime still uses RC + a Bacon–Rajan
cycle collector behind an explicit `@gc` annotation (`runtime/src/gc.c`).
**Phase 2a (landed).** `Types.is_gc_class` is now inference-first, so `Node` is
traced with **no annotation** and *traced classes alias freely* — the ring
**compiles** (the old `WO-E301: use of \`a\` after it was moved` at `c.next = a`
is gone), and its bytecode is byte-identical to writing `@gc class Node`.
**Not yet: the ring runs.** On today's runtime (RC + Bacon–Rajan, `gc.c`) a
**nullable single-reference gc field** (`next: ?Node`) store/read is
unimplemented — even a one-hop `a.next = b; print(a.next.label)` traps
`null receiver` (the existing gc corpus only exercises `multi` gcref fields,
which do work). Running the ring, and reclaiming it, is **Phase 3**: the
incremental mark-sweep collector + full gcref field paths, the `.wob`
opcode-27/28 retirement, and the sweep list. Remaining front-end work
(**Phase 2b**): demand-promotion for the acyclic-but-aliased case, then
`@gc`-in-source becomes an error and the annotation bridge is removed.
When 7b lands, the acceptance is:
- `woc --dump-gc docs/examples/gc-cycle` classifies `Node gc (cycle …)` /