plum

#treesitter#compiler#wasm

git clone https://git.pyrossh.dev/plum

A statically typed, imperative programming language inspired by rust, python


docs/superpowers/plans/2026-07-25-wasm-gc-migration.md
# Wasm-GC Migration Implementation Plan

> **For agentic workers:** Steps use checkbox (`- [ ]`) syntax for tracking. Work task-by-task, in order — later tasks assume earlier ones landed.

**Goal:** Replace `plum-wasm-codegen`'s hand-rolled bump-allocator memory model with wasm-gc (`struct`/`array` types, `ref`/`ref null`, `struct.new`, `ref.test`/`br_on_cast`), per `docs/superpowers/specs/2026-07-25-wasm-gc-migration-design.md`. Retire the bump allocator, `MemoryType`, and `bump_global` entirely. Unblocks `libs/std/list.plum`'s `todo` methods (`add`/`set`/`removeAt`/`remove`/`clear`/`reverse`), which is the concrete deliverable.

**Architecture:** One wasm-gc `struct` type per concrete (post-monomorphization) class/struct. Per enum, one abstract supertype `struct` plus one concrete subtype `struct` per variant (wasm-gc declared subtyping), matched via `ref.test`/`br_on_cast`**including `Bool`**, per explicit user decision below. `Str` becomes `array<i8>`. The linear memory, `MemoryType`, and `bump_global` are deleted entirely once nothing depends on them — static string data moves to a **passive** data segment consumed via `array.new_data` (no active memory needed for it at all).

**Decisions made during planning that the design spec left open** (read these before starting — they resolve real ambiguities, not stylistic preferences):

1. **Bool is a full wasm-gc struct, no special-casing** (explicit user decision — the alternative, keeping `Bool` as a raw `i32` for cheap control flow, was rejected in favor of literal spec uniformity). Consequence: every `if`/`while` condition, `&&`/`||` operand, and `assert` — anywhere a `Bool` value drives wasm's native control flow (which requires a raw `i32`, not a `ref`) — needs a `ref.test (ref $True)` inserted to convert the `Bool` ref into an `i32` immediately before it's consumed by `Instruction::If`/`BrIf`. Comparison operators (`==`, `!=`, `<`, etc.) go the other direction: the native predicate produces an `i32` 0/1, which then selects between the pre-allocated `$True`/`$False` singletons (see next point) via a small `if/else` yielding a `ref`.
2. **Payload-free variants are pre-allocated once as globals, not constructed per-use.** `True`, `False`, `None`, and any other zero-field variant of any enum are immutable and interchangeable, so each gets exactly one `struct.new` at a module-initialization point, stored in a `global`; every subsequent bare reference (`True`, `None`, a comparison result, a discriminant-free `enum` variant) is a `global.get` of that singleton, not a fresh allocation. This avoids needless GC churn for the single highest-frequency value shape in the language (every boolean expression).
3. **Closure env pointers are `anyref` in the shared `call_indirect` signature**, not a per-closure concrete type. Every closure literal has a structurally different captured-variable shape, but all closures of the same plum `fn(...)` type must share one wasm function-type for `call_indirect` to dispatch across them uniformly — exactly the role `i32` (an untyped address) plays today. Each closure's own compiled body does `ref.cast (ref $ItsOwnEnvType)` on the incoming `anyref` as its first instruction, recovering its concrete, statically-typed env struct before reading captured fields. The closure value itself (`{table_index, env_pointer}`) becomes a two-field `struct` (`i32`, `anyref`) instead of a bump-allocated pair.
4. **String data uses a passive data segment + `array.new_data`**, not an active one — this needs no `memory` section at all, which is what lets the memory section disappear completely rather than sticking around just for string constants.

**Tech Stack:** `wasm-encoder` 0.220 (already supports `CoreTypeEncoder::struct_`/`array`/`subtype`/`rec`, `Instruction::StructNew`/`StructGet`/`StructSet`/`ArrayNewData`/`RefTestNonNull`/`BrOnCast`, `RefType`/`HeapType::Concrete` — verified directly against the vendored source, no upgrade needed), `wasmtime` 28 (`Config::wasm_gc`/`wasm_function_references` exist and are reachable — the `"gc"`/`"gc-drc"`/`"gc-null"` cargo features are already in wasmtime's `default` feature set, so no `Cargo.toml` change is needed, just calling the `Config` methods in test setup). **Caveat directly from wasmtime's own doc comment on `Config::wasm_gc`: "Wasmtime's implementation of the GC proposal is still in progress and generally not ready for primetime."** Budget time in Task 1 to discover real limitations empirically, not just from docs.

## Global Constraints

- Spec: `docs/superpowers/specs/2026-07-25-wasm-gc-migration-design.md`. This plan's four numbered decisions above fill gaps that spec left unresolved (Bool, closures, singleton variants, string data segment kind) — treat them as binding, not as this plan's own invention to second-guess mid-implementation.
- This is fundamentally **not** an incrementally-toggleable migration: the moment `Str` (or anything) becomes a `ref`, every class/struct/enum that can hold one transitively can no longer live in linear memory (wasm-gc references cannot be stored in linear memory at all — that's the point of the proposal's separate reference universe). Task 2 is therefore one large, multi-step "big bang" task that switches construction, field/variant access, match dispatch, strings, and closures over together — expect it to be the bulk of this plan's work, and expect the codebase to be non-compiling partway through its steps until the whole task lands. Do not attempt to keep every individual step's diff independently green; the task-level `cargo test --workspace` at the end of Task 2 is the real gate.
- `plum-checker`'s type-checking/monomorphization needs **no changes** — it already resolves every generic instantiation to a concrete type before codegen runs (confirmed by reading `plum-checker/src/monomorphize.rs`'s existing role in `compileSource`). If you find this assumption wrong, stop and report BLOCKED rather than silently expanding scope into `plum-checker`.
- Run `cargo build --workspace --all-targets` after every step that touches `plum-wasm-codegen/src/lib.rs` (compilation alone is a fast, cheap checkpoint even mid-task before behavior is testable), and `cargo test --workspace` at the end of every task.
- `[T]` array literals/indexing and wasm exception-handling remain explicitly out of scope (per the spec) — do not add them opportunistically because GC types make them easier.

---

### Task 1: Wasmtime GC config verification + wasm-gc type-section emitter foundation

**Files:**
- Modify: `plum-wasm-codegen/Cargo.toml` (only if the empirical check in Step 1 finds the default features insufficient)
- Modify: `plum-wasm-codegen/tests/codegen_tests.rs`, `plum-wasm-codegen/tests/examples_test.rs`
- Modify: `plum-wasm-codegen/src/lib.rs`

**Interfaces:**
- Consumes: nothing (bottom of this plan's dependency chain).
- Produces: every `wasmtime::Engine`/`Config` used in tests has the GC proposal enabled; a new type-section emitter that, given the checker's monomorphized `Source`, emits one wasm-gc `struct` per concrete class, one abstract-supertype-plus-per-variant-subtype set per enum (Bool included, built in exactly like the existing hardcoded `True`/`False` `EnumVariantInfo` registration), and one `array<i8>` type for `Str`**wired up but not yet consumed by any real codegen path**, so all EXISTING tests (still running through the untouched bump-allocator codegen) stay green. This task is purely additive.

- [x] **Step 1: Confirm wasmtime's GC `Config` actually works empirically, first**

Before writing any type-emitter code, write a throwaway test (in `codegen_tests.rs`, can be deleted/replaced by Task 2) that: builds a `wasmtime::Config`, calls `.wasm_gc(true)` (and `.wasm_function_references(true)` — check by reading `wasmtime-28.0.1`'s `config.rs`, vendored at `~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/wasmtime-28.0.1/src/config.rs`, whether GC has an undeclared dependency on function-references that isn't auto-enabled), constructs an `Engine` from it, and hand-encodes (via `wasm_encoder` directly, no plum involved) the smallest possible module with one GC struct type, one function that does `struct.new_default` + returns it typed as `(ref null $T)`, and confirms it validates AND instantiates+runs under that `Engine`. If this fails or exhibits a real engine limitation, that finding drives every subsequent step — report it before proceeding, don't route around it silently.

- [x] **Step 2: Update the test harness's `Engine`/`Config` construction**

Read the current `Engine::default()` (or equivalent) call sites in `codegen_tests.rs` and `examples_test.rs` (search `wasmtime::Engine`) and replace with the `Config` confirmed working in Step 1. Every test in both files must still pass unchanged (they're still exercising the untouched bump-allocator codegen at this point) — this step is purely "can a GC-enabled engine still run today's non-GC output," which it should, since enabling a feature doesn't retroactively require using it.

- [x] **Step 3: Read the current type-registration surface before extending it**

Read `plum-checker`'s `ClassEnv`/`EnumVariants`/`EnumVariantInfo` definitions (`plum-checker/src/lib.rs`) and `plum-wasm-codegen`'s `CompileCtx`/`LocalCtx` (`plum-wasm-codegen/src/lib.rs`) exact current fields — this plan's earlier research read them as of the enum-discriminant-values plan's completion, but re-verify, since intervening work may have touched them.

- [x] **Step 4: Add a GC type registry, populated but unconsumed**

Add a new struct (e.g. `GcTypeRegistry`) that, given the monomorphized `ast::Source` plus the existing `ClassEnv`/`EnumVariants` tables, assigns and records a wasm type-section index for: every concrete class name, every enum's abstract supertype, every enum variant's concrete subtype, and one shared `array<i8>` index for `Str`. Emit these into a real `TypeSection` via `CoreTypeEncoder::struct_`/`array`/`subtype` (structs need `SubType { is_final: false, supertype_idx: None, ... }` for a class since it needs no subtyping, but an enum's supertype needs `is_final: false` so variants CAN subtype it, and each variant subtype needs `SubType { supertype_idx: Some(super_idx), ... }`) — group the whole enum's supertype+variants in one `rec` group (`CoreTypeEncoder::rec`) since wasm-gc subtyping requires supertype and subtype to be declared in the same recursive group or supertype declared earlier in an earlier group; read `wasm-encoder`'s `rec`/`subtype` doc comments (`~/.cargo/registry/.../wasm-encoder-0.220.1/src/core/types.rs`) to get the ordering constraint exactly right. Thread the resulting index tables through `CompileCtx` as new fields (e.g. `class_type_idx: HashMap<String, u32>`, `enum_super_type_idx: HashMap<String, u32>`, `variant_type_idx: HashMap<String, u32>`, `str_type_idx: u32`), populated in `compileSource` but not read by anything else yet.

- [x] **Step 5: Add a corresponding Rust test proving the type section is well-formed**

Add a test that compiles a small source (one class, one enum with a payload variant and a payload-free variant) through `compileSource`, and asserts the resulting bytes validate via `wasmparser::validate` under the GC-enabled config from Step 1 — this proves the new type section alone (still unused by real codegen) is well-formed, before Task 2 starts depending on it.

- [x] **Step 6: Run full verification**

`cargo build --workspace --all-targets` then `cargo test --workspace`. Expected: 100% pass, identical results to before this task — nothing behavioral changed yet.

- [x] **Step 7: Commit**

```bash
git add plum-wasm-codegen/src/lib.rs plum-wasm-codegen/tests/codegen_tests.rs plum-wasm-codegen/tests/examples_test.rs plum-wasm-codegen/Cargo.toml
git commit -m "feat(plum-wasm-codegen): add wasm-gc type-section emitter (unconsumed) and GC-enabled test Engine"
```

---

### Task 2: The big switchover — construction, field/variant access, match dispatch, strings, and closures onto wasm-gc

**Files:** `plum-wasm-codegen/src/lib.rs` (the overwhelming majority of this task), `plum-wasm-codegen/tests/codegen_tests.rs`, `plum-wasm-codegen/tests/examples_test.rs`

**Interfaces:**
- Consumes: the type registry from Task 1.
- Produces: every codegen path that currently reads/writes `ctx.bump_global` instead does `struct.new`/`struct.get`/`struct.set`/`array.*`; `match` dispatch uses `ref.test`/`br_on_cast`; the memory section, `bump_global`, `HEAP_BASE`, `STRING_BASE` are deleted with nothing left depending on them.

This task is large enough that its steps are grouped by concern. Within each concern, **read the current exact code first** — this plan describes shapes and gives file:line references from the research pass that wrote it, but by the time you reach a given step, earlier steps in this same task will have already changed surrounding code. Re-verify against the live file, not this plan's prose.

#### 2a. Bool/enum singletons and control-flow conversion (do this first — nearly everything else depends on being able to produce/consume a Bool)

- [x] **Step 1: Read `compileSource`'s current initialization sequence, `compileStmt`'s `If`/`While` arms, `boolean_operator`/comparison codegen, and `assert` codegen**

These are all in `plum-wasm-codegen/src/lib.rs` — find them by searching `ast::Stmt::If`, `ast::Stmt::While`, `ast::Expr::Bool(`, `ast::Expr::Compare(`, `ast::Stmt::Assert`.

- [x] **Step 2: Emit one global per payload-free variant across the whole program (including `True`/`False`)**

In `compileSource`, after the type registry (Task 1) is built, walk every enum's variants (plus the built-in `Bool`) and for each payload-free one, emit a `global` (mutable: false is ideal but wasm-gc struct globals may need an initializer expression capable of `struct.new` — read `wasm_encoder`'s `ConstExpr`/`GlobalSection` to confirm whether a GC `struct.new` is legal inside a global's constant-initializer expression under this wasm-gc revision; if not, fall back to a `start` function that runs once and stores each singleton into a mutable global before `main`/any export can run). Record `variant_name -> global_index` in a new `CompileCtx` field.

- [x] **Step 3: Rewrite conditional control flow to convert Bool refs to i32 via `ref.test`**

Wherever a `Bool`-typed value currently feeds `Instruction::If`/`BrIf` unchanged (it's already an i32 today), insert `Instruction::RefTestNonNull(HeapType::Concrete(true_variant_type_idx))` immediately before it. This applies to at least: `if`/`else if` conditions, `while` conditions, `assert`, and short-circuit `&&`/`||` (which currently, per existing codegen, likely already lower to nested `If` blocks for short-circuiting — read the current `boolean_operator` codegen to confirm the exact shape before changing it).

- [x] **Step 4: Rewrite comparison operators to produce a Bool ref from a native i32 predicate**

Currently a comparison (`==`, `<`, etc.) directly leaves an i32 on the stack as the Bool result. Change it to: compute the native i32 predicate as today, then `Instruction::If(BlockType::Result(bool_ref_type))` / push `GlobalGet(true_singleton)` / `Else` / `GlobalGet(false_singleton)` / `End`, so the result is a proper `Bool` ref matching every other Bool-producing expression.

- [x] **Step 5: Update every OTHER payload-free bare-variant construction site to use its singleton global instead of allocating**

Search for the current handling of `ast::Expr::TypeName(n)` where `info.field_types.is_empty()` (bare tag construction) — this currently does `Instruction::I32Const(info.tag)`; change it to `Instruction::GlobalGet(singleton_idx)`.

- [x] **Step 6: Build and run affected tests only, iterating**

`cargo build -p plum-wasm-codegen` first (fast compile-only check — expect many errors from downstream code still assuming i32 Bool/tags, which is fine, that's 2b/2c/2d's job). Do not attempt `cargo test` yet — the crate won't fully compile again until struct/enum construction (2b) and match dispatch (2d) also land, since they're mutually dependent on the same representation.

#### 2b. Struct/class construction and field access

- [x] **Step 1: Read the current `ast::Expr::ClassCall` construction arm and the `Attribute`/`Field` read/write codegen**

Search `ast::Expr::ClassCall`, `ast::AttrKind::Field`, `ast::AssignTarget::Field`.

- [x] **Step 2: Rewrite construction to `struct.new`**

For each `ClassCall`, look up the class's declared field order (not `call.fields`'s written order — construction must push values in DECLARATION order for `struct.new`). For each declared field, find the matching `call.fields` entry by name and `compileExpr` its value onto the stack; once every field's value is pushed, emit `Instruction::StructNew(class_type_idx)`. The `classcall_scratch` local is very likely no longer needed for this case (no bump-pointer bookkeeping requires holding an intermediate address — verify by reading whether anything else in the current code relies on that scratch slot for `ClassCall` specifically before deleting its allocation for this case).

- [x] **Step 3: Rewrite field reads to `struct.get`**

Replace the current class-field-read arm's `emitLoad` at a byte offset with `compileExpr` (pushes the object ref) followed by `Instruction::StructGet { struct_type_index: class_type_idx, field_index }`.

- [x] **Step 4: Rewrite field writes (`self.x = ...`, chained `a.b.c = ...`) to `struct.set`**

Same shape: compile the object ref, compile the new value, `Instruction::StructSet { struct_type_index, field_index }`. `StructSet`'s stack order — verify from `wasm-encoder`'s encode impl or the wasm-gc spec whether it expects `(struct_ref, value)` or `(value, struct_ref)` push order; get this from the actual instruction encoding, not assumption, since getting it backwards produces a type-valid-looking-in-isolation-but-wrong-at-runtime bug that validation may not always catch depending on other stack contents.

- [x] **Step 5: Rewrite discriminant-enum field access (`self.n` on an `enum Foo(n: Int)`)** analogously, through `ctx.enum_params`, using `StructGet`/`StructSet` against the enum's own struct layout with the `field_idx + 1` tag-slot offset convention preserved conceptually (though the "+1 for tag" byte-offset trick is a linear-memory artifact — under wasm-gc each variant subtype simply has its own explicit field list starting at field index 0 for the first real field, no tag slot needed at all, since the tag IS the type itself now, discoverable via `ref.test`, not a stored value read at a fixed offset).

#### 2c. String migration to `array<i8>`

- [x] **Step 1: Read the current string literal encoding, `registerStringConcatHelper`, `registerIntToStringHelper`, and string interpolation codegen**

Search `compile_static_string` (or equivalent), `registerStringConcatHelper`, `registerIntToStringHelper`.

- [x] **Step 2: Static string literals become passive-data-segment `array.new_data`**

Each string literal's bytes go into one passive `DataSection::passive(bytes)` segment (no active memory offset — confirm `wasm_encoder`'s `DataSection::passive` doesn't require a preceding memory section to exist at all, per this plan's Decision 4). At the literal's use site, emit `Instruction::ArrayNewData { array_type_index: str_type_idx, array_data_index }`.

- [x] **Step 3: Rewrite `registerStringConcatHelper` against `array.new`/`array.copy`**

Read the exact current helper body first. The new version needs the combined length (`array.len` on both inputs, or track lengths already known from a length-prefix design if you keep one — decide whether `Str`'s `array<i8>` needs an explicit stored length or relies on `array.len`, and note that GC arrays DO track their own length natively, so a manual length prefix is no longer needed at all, simplifying this over the current length-prefixed-blob design), allocates a new array of that combined size (`array.new_default` sized dynamically — check whether `array.new_default` accepts a non-constant size operand; it does, per the wasm-gc spec, size is a runtime i32 operand), then two `Instruction::ArrayCopy { array_type_index_dst, array_type_index_src }` calls to copy each source's bytes into the destination at the right offset.

- [x] **Step 4: Rewrite `registerIntToStringHelper` similarly** — read its current byte-writing loop and adapt each byte store to `array.set` (or build into a fixed-size scratch and `array.new_data`-style copy, whichever the current algorithm's shape most directly maps to).

- [x] **Step 5: Update string interpolation codegen** to call the rewritten helpers; verify `Str`-typed class/enum fields (now `ref null $StrArray` instead of i32) round-trip correctly through 2b/2d's `struct.get`/`struct.set` and `ref.test`/variant-subtype field lists.

#### 2d. Match dispatch via `ref.test`/`br_on_cast`

- [x] **Step 1: Read `compileMatch`, `compileMatchArmsMulti`, `compileCasePositions`, `compileVariantEqArm` (or its current name — it may have been renamed during the camelCase pass), `compileVariantConstructorArm`, and `compileFieldPatterns` in full before changing any of them** — this is the most intricate part of the existing codegen (recursive multi-subject, multi-position, nested-constructor-pattern dispatch) and the part most likely to have subtle behavioral requirements not obvious from a partial read.

- [x] **Step 2: Replace the `HEAP_BASE` range-check + tag-load-and-compare pattern with `ref.test`**

Every place that currently does "range-check against `HEAP_BASE`, then conditionally load+compare a tag" (both the top-level bare-Name/Class-pattern arms and the nested `compileFieldPatterns` equivalents) becomes a single `Instruction::RefTestNonNull(HeapType::Concrete(variant_type_idx))` (or `RefTestNullable` if the value can legitimately be null in context — decide per call site) directly on the subject ref, producing the same "did this match" i32 the existing `If`/`Else` structure around it already expects — the surrounding control-flow shape (fall through to `compileMatchArmsMulti(rest, ...)` on mismatch) doesn't need to change, only how the boolean test itself is computed.

- [x] **Step 3: Replace payload-field extraction with `ref.cast` + `struct.get`**

Where a matched constructor pattern currently loads a field via `emitLoad(field_vt, offset)` from the raw pointer, first `Instruction::RefCastNonNull(HeapType::Concrete(variant_type_idx))` (narrowing the statically-typed-as-supertype subject to its concrete variant subtype — the match arm's binding scope is exactly where this narrowing is valid) then `Instruction::StructGet { struct_type_index: variant_type_idx, field_index }`.

- [x] **Step 4: Consider (but don't require) using `br_on_cast` for the top-level dispatch chain** — the spec's Components section suggests `ref.test`/`br_on_cast` as the replacement mechanism; a straightforward `ref.test` + `if/else` chain (mirroring today's control-flow shape exactly, per Step 2) is very likely simpler to get right than restructuring into `br_on_cast`'s branch-table-like shape, and is equally correct. Only reach for `br_on_cast` if the `if/else` chain form turns out to hit a real wasm-gc validation or structural limitation — don't restructure working control flow for its own sake.

#### 2e. Closures

- [x] **Step 1: Read `compileClosureLiteral`, `compileClosureCall`, `compileClosureBody`, and the `Collector`'s closure-discovery pass in full.**

- [x] **Step 2: Give every closure literal its own env `struct` type**, one field per captured variable in `free_vars` order, field types matching each captured variable's own type (which may itself now be a `ref` type post-2b/2c). Register these in the type registry alongside classes/enums.

- [x] **Step 3: Rewrite `compileClosureLiteral`**: build the env via `struct.new` (pushing each captured local's current value, per Decision 3 — snapshot-by-value semantics are unchanged, only the allocation mechanism changes), then build the 2-field closure struct `{i32 table_index, anyref env}` via `struct.new`, casting/widening the concrete env ref to `anyref` for storage (a supertype widening — confirm whether `wasm-encoder`/wasm-gc requires an explicit instruction for this or whether it's implicit at the type-checking level when a subtype value is used where a supertype/`anyref` is expected; if implicit, no extra instruction is needed).

- [x] **Step 4: Rewrite `compileClosureBody`**: its first instructions must `RefCastNonNull` (or the nullable variant, matching the declared param type) the incoming `anyref` env parameter down to `(ref $ThisClosuresEnvType)` before any captured-field reads, then use `struct.get` for each.

- [x] **Step 5: Update the shared `call_indirect` function-type signatures** (`fnParamTypeToWasmSig`/`ClosureSigKey` and wherever `call_indirect` types are registered/deduped) so the env-pointer parameter position is `ValType::Ref(RefType::ANYREF)` instead of `ValType::I32`, everywhere this shape is constructed.

#### 2f. Retire the bump allocator entirely

- [x] **Step 1: Remove `HEAP_BASE`, `STRING_BASE`, `bump_global` (the field, its threading through `CompileCtx`/`LocalCtx`, and its initialization in `compileSource`), and the `addMemory`/memory-section code in `WasmModule::finish`** once `cargo build -p plum-wasm-codegen` shows nothing references them anymore. If anything still does, that's a sign an earlier 2a–2e step was missed or incomplete — go back, don't leave a partial bump-allocator fallback path.
- [x] **Step 2: Remove now-dead scratch-slot bookkeeping** (`classcall_scratch` etc., in `Collector` and `LocalCtx`) for any use case fully replaced by direct stack-order `struct.new` construction (per 2b Step 2) — but only what's actually provably dead; `match`'s subject-holding scratch locals are still needed (now typed as `ref null` instead of `i32`), don't remove those.

#### 2g. Full verification for Task 2

- [x] **Step 1: `cargo build --workspace --all-targets`** — iterate until clean.
- [x] **Step 2: Update every existing `codegen_tests.rs`/`examples_test.rs` test that encoded assumptions about the old representation** (e.g. anything asserting raw byte layout, or relying on `HEAP_BASE`-adjacent behavior) — re-verify each still asserts the right OBSERVABLE behavior (a program's computed result), not the old mechanism.
- [x] **Step 3: `cargo test --workspace`** — expected: 100% pass. This is the real gate for this entire task; do not commit Task 2 before this is green.
- [x] **Step 4: Commit**

```bash
git add plum-wasm-codegen/src plum-wasm-codegen/tests
git commit -m "feat(plum-wasm-codegen): migrate structs, enums, strings, and closures to wasm-gc"
```

---

### Task 3: Implement `List`'s `todo` methods against the new struct representation

**Files:** `libs/std/list.plum`, `plum-wasm-codegen/tests/codegen_tests.rs`

**Interfaces:**
- Consumes: `struct.new`/`struct.set`/`ref null` field semantics from Task 2 — specifically, that unlinking a `Node` (overwriting a `next`/`prev` field with `None`) makes it unreachable and GC-reclaimed with no explicit free.
- Produces: `add`, `set`, `removeAt`, `remove`, `clear`, `reverse` on `List[T]` (currently `todo`) have real implementations; `Map` (built on `List`) works transitively.

- [x] **Step 1: Read `libs/std/list.plum`'s current `Node`/`List` field declarations and the already-implemented `get`/`length`/`each`/`map` methods** for the established style (`Option[Node]` traversal via `match current`) to match.
- [x] **Step 2: Implement `add`** — append (or prepend, per the existing doc comment on `add` — re-read it; the comment currently says "adds the specified elements to the start of the list" — confirm this is the intended semantic, since "add" more commonly means append, and fix the comment or the implementation, whichever is actually wrong, rather than silently implementing whichever is more convenient) by splicing a new `Node` in via `self.tail`/`self.head` and updating `size`. **Was prepend in the doc comment, append in spirit (`makeList` builds in call order) — fixed the comment; implemented as append.**
- [x] **Step 3: Implement `set`** — traverse to index `i` (mirroring `get`'s traversal), overwrite the found node's `value` field, return `Some(oldValue)` or `None` if out of bounds.
- [x] **Step 4: Implement `removeAt`/`remove`** — traverse to the target node, splice it out by rewriting its neighbors' `prev`/`next` to point at each other (or to `None` at the list's ends, updating `self.head`/`self.tail` accordingly), decrement `size`. The removed node becomes unreachable once nothing points to it — no manual free needed. **Added a shared `unlink` helper for the splice logic, used by both.**
- [x] **Step 5: Implement `clear`** — reset `self.head`/`self.tail` to `None` and `size` to `0`; every node becomes unreachable transitively.
- [x] **Step 6: Implement `reverse`** — build (or in-place relink) a new list with `prev`/`next` swapped at every node. **The pre-existing declared signature was `reverse(self, v: fn(T) -> Bool) -> List` (a leftover, unrelated predicate param — looks copy-pasted from `sort`); fixed to `reverse(self) -> List`.**
- [x] **Step 7: Add codegen tests** — with an important caveat discovered while writing them (see "Discovered pre-existing gaps" below): `libs/std/list.plum` itself **cannot currently compile** through `plum-wasm-codegen`, for reasons unrelated to Task 3's new method bodies — this was already true of the pre-existing `get`/`each`/`map` before this task touched the file. The tests therefore exercise a field-for-field/statement-for-statement port of the same six methods against a deliberately **non-generic** `Node`/`List`/`NodeLink` (swapping the generic `Option[Node]` for a monomorphic `NodeLink` enum) — this validates the actual wasm-gc mechanics (self-referential nullable-via-enum struct fields, `struct.set` mutation through an aliased reference, `ref.test` dispatch, GC reachability after `removeAt`/`clear`) without depending on the separate, deeper checker gap. Two tests added: `listAddSetRemoveAtRemoveClearReverseAllWorkCorrectly` and `removingEveryNodeInALoopLeavesAnEmptyCorrectlyFunctioningList` (the latter is this step's required unreachability proof).
- [x] **Step 8: Run `cargo test --workspace`.** 100% pass (211 tests across the workspace), including the new tests.
- [x] **Step 9: Commit**

#### Discovered pre-existing gaps (found while implementing this task, NOT fixed — out of scope for "implement List's methods")

While testing against the REAL generic `List[T]`/`Node[T]`/`Option[Node]` shape, compilation failed with errors like `unknown type 'Option' in GC type registry` and `type name 'None' is not yet supported as a value`. Root cause, confirmed by direct inspection of the monomorphized AST: **`plum_checker::plumTypeFromAst` drops generic type arguments entirely** (`Option[Int]` and bare `Option` both become `PlumType::TNamed("Option")`) — so a class or enum-variant field declared with a concrete instantiation of a generic type (`Node.next: Option[Node]`) keeps referencing the generic type's bare name. Once monomorphization actually specializes that generic type for a concrete argument (renaming it to e.g. `Option$Int` and REMOVING the unspecialized original — confirmed via direct inspection), the field's stored type annotation is left pointing at a name that no longer exists. This is a real, pre-existing gap in `plum-checker`'s generics/monomorphization support (not something Task 2's wasm-gc work introduced — it would misbehave identically under the old bump-allocator representation, just never crash as loudly), and it blocks `libs/std/list.plum`'s `get`/`each`/`map` from compiling too, not just this task's new methods. Properly fixing it needs `PlumType` to represent type applications (not just bare names) and the monomorphizer to rewrite field-type annotations consistently with however it renames the types they reference — a separate, sizable piece of work.

Two smaller, genuinely wasm-gc-migration-scoped bugs were found and fixed along the way (both covered by the existing/new test suite):
- `Expr::Compare`'s codegen unconditionally emitted `i64.eq`/`i64.ne` for any non-`Float` comparison — correct when `Bool`/enums were `i32`, but wrong now that they're wasm-gc refs. Fixed to emit `ref.eq` for `==`/`!=` between reference-typed operands (`plum-wasm-codegen/src/lib.rs`).
- Method calls (`self.method(...)`/`obj.method(...)`) never packed trailing arguments into a GC array for a method with a variadic (`...T`) parameter — only plain function calls did. `add(self, values: ...T)` was the first variadic *method* anywhere in the codebase, which is how this surfaced. Fixed by porting the same variadic-packing logic to the method-call codegen path.

Also found (not fixed, not wasm-gc-related): plum's grammar's `class_call` rule requires named (`field: value`) arguments — there is no positional class-construction syntax — so the pre-existing `makeList`'s `List(None, None, 0)` (positional) has never actually compiled either. Left as-is since fixing it also requires resolving a second, separate issue (passing an already-packed variadic array through to another variadic parameter in `List(...).add(values)`).

```bash
git add libs/std/list.plum plum-wasm-codegen/tests/codegen_tests.rs
git commit -m "feat(libs/std): implement List's add/set/removeAt/remove/clear/reverse on wasm-gc"
```

---

### Task 4: Final verification and README update

**Files:** `README.md` (Known Gaps section)

- [x] **Step 1: `cargo build --workspace --all-targets` and `cargo test --workspace`.** Expected: 100% pass.
- [x] **Step 2: `cd tooling/tree-sitter-plum && npx --yes tree-sitter-cli test`** — unaffected by this plan (no grammar changes), confirm it's still 100% as a sanity check that nothing unrelated regressed.
- [x] **Step 3: Run `scripts/test-examples.sh`** (added in an earlier session) to confirm every `examples/*.plum` file still compiles and runs correctly through the real CLI end to end, not just the unit test suite.
- [x] **Step 4: Update README's "Known gaps" section** — the current entry "`libs/std`'s actual `List`/`Map` still don't fully compile ... `List`'s methods beyond `get`/`length` ... are still `todo`" is resolved by Task 3; remove or rewrite that bullet to reflect reality. Check whether `Map`'s own gap (references `Buffer`/`Stringable` trait-bounded dispatch, which `plum-checker` still doesn't process) is still accurate and leave that part as-is if so — don't overclaim `Map` fully works if trait dispatch still doesn't exist.
- [x] **Step 5: Commit**

```bash
git add README.md
git commit -m "docs: update Known gaps for wasm-gc migration and List's new methods"
```