feat(compiler): remove @gc from the language — GC-ness is fully inferred (7b)

`@gc` is no longer part of the language: a developer never writes or mentions
it. GC-ness is decided entirely by inference (structural cycles + demand
promotion), which the earlier 7b commits made complete and precise.

- parser: `@gc` on a class is now WO-E104 ("GC-ness is inferred; run
  `woc --dump-gc`. Remove it."). `is_gc` stays false; the class classifies by
  inference. No `.wo` in the repo carries `@gc` anymore.
- types.ml: retired the WO-W201 machinery (suggest_gc_annotation,
  has_recursive_structure(_type), has_unique_field, gc_suggestion_code) — it
  suggested `@gc`, now obsolete since inference traces exactly those classes.
- runner.ml: deleted the 8 WO-W201 gc-suggestion test blocks; the @gc-exemption
  test's `Cache` is made self-referential so inference classifies it gc without
  an annotation.
- fixtures: dropped `@gc` from rc.wo (Cache demand-promotes via its escape),
  elision.wo (Cache given a self-ref to stay structurally gc for the rc-elision
  dump), pricing-demo.wo (PriceCache doesn't escape -> now owned), and the
  abandoned-cycle/budget-steps corpus (Node is structurally gc). rc.wo keeps a
  placeholder comment line so its line-indexed rc assertions hold. Goldens
  re-blessed.
- docs: error catalog gains WO-E104 and marks WO-W201 retired; gc-cycle README
  records the keyword removal.

Verified: woc-test 553/0 (was 566 minus the 13 retired WO-W201 checks),
test_diag 14/0, oop-e2e 79/0, employee 8/0, log-watcher 7/0. `git grep '@gc'`
finds only comments — success criterion 1 met.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
shoney.arickathil 2026-08-18 19:19:09 +02:00
parent 023233895c
commit 3f842f854b
14 changed files with 50 additions and 169 deletions

1
.gitignore vendored
View file

@ -101,3 +101,4 @@ prototypes/wo-db/
__pycache__/
*.pyc
dist/
docs/examples/*/target/

View file

@ -126,6 +126,7 @@ let fail (st : state) (site : Ast.pos) (code : string) (message : string) : 'a =
let syntax_code = Diag.parsing_prefix ^ "01" (* WO-E101: generic syntax error *)
let table_code = Diag.parsing_prefix ^ "02" (* WO-E102: invalid @table(...) configuration *)
let gc_removed_code = Diag.parsing_prefix ^ "04" (* WO-E104: `@gc` — GC-ness is inferred *)
(* haxe-parity Task 2: the haxe keyword verdict table's `inline` row —
"adopt (values): const compile-time values; inline *functions*
@ -332,10 +333,19 @@ let parse_type_annotations (st : state) : type_annotations =
let table = ref None in
while peek st = Token.At do
ignore (advance st);
let at_pos = peek_pos st in
let name = expect_ident st "annotation name" in
(match name with
| "gc" ->
is_gc := true;
(* iteration 7b: `@gc` is not part of the language — GC-ness is inferred
(structural cycles + demand promotion; see `woc --dump-gc`). Reject it
rather than accept a meaningless annotation. `is_gc` stays false. *)
Diag.Collector.add st.collector
(Diag.error ~code:gc_removed_code ~file:st.file ~line:at_pos.Ast.line
~col:at_pos.Ast.col
~message:
"`@gc` is not a valid annotation: GC-ness is inferred by the \
compiler (run `woc --dump-gc`). Remove it." ());
skip_paren_args st
| "table" -> table := Some (parse_table_cfg st)
| _ -> skip_paren_args st);

View file

@ -302,34 +302,6 @@ let with_builtin_records (syms : symbols) : symbols =
{ acc with classes = StringMap.add name info acc.classes })
syms predeclared_records
let rec has_recursive_structure (cls : class_info) : bool =
List.exists (fun (_, ty, _, _) ->
match ty with
| Ast.Scalar name -> name = cls.name (* direct self-reference *)
| Ast.Ref name -> name = cls.name
| Ast.Multi name -> name = cls.name (* multi Self *)
| Ast.Map (k, v) -> k = cls.name || v = cls.name (* map<_, Self> / map<Self, _> *)
| Ast.Backlink _ -> false
| Ast.Nullable inner -> has_recursive_structure_type inner cls.name
) cls.fields
and has_recursive_structure_type (ty : Ast.field_ty) (cls_name : string) : bool =
match ty with
| Ast.Scalar name -> name = cls_name
| Ast.Ref name -> name = cls_name
| Ast.Multi name -> name = cls_name
| Ast.Map (k, v) -> k = cls_name || v = cls_name
| Ast.Backlink _ -> false (* a computed inverse holds no owned structure *)
| Ast.Nullable inner -> has_recursive_structure_type inner cls_name
(* @unique field -> persistent identity (plan's "When NOT to emit": a
class with a @unique field should not get the @gc suggestion even if
it also has recursive/shared structure). Annotation *names* only, per
Ast.field's own doc comment -- "unique" is what parse_field stores for
a bare `@unique`. *)
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
@ -338,15 +310,6 @@ let is_gc_class (syms : symbols) name =
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 *)
let suggest_gc_annotation ~file (cls : class_info) (collector : Diag.Collector.t) : unit =
if not cls.is_gc && Option.is_none cls.table && not (has_unique_field cls)
&& has_recursive_structure cls then
Diag.Collector.add collector
(Diag.warning ~code:gc_suggestion_code ~file ~line:cls.pos.line ~col:cls.pos.col
~message:(Printf.sprintf "%s has recursive/shared structure that borrow checker cannot prove. Consider adding @gc if this is an ephemeral in-memory cache. If this maps to a database table, keep owned (default)." cls.name) ())
(* Ast.field_ty -> the internal resolved typ. Hoisted out of
typecheck_program (where it was a local closure) so the .wob emitter
can reach the same mapping instead of keeping a second copy of it;
@ -515,9 +478,7 @@ let collect_declarations ~file (prog : program) (collector : Diag.Collector.t) :
| Some (existing : class_info) ->
report_duplicate_decl collector ~file ~kind:"class" ~name:c.name ~pos:c.pos
~first_pos:existing.pos
| None ->
classes := StringMap.add c.name info !classes;
suggest_gc_annotation ~file info collector)
| None -> classes := StringMap.add c.name info !classes)
| Ast.Interface i ->
let methods = List.map (fun (m : Ast.method_sig) ->
{ name = m.name;

View file

@ -14,11 +14,11 @@
22:5 ASSIGN self.prices = amount
25:3 METHOD adopt(take other: Product) -> Product
26:5 RETURN other
31:1 CLASS PriceCache @gc
32:3 FIELD entries: map<Text, Int>
35:1 TYPE Note
36:3 FIELD id: Id
37:3 FIELD body: Text
38:3 FIELD created: Timestamp = now()
41:1 METHOD discount(mut amount: Int, take pct: Int) -> Int
42:3 RETURN amount
30:1 CLASS PriceCache
31:3 FIELD entries: map<Text, Int>
34:1 TYPE Note
35:3 FIELD id: Id
36:3 FIELD body: Text
37:3 FIELD created: Timestamp = now()
40:1 METHOD discount(mut amount: Int, take pct: Int) -> Int
41:3 RETURN amount

View file

@ -27,7 +27,6 @@ class Product {
}
}
@gc
class PriceCache {
entries: map<Text, Int>
}

View file

@ -2,14 +2,15 @@
k0 TEXT "Cache"
k1 TEXT "Holder"
k2 TEXT "hits"
k3 TEXT "cache"
k4 TEXT "read"
k5 TEXT "proven"
k6 TEXT "main"
k7 INT 41
k8 INT 1
k3 TEXT "peer"
k4 TEXT "cache"
k5 TEXT "read"
k6 TEXT "proven"
k7 TEXT "main"
k8 INT 41
k9 INT 1
== CLASSES ==
c0 Cache flags=gc fields=[hits:SCALAR]
c0 Cache flags=gc fields=[hits:SCALAR, peer:GCREF]
c1 Holder flags=- fields=[cache:GCREF]
== INTERFACES ==
== VTABLES ==
@ -33,7 +34,7 @@ m2 main args=0 regs=6 [free fn] [ENTRY]
drops: pc 14 owned={} gc={r0}
drops: pc 15 owned={} gc={}
0000 NEW r0, c0
0001 LOADK r1, k7
0001 LOADK r1, k8
0002 SETF r0, f0, r1
0003 NEW r1, c1
0004 MOVE r2, r0
@ -42,7 +43,7 @@ m2 main args=0 regs=6 [free fn] [ENTRY]
0007 MOVE r4, r1
0008 CALL r4, m1
0009 MOVE r3, r4
0010 LOADK r5, k8
0010 LOADK r5, k9
0011 ADD r2, r3, r5
0012 BUILTIN r2, r2, print_int
0013 DROP r1

View file

@ -7,9 +7,9 @@
-- the emitted body must contain NO borrow op and NO rc op at all — the
-- disassembly below is the evidence. `main` is the contrast: an escape
-- into a field is a KEPT acquire, so RC_INC does appear there.
@gc
class Cache {
hits: Int
peer: ?Cache
}
class Holder {

View file

@ -27,7 +27,6 @@ class Product {
}
}
@gc
class PriceCache {
entries: map<Text, Int>
}

View file

@ -1,4 +1,4 @@
@gc
-- Cache is inferred `gc`: it escapes (returned out of `escaping`), so demand promotion traces it.
class Cache {
n: Int
}

View file

@ -1231,98 +1231,6 @@ let () =
check "Float is not a builtin scalar" (not (Types.is_builtin_scalar "Float"));
check "Timestamp is a builtin scalar" (Types.is_builtin_scalar "Timestamp")
let () =
(* class Node { next: Node } -- direct self-reference, no @gc, no
@table, no @unique field: WO-W201 must fire, at the class's own
(real) file/line/col, and a warning-only run must still exit 0
(Diag.Collector's severity-keyed exit-code contract). *)
let path = "node.wo" in
let _, collector = typecheck_str ~file:path "class Node {\n next: Node\n}\n" in
let diags = Diag.Collector.diagnostics collector in
check_eq "gc-suggestion: exactly one diagnostic (WO-W201)" ~expected:1
~actual:(List.length diags) string_of_int;
(match diags with
| [ d ] ->
check "gc-suggestion: code is WO-W201" (d.Diag.code = "WO-W201");
check "gc-suggestion: severity is Warning" (d.Diag.severity = Diag.Warning);
check "gc-suggestion: real file/line/col (node.wo:1:1, the `class` token)"
(d.Diag.site.Diag.file = path && d.Diag.site.Diag.line = 1 && d.Diag.site.Diag.col = 1)
| _ -> check "gc-suggestion: exactly one diagnostic" false);
check_eq "gc-suggestion: a warning-only run exits 0, not 1" ~expected:0
~actual:(Diag.Collector.exit_code collector) string_of_int
let () =
(* @gc class Cache { next: Cache } -- same recursive shape as above,
but already @gc: WO-W201 must NOT fire. *)
let _, collector = typecheck_str ~file:"cache.wo" "@gc\nclass Cache {\n next: Cache\n}\n" in
check_eq "gc-suggestion: @gc class reports nothing" ~expected:0
~actual:(List.length (Diag.Collector.diagnostics collector)) string_of_int
let () =
(* @table(...) class Node2 { next: Node2 } -- recursive, but DB-backed
via @table: WO-W201 must NOT fire (plan: "@table -> must be owned"). *)
let _, collector =
typecheck_str ~file:"node2.wo"
"@table(name: \"nodes\")\nclass Node2 {\n next: Node2\n}\n"
in
check_eq "gc-suggestion: @table class reports nothing" ~expected:0
~actual:(List.length (Diag.Collector.diagnostics collector)) string_of_int
let () =
(* class Node3 { id: Id @unique; next: Node3 } -- recursive, but has a
@unique field (persistent identity): WO-W201 must NOT fire (plan's
"When NOT to emit" list, second bullet). *)
let _, collector =
typecheck_str ~file:"node3.wo"
"class Node3 {\n id: Id @unique\n next: Node3\n}\n"
in
check_eq "gc-suggestion: class with a @unique field reports nothing" ~expected:0
~actual:(List.length (Diag.Collector.diagnostics collector)) string_of_int
let () =
(* class Point { x: Int; y: Int } -- a plain data struct, no
recursive/shared fields: WO-W201 must NOT fire either. *)
let _, collector =
typecheck_str ~file:"point.wo" "class Point {\n x: Int\n y: Int\n}\n"
in
check_eq "gc-suggestion: simple data struct reports nothing" ~expected:0
~actual:(List.length (Diag.Collector.diagnostics collector)) string_of_int
let () =
(* class Calc { items: multi Item } (golden/ast/body-statements.wo's own
shape) -- a `multi` field of an UNRELATED type, not `multi Self`.
has_recursive_structure must key off self-reference, not "any multi
field": a bare `Ast.Multi _ -> true` would spuriously fire WO-W201
on every plain data class that merely holds a collection. *)
let _, collector =
typecheck_str ~file:"calc.wo" "class Calc {\n items: multi Item\n}\n"
in
check_eq "gc-suggestion: unrelated `multi Item` field reports nothing" ~expected:0
~actual:(List.length (Diag.Collector.diagnostics collector)) string_of_int
let () =
(* class Bucket { entries: map<Text, Item> } -- same over-trigger risk
for `map`, neither side self-referential. *)
let _, collector =
typecheck_str ~file:"bucket.wo" "class Bucket {\n entries: map<Text, Item>\n}\n"
in
check_eq "gc-suggestion: unrelated `map<Text, Item>` field reports nothing" ~expected:0
~actual:(List.length (Diag.Collector.diagnostics collector)) string_of_int
let () =
(* class Tree { children: multi Tree } -- `multi Self` must still fire
(the plan's own literal example of the heuristic). *)
let path = "tree.wo" in
let _, collector =
typecheck_str ~file:path "class Tree {\n children: multi Tree\n}\n"
in
let diags = Diag.Collector.diagnostics collector in
check_eq "gc-suggestion: `multi Self` still fires WO-W201" ~expected:1
~actual:(List.length diags) string_of_int;
match diags with
| [ d ] -> check "gc-suggestion: `multi Self` diagnostic is WO-W201" (d.Diag.code = "WO-W201")
| _ -> check "gc-suggestion: `multi Self` exactly one diagnostic" false
let () =
(* class BadExample { code: INVALID_TYPE } -- INVALID_TYPE is not a
builtin, class, or interface: WO-E225 must fire, at
@ -1955,13 +1863,15 @@ let () =
(Option.is_some (find_substring ~needle:"previous loop iteration" d.Diag.message)))
let () =
(* @gc is exempt from all of it (spec rule 5): the exact shape that is
WO-E301 above is silent here, and produces no move-table entries
either — a @gc transfer is an rc site, not a move. *)
(* A traced (gc) class is exempt from all of it (spec rule 5): the exact
shape that is WO-E301 above is silent here, and produces no move-table
entries either — a gc transfer is an rc site, not a move. `Cache` is
self-referential (`peer: ?Cache`), so inference classifies it gc without
any annotation (iteration 7b). *)
let src =
"@gc\n\
class Cache {\n\
"class Cache {\n\
\ n: Int\n\
\ peer: ?Cache\n\
}\n\
\n\
fn keep(take c: Cache) -> Int {\n\

View file

@ -209,13 +209,14 @@ opcode-27/28 retirement, and the sweep list.
**Phase 2b (landed).** Demand promotion: the ownership pass, run in collect
mode, promotes any class whose value *must escape* (returned, stored where it
outlives its scope) — the acyclic-but-aliased case (`PriceCache`) inference by
structure cannot see. So GC-ness is now fully inferred (cycles by structure +
aliasing by demand), and `@gc` is redundant everywhere. What remains is
*removing the `@gc` keyword itself* — the parser rejecting it, and the RC/`@gc`
golden + `test_diag` assertions being rewritten — which is coupled to Phase 3
(the RC machinery those tests cover is deleted there), so the keyword removal
lands with Phase 3.
outlives its scope) — the acyclic-but-aliased case inference by structure cannot
see, targeting the escaping projection's type precisely. So GC-ness is fully
inferred: cycles by structure + aliasing by demand.
**`@gc` removed from the language (landed).** The keyword is now a hard error
(`WO-E104`): a developer never writes or mentions it; `woc --dump-gc` shows what
inference decided. WO-W201 (which suggested `@gc`) is retired. No `.wo` in the
repo carries `@gc`. This is the front-end half of iteration 7b, complete.
When 7b lands, the acceptance is:
- `woc --dump-gc docs/examples/gc-cycle` classifies `Node gc (cycle …)` /

View file

@ -34,6 +34,7 @@ half of the story ("moved here" / "borrowed here" / etc.).
| WO-E101 | generic syntax error: an unexpected token where the grammar expected something else, including running off the end of the file inside an unclosed block/type/interface body. Declaration-level recovery syncs to the next top-level keyword so one bad declaration yields one diagnostic, not a cascade. | `expected ')' or ',', got NEWLINE` |
| WO-E102 | an invalid `@table(...)` configuration: `name` given twice, an `index` with no columns, or an argument key other than `name`/`index`. | `@table(name: ...) given twice` |
| WO-E103 | haxe-parity Task 2. `inline fn ...` — the haxe keyword verdict table's own reject half of the `inline` row (`const` values are the adopted half). The whole declaration is discarded by the usual top-level recovery, same as any other bad declaration. | `` `inline fn` is rejected — optimization is the compiler's job `` |
| WO-E104 | iteration 7b. `@gc` on a class — the annotation is gone: GC-ness is inferred (structural cycles + demand promotion; `woc --dump-gc`). The annotation is skipped for recovery and the class classifies by inference. | `` `@gc` is not a valid annotation: GC-ness is inferred by the compiler (run `woc --dump-gc`). Remove it. `` |
## WO-E2xx / WO-W2xx — types (Task 6, `compiler/src/types.ml`; WO-E214 Task 8, `compiler/bin/main.ml`; WO-E215 plan 3 Task 2, `compiler/src/types.ml`; WO-E210/E216–E218/W202 haxe-parity Task 1, `compiler/src/types.ml`; WO-E201 haxe-parity Task 2, `compiler/src/types.ml`; WO-E208 haxe-parity Task 3, `compiler/src/types.ml`; WO-E203 haxe-parity Task 4, `compiler/src/types.ml`)
@ -41,7 +42,7 @@ half of the story ("moved here" / "borrowed here" / etc.).
| --- | --- | --- |
| WO-E201 | haxe-parity Task 2. An `and`/`or` operand whose type is confidently known (the same narrow, "stay silent when underivable" deriver WO-E209 uses — `confident_typ`) and is not `Bool` — this language has no truthiness. Reserved since Task 6, its first real emission site. Haxe-parity Task 3 gave it two more sites: a `switch` expression's arms disagree on their yielded type (the switch's own type is fixed by the first arm — types.ml's "first wins" convention — every later arm is checked against it, via the regular `.typ` inference, not `confident_typ`; since haxe-parity Task 4 the comparison is *structural* — two typedef records with the same shape are the same type, `typ_equal`); and (review fix, Critical 2) a `case` value whose representation (`WO_K_TEXT` vs. `WO_K_SCALAR`) doesn't match the switch subject's — a real VM segfault if unchecked (a `Text` subject picks EQS, and EQS's `str_check` dereferences whatever sits in a mismatched `Int` case value's register), checked via `confident_typ`, silent when either side is unresolved. Haxe-parity Task 4 added the union-subject site: a `case` value over a confidently union-typed subject that does not name one of that union's variants (a misspelled variant, a variant of some other union, or a plain literal — union arms match variants, never values). Task 4's fix round 1 added three inverse/porosity sites, each a reviewer-reproduced silent-wrong-behavior hole: a variant-named `case` over a confidently **`?Union`** subject (it can never match — `switch` does not narrow `?T`; the message points at handling nil first, since forced handling is Task 6's), a variant-named `case` over a confidently **non-union** subject (`switch n { case Lo: }` over `n: Int` silently ordinal-matched), and a **cross-union `==`/`!=`** (`X == P` from two different bare unions was silently true whenever the ordinals matched; same-union comparison stays legal). Lexical scope wins at every one of these sites — a local sharing a variant's name is never misread as one. | `` `Wat` is not a variant of union `Kind` `` |
| WO-E203 | haxe-parity Task 4 (`typedef` records + enum payload variants). A payload variant's argument count doesn't match its declaration, at either of the two places payload fields are positional: a construction (`Failed("a", "b")` against `Failed(reason: Text)`) or a `switch` pattern (`case Failed(a, b):`). The pattern site also rejects a non-name argument (`case Failed("x"):` — payload fields are bound positionally, never matched by value) and a payload-binding pattern sharing its arm with other values (`case Failed(r), Pending:` — the binding would be meaningless on the other match). Reserved since plan 2 Task 6; these are its first real emission sites, scoped to variant payloads only — user `fn`/method call arity is still the emitter's WO-E403, unchanged (see "Reserved, not yet emitted" below for the history of that gap). | `` variant `Failed` of `Status` takes 1 payload argument(s), given 2 `` |
| WO-W201 *(warning)* | a class has recursive/shared structure (a field, directly or through `ref`/`multi`/`map`/`?`, refers back to its own class) that the ownership pass cannot prove disjoint, has no `@table`, and has no `@unique` field — suggests `@gc`. | `Node has recursive/shared structure that borrow checker cannot prove. Consider adding @gc if this is an ephemeral in-memory cache. If this maps to a database table, keep owned (default).` |
| WO-W201 *(retired, iteration 7b)* | Suggested `@gc` for a recursive/shared class. Retired: `@gc` no longer exists (WO-E104) and inference classifies exactly these classes as traced automatically, so the suggestion is obsolete. | *(no longer emitted)* |
| WO-E202 | a `.field` access names a field that the base's class (a *declared* class — an unresolved/placeholder expression type never triggers this) doesn't have. | `unknown field \`price\` on \`Product\`` |
| WO-E206 | a constructor literal (`ClassName { ... }`) omits a field the class declares that is neither defaulted nor nullable. Haxe-parity Task 4 narrowed it from "omits any field with no default was already the rule, but defaults were unenforceable" to the real omittability rule: a field with a declared default is filled by the emitter (`TailState {}` — the sample's defaults-fill-in pattern), and a `?`-typed field omitted is nil (the zero word `NEW` already leaves), for classes and typedef records alike. | `missing field \`sku\` in constructor of \`Product\`` |
| WO-E207 | a constructor literal names a class that isn't declared anywhere in the (possibly multi-file) program. `typedef` records (haxe-parity Task 4) are classes to this check — a record name resolves here like any declared class. | `unknown type \`Widget\` in constructor` |

View file

@ -4,7 +4,6 @@
-- same trick runtime/test/test_cycle.c uses (BIG=130) so ASan can prove
-- a Node is actually freed, not just recycled inside the arena's own
-- freelist where a sanitizer can never see it.
@gc
class Node {
peers: multi Node
p1: Int

View file

@ -4,7 +4,6 @@
-- same trick runtime/test/test_cycle.c uses (BIG=130) so ASan can prove
-- a Node is actually freed, not just recycled inside the arena's own
-- freelist where a sanitizer can never see it.
@gc
class Node {
peers: multi Node
p1: Int