plum

#treesitter#compiler#wasm

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

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


cb48db9Peter John 2026-07-24T12:30:18+05:30
docs: add implementation plan for enum discriminant values
docs/superpowers/plans/2026-07-24-enum-discriminant-values.md ADDED
@@ -0,0 +1,385 @@
1
+ # Enum Discriminant Values Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Let an enum declare shared, uniformly-typed value fields (`enum Step(n: Int) = | READ_MIN_OCCURANCES(0) | ...`), readable via ordinary field access (`self.n`) on any instance regardless of variant, per `docs/superpowers/specs/2026-07-24-enum-discriminant-values-design.md`.
6
+
7
+ **Architecture:** Grammar (new `enum_param` rule + paren-form variant values, alongside today's unchanged bracket-form generic payload) → AST (`Enum.params`, `EnumVariant.values`) → parser → checker (arity/type validation against declared params; discriminant enums also get an entry in `ClassEnv`-shaped bookkeeping so field access can reuse the SAME offset-load mechanism a class field already uses, adjusted by a constant +1 slot for the leading tag — not literally inserted into `ClassEnv` itself, since that would misalign offsets by one slot; a small dedicated lookup path instead) → codegen (bare variant references belonging to a discriminant enum reuse the EXISTING payload-variant construction path via a synthetically-built `ast::FnCall` carrying the variant's declared literal values as its "arguments" — no new heap-layout code needed at all, since `[tag][field0][field1]...` is already exactly what payload variants use).
8
+
9
+ **Tech Stack:** tree-sitter (JS grammar), Rust (`plum-core`, `plum-checker`, `plum-wasm-codegen`).
10
+
11
+ ## Global Constraints
12
+
13
+ - Spec: `docs/superpowers/specs/2026-07-24-enum-discriminant-values-design.md`
14
+ - A variant has either non-empty `fields` (today's generic-payload feature, bracket-delimited `[...]`) or non-empty `values` (this feature, paren-delimited `(...)`), never both — checker-enforced, not grammar-enforced.
15
+ - An enum with declared `params` requires every variant to supply exactly `params.len()` values, type-unified against the params' declared types.
16
+ - Field access (`self.n`) on a discriminant-enum value must load from offset `(param_idx + 1) * 8` — **not** `param_idx * 8` — because slot 0 is always the tag, exactly matching today's payload-variant heap layout (`[tag: i32][field0][field1]...`). Getting this offset wrong is the single highest-risk mistake in this plan; Task 3's steps call this out explicitly.
17
+ - Ordinary enums (`Option`, `Result`, `Bool`, `Color`, ...) are completely unaffected — this is purely additive, gated on `params`/`values` being non-empty.
18
+ - Run `cargo test --workspace` and (from `tooling/tree-sitter-plum/`) `npx --yes tree-sitter-cli test` after every task.
19
+
20
+ ---
21
+
22
+ ### Task 1: Grammar — enum params and paren-form variant values
23
+
24
+ **Files:**
25
+ - Modify: `tooling/tree-sitter-plum/grammar.js`
26
+ - Modify: `tooling/tree-sitter-plum/test/corpus/enum.txt`
27
+
28
+ **Interfaces:**
29
+ - Consumes: nothing.
30
+ - Produces: `enum` gains an optional `field("params", ...)` before `"="`; `enum_field`'s existing `parameters` field gains a second alternative — parens containing an expression list (discriminant values) alongside the existing bracket-delimited type list.
31
+
32
+ - [ ] **Step 1: Read the current `enum`/`enum_field` rules first**
33
+
34
+ Read `tooling/tree-sitter-plum/grammar.js`'s current `enum` and `enum_field` rules directly — this plan's earlier research excerpts may have drifted from the exact current text (e.g. after Task 1 of the bracket-generics migration, `enum_field`'s parameters use `"[" commaSep1(choice($.type_identifier, $.generic)) "]"`). Use the actual current text as your starting point for the edits below, not this plan's paraphrase.
35
+
36
+ - [ ] **Step 2: Add `enum_param` and the `params` field to `enum`**
37
+
38
+ Add a new rule (near `generic_type`/`param`):
39
+
40
+ ```js
41
+ enum_param: ($) =>
42
+ seq(field("name", $.var_identifier), ":", field("type", $.type)),
43
+ ```
44
+
45
+ Extend `enum`'s rule to add an optional `params` field between the name and `"="` (mirroring how `class`'s `generics` field is positioned):
46
+
47
+ ```js
48
+ enum: ($) =>
49
+ seq(
50
+ "enum",
51
+ field("name", $.type_identifier),
52
+ field("params", optional(seq("(", commaSep1($.enum_param), ")"))),
53
+ "=",
54
+ $._indent,
55
+ optional(repeat(alias($.enum_field, $.field))),
56
+ $._dedent,
57
+ ),
58
+ ```
59
+
60
+ - [ ] **Step 3: Add the paren-form alternative to `enum_field`'s parameters**
61
+
62
+ Extend the existing bracket-only choice to also allow a paren-delimited expression list:
63
+
64
+ ```js
65
+ enum_field: ($) =>
66
+ seq(
67
+ "|",
68
+ field("name", $.type_identifier),
69
+ field(
70
+ "parameters",
71
+ optional(choice(
72
+ seq("[", commaSep1(choice($.type_identifier, $.generic)), "]"), // existing: generic type payload
73
+ seq("(", commaSep1($.expression), ")"), // new: discriminant value literals
74
+ )),
75
+ ),
76
+ ),
77
+ ```
78
+
79
+ - [ ] **Step 4: Regenerate the parser**
80
+
81
+ Run: `cd tooling/tree-sitter-plum && npx --yes tree-sitter-cli generate`
82
+ Expected: succeeds with no conflicts. If tree-sitter reports one (e.g. between the two `enum_field` parameter alternatives — an integer literal vs. a `type_identifier` can both start with different tokens, so this SHOULD be conflict-free, but verify), stop and report BLOCKED.
83
+
84
+ - [ ] **Step 5: Add corpus tests**
85
+
86
+ In `tooling/tree-sitter-plum/test/corpus/enum.txt`, add a new test block (match the file's existing header/divider format exactly — read it first):
87
+
88
+ ```
89
+ ================================================================================
90
+ enum - discriminant values
91
+ ================================================================================
92
+
93
+ enum Step(n: Int) =
94
+ | READ_MIN_OCCURANCES(0)
95
+ | READ_MAX_OCCURANCES(1)
96
+ ```
97
+
98
+ Do not hand-guess the expected S-expression tree — run Step 6 first, capture the REAL parser output, and paste that into the corpus file (same practice as the bracket-generics-migration and guard-clause-match plans' corpus steps).
99
+
100
+ - [ ] **Step 6: Run the corpus tests, fix the expected tree from real output, iterate to green**
101
+
102
+ Run: `cd tooling/tree-sitter-plum && npx --yes tree-sitter-cli test 2>&1 | tail -100`, using `npx --yes tree-sitter-cli parse -` on the new source (or a temp file) to get ground truth for the expected tree. Iterate until 100% pass including the new test AND every pre-existing test (in particular, re-confirm the EXISTING "enum - generic variant fields" test, `| Some[T]`, still passes unchanged — this task must not regress the bracket-form path).
103
+
104
+ - [ ] **Step 7: Commit**
105
+
106
+ ```bash
107
+ git add tooling/tree-sitter-plum/grammar.js tooling/tree-sitter-plum/src tooling/tree-sitter-plum/test/corpus/enum.txt
108
+ git commit -m "feat(grammar): add enum discriminant-value declarations"
109
+ ```
110
+
111
+ ---
112
+
113
+ ### Task 2: `plum-core` — AST and parser support
114
+
115
+ **Files:**
116
+ - Modify: `plum-core/src/ast.rs`
117
+ - Modify: `plum-core/src/parser.rs`
118
+ - Modify: `plum-core/tests/parser_test.rs`
119
+
120
+ **Interfaces:**
121
+ - Consumes: the regenerated grammar from Task 1 (`enum` node with an optional `"params"`-field child list of `enum_param` nodes; `enum_field` node whose `"parameters"` field may now contain either `type_identifier`/`generic` children (existing) OR arbitrary expression children (new)).
122
+ - Produces: `Enum.params: Vec<EnumParam>` (new, empty for ordinary enums); `EnumVariant.values: Vec<Expr>` (new, empty for ordinary variants).
123
+
124
+ - [ ] **Step 1: Add `EnumParam` and extend `Enum`/`EnumVariant` in `ast.rs`**
125
+
126
+ Read the current `Enum`/`EnumVariant` structs first (they may have shifted slightly). Add:
127
+
128
+ ```rust
129
+ #[derive(Debug, Clone, PartialEq)]
130
+ pub struct EnumParam {
131
+ pub name: String,
132
+ pub ty: Type,
133
+ }
134
+ ```
135
+
136
+ Extend `Enum`:
137
+
138
+ ```rust
139
+ #[derive(Debug, Clone, PartialEq)]
140
+ pub struct Enum {
141
+ pub name: String,
142
+ pub params: Vec<EnumParam>, // new; empty for an enum declared without "(...)"
143
+ pub variants: Vec<EnumVariant>,
144
+ }
145
+ ```
146
+
147
+ Extend `EnumVariant`:
148
+
149
+ ```rust
150
+ #[derive(Debug, Clone, PartialEq)]
151
+ pub struct EnumVariant {
152
+ pub name: String,
153
+ pub fields: Vec<String>, // existing, unchanged meaning
154
+ pub values: Vec<Expr>, // new; empty for an ordinary/generic variant
155
+ }
156
+ ```
157
+
158
+ Every existing call site that constructs an `Enum`/`EnumVariant` literal (in `plum-core/src/parser.rs`, and any test fixture that builds one directly rather than through parsing) needs its `params: vec![]` / `values: vec![]` added — grep for `Enum {` and `EnumVariant {` across the workspace and fix each one.
159
+
160
+ - [ ] **Step 2: Parse `enum`'s new `params` field and `enum_field`'s value-list alternative**
161
+
162
+ Read `parse_enum`/`parse_enum_variant` in `plum-core/src/parser.rs` (their exact current bodies — this plan's earlier research summarized them but may be slightly stale). Extend `parse_enum` to also collect `enum_param` children into `Enum.params` (a small new helper `parse_enum_param(&self, node: Node) -> EnumParam` mirroring `parse_field`'s two-child-read shape: name then type). Extend `parse_enum_variant` so that when a `parameters`-field child is an expression node (not `type_identifier`/`generic`), it's parsed via `parse_expression`/`unwrap_expr_node` into `EnumVariant.values` instead of `EnumVariant.fields` — the two are populated from disjoint child-kind sets (type-identifier/generic-kind children go to `fields` as today; everything else under the same field position is an expression, goes to `values`).
163
+
164
+ - [ ] **Step 3: Add a parser test**
165
+
166
+ In `plum-core/tests/parser_test.rs`, parse the `Step` example from Task 1 and assert: `Enum.params == vec![EnumParam { name: "n", ty: Type { name: "Int", generics: vec![] } }]`, and each variant's `values` is `vec![Expr::Int(0)]` / `vec![Expr::Int(1)]` respectively, with `fields` empty for both.
167
+
168
+ - [ ] **Step 4: Run `plum-core`'s tests**
169
+
170
+ Run: `cargo test -p plum-core 2>&1 | tail -60`
171
+ Expected: PASS.
172
+
173
+ - [ ] **Step 5: Commit**
174
+
175
+ ```bash
176
+ git add plum-core/src/ast.rs plum-core/src/parser.rs plum-core/tests/parser_test.rs
177
+ git commit -m "feat(plum-core): parse enum discriminant-value declarations"
178
+ ```
179
+
180
+ ---
181
+
182
+ ### Task 3: `plum-checker` — validate discriminant values and enable field access
183
+
184
+ **Files:**
185
+ - Modify: `plum-checker/src/lib.rs`
186
+ - Modify: `plum-checker/tests/checker_tests.rs`
187
+
188
+ **Interfaces:**
189
+ - Consumes: `Enum.params`/`EnumVariant.values` from Task 2.
190
+ - Produces: `EnumVariantInfo` gains a `values: Vec<ast::Expr>` field (populated only for discriminant variants, consumed by `plum-wasm-codegen` in Task 4); arity/type validation errors for malformed discriminant declarations; `self.n`-style field access resolves correctly on a discriminant-enum receiver.
191
+
192
+ This is the highest-risk task in this plan — the field-access offset (`(param_idx + 1) * 8`, not `param_idx * 8`) is easy to get wrong, since it's tempting to just reuse `ClassEnv`'s exact field-access code path (which assumes NO leading tag slot). **Do not** insert a discriminant enum's params directly into `ClassEnv` — that would misalign every field by one 8-byte slot (it would read/write the tag instead of the actual value). Read the "Field access" bullet in the design spec's Codegen section again before starting this task if anything below is unclear.
193
+
194
+ - [ ] **Step 1: Read `build_global_tables`, `CheckCtx`, `EnumVariantInfo` and the field-access check function first**
195
+
196
+ Read their current exact bodies in `plum-checker/src/lib.rs` (file:line references from the design survey, which may have drifted: `EnumVariantInfo`/`EnumVariants`/`ClassEnv`/`CheckCtx` type defs around lines 44-66; `build_global_tables` around lines 72-143; the field-access check inside whatever function checks `ast::AttrKind::Field` — search for `"cannot access field"` or similar).
197
+
198
+ - [ ] **Step 2: Extend `EnumVariantInfo` with a `values` field**
199
+
200
+ ```rust
201
+ #[derive(Debug, Clone, PartialEq)]
202
+ pub struct EnumVariantInfo {
203
+ pub enum_name: String,
204
+ pub tag: i32,
205
+ pub field_types: Vec<PlumType>,
206
+ pub values: Vec<ast::Expr>, // new; non-empty only for a discriminant variant
207
+ }
208
+ ```
209
+
210
+ Every existing construction site of `EnumVariantInfo` (there are at least two in `build_global_tables` — one for the built-in `Bool` variants, one in the enum-registration loop) needs `values: vec![]` added for the ordinary case.
211
+
212
+ - [ ] **Step 3: Populate `field_types`/`values` correctly for discriminant enums in `build_global_tables`**
213
+
214
+ The current enum-registration loop (inside `build_global_tables`'s first pass) computes each variant's `field_types` from `v.fields` (type-name strings). For a discriminant enum (`e.params` non-empty), EVERY variant's `field_types` must instead come from `e.params` (the same list, shared across all variants of that enum — this is what makes `self.n` resolve to a consistent type regardless of variant), and `values` comes from that specific variant's own `v.values`. Something like:
215
+
216
+ ```rust
217
+ ast::Item::Enum(e) => {
218
+ let shared_field_types: Vec<PlumType> = e.params.iter()
219
+ .map(|p| plum_type_from_ast(&p.ty))
220
+ .collect();
221
+ for (tag, v) in e.variants.iter().enumerate() {
222
+ let field_types = if !e.params.is_empty() {
223
+ shared_field_types.clone()
224
+ } else {
225
+ v.fields.iter()
226
+ .map(|f| plum_type_from_ast(&ast::Type { name: f.clone(), generics: vec![] }))
227
+ .collect()
228
+ };
229
+ enum_variants.insert(v.name.clone(), EnumVariantInfo {
230
+ enum_name: e.name.clone(),
231
+ tag: tag as i32,
232
+ field_types,
233
+ values: v.values.clone(),
234
+ });
235
+ }
236
+ }
237
+ ```
238
+
239
+ - [ ] **Step 4: Add arity/type validation for discriminant declarations**
240
+
241
+ Somewhere in `check_source` (or a small new helper called from it, once per `Item::Enum`), for every enum with non-empty `params`: every variant must have `values.len() == params.len()` (error otherwise, naming the variant and the expected/actual counts), and each value's inferred type (`infer_expr`) must unify against the corresponding param's declared type. For an enum with EMPTY `params`, every variant's `values` must also be empty — a non-empty `values` there is an error too (most likely caused by accidentally writing e.g. `Some(5)` where `Some[Int]` was meant). Add this validation pass; follow this file's existing conventions for where per-item declaration-level checks like this live (e.g. is there already a similar pass for classes/traits you can mirror the placement of).
242
+
243
+ - [ ] **Step 5: Enable field access on a discriminant enum receiver**
244
+
245
+ Add a new lookup table alongside `ClassEnv`/`EnumVariants` — reuse the exact same underlying shape (`Vec<(String, PlumType)>` per enum name) so the code reads almost identically to today's class-field lookup, but keep it a SEPARATE map (do not merge into `ClassEnv` — see the offset-misalignment warning above):
246
+
247
+ ```rust
248
+ /// Field names and types for every discriminant enum's shared params (`enum Foo(n: Int) = ...`),
249
+ /// keyed by the ENUM's name (not a variant name) — e.g. `"Step" -> [("n", TInt)]`. Field
250
+ /// access on a value of this type must load/store at offset `(field_idx + 1) * 8`, NOT
251
+ /// `field_idx * 8` like a class — slot 0 is always the variant's tag.
252
+ pub type EnumParams = BTreeMap<String, Vec<(String, PlumType)>>;
253
+ ```
254
+
255
+ Populate it in `build_global_tables` (one entry per discriminant enum, empty/no entry for ordinary enums), thread it through `CheckCtx` as a new field (`pub enum_params: &'a EnumParams`), and update `build_global_tables`'s return type/signature and its ONE call site in `check_source` (search for other callers too — `plum-wasm-codegen` calls this same function, see Task 4).
256
+
257
+ In the field-access check function, add a fallback: if `obj_ty`'s name isn't found in `ctx.classes`, check `ctx.enum_params` before falling through to today's permissive "unmodeled type" escape hatch — resolve the field the same way a class field would be (find by name, unify against its declared type), just sourced from `enum_params` instead of `classes`.
258
+
259
+ - [ ] **Step 6: Add checker tests**
260
+
261
+ In `plum-checker/tests/checker_tests.rs`:
262
+ 1. The `Step` example type-checks with no errors.
263
+ 2. A variant with the wrong number of discriminant values produces an error.
264
+ 3. A variant with a wrongly-typed discriminant value (e.g. a `Str` literal where `Int` is declared) produces an error.
265
+ 4. A non-empty `values` on a variant of a param-less (ordinary) enum produces an error.
266
+ 5. `self.n` (or `obj.n`) field access inside a method on a discriminant-enum receiver type-checks and resolves to the declared param's type; the same on an ORDINARY enum (no `params`) still fails exactly as it does today.
267
+
268
+ - [ ] **Step 7: Run `plum-checker`'s tests**
269
+
270
+ Run: `cargo test -p plum-checker 2>&1 | tail -100`
271
+ Expected: PASS, including your new tests, and every pre-existing enum/field-access test.
272
+
273
+ - [ ] **Step 8: Commit**
274
+
275
+ ```bash
276
+ git add plum-checker/src/lib.rs plum-checker/tests/checker_tests.rs
277
+ git commit -m "feat(plum-checker): validate enum discriminant values, enable field access on them"
278
+ ```
279
+
280
+ ---
281
+
282
+ ### Task 4: `plum-wasm-codegen` — compile discriminant-enum construction and field access
283
+
284
+ **Files:**
285
+ - Modify: `plum-wasm-codegen/src/lib.rs`
286
+ - Modify: `plum-wasm-codegen/tests/codegen_tests.rs`
287
+
288
+ **Interfaces:**
289
+ - Consumes: `EnumVariantInfo.values`/`field_types` (Task 3), `EnumParams` (Task 3) threaded into `LocalCtx`.
290
+ - Produces: a bare discriminant-variant reference (`READ_MIN_OCCURANCES`) heap-allocates and stores `[tag][value0][value1]...` using the variant's OWN declared literal values (not call-site args, since there is no call site); `self.n` field access loads from `(field_idx + 1) * 8` off a discriminant-enum receiver.
291
+
292
+ - [ ] **Step 1: Read the current `compile_expr`'s `ast::Expr::TypeName` arm, `compile_variant_construction`, the `Attribute`/`Field` codegen arm, `build_global_tables`'s one call site, and the `Collector`'s `walk_expr` first**
293
+
294
+ These are all in `plum-wasm-codegen/src/lib.rs`; use file:line references from the design survey as a starting point but READ the actual current code — this is the largest, most detail-sensitive file touched by this plan.
295
+
296
+ - [ ] **Step 2: Thread `EnumParams` into `LocalCtx` and its one construction site**
297
+
298
+ Add `enum_params: &'a plum_checker::EnumParams` to the `LocalCtx` struct (alongside the existing `classes: &'a ClassEnv` field), and update `build_global_tables`'s call site (now returning an extra value) plus wherever `LocalCtx` is constructed (there appear to be two near-duplicate codegen entry points in this file, given the earlier survey found `match_scratch_base`/`match_scratch_index` set up twice, around lines ~1843 and ~3387 — both need the new field threaded through if both actually construct a full `LocalCtx`; verify this by reading, don't assume).
299
+
300
+ - [ ] **Step 3: Make a bare discriminant-variant reference construct correctly**
301
+
302
+ The current `ast::Expr::TypeName(n)` arm in `compile_expr` (search `ast::Expr::TypeName(n) => match ctx.enum_variants.get(n)`) special-cases `info.field_types.is_empty()` (bare tag) vs. non-empty (today: an error, "carries a payload — construct it with '...(...)'"). Add a third case: `field_types` non-empty AND `info.values` non-empty (a discriminant variant) — construct it via the EXISTING `compile_variant_construction` function, unchanged, by building a synthetic `ast::FnCall` whose `args` are `info.values` wrapped as `ast::Arg::Positional`:
303
+
304
+ ```rust
305
+ ast::Expr::TypeName(n) => match ctx.enum_variants.get(n) {
306
+ Some(info) if info.field_types.is_empty() => {
307
+ Instruction::I32Const(info.tag).encode(body);
308
+ }
309
+ Some(info) if !info.values.is_empty() => {
310
+ let synthetic_call = ast::FnCall {
311
+ name: n.clone(),
312
+ args: info.values.iter().cloned().map(ast::Arg::Positional).collect(),
313
+ };
314
+ compile_variant_construction(info, &synthetic_call, expr, body, ctx, state)?;
315
+ }
316
+ Some(_) => return Err(format!("codegen: '{}' carries a payload — construct it with '{}(...)'", n, n)),
317
+ None => return Err(format!("codegen: type name '{}' is not yet supported as a value", n)),
318
+ },
319
+ ```
320
+
321
+ `compile_variant_construction` needs NO changes — it only reads `call.args`/`call.name` and `expr`'s pointer identity for scratch-slot lookup, all of which the synthetic call/the original `expr` already provide correctly.
322
+
323
+ - [ ] **Step 4: Register a scratch slot for bare discriminant-variant references in the `Collector`**
324
+
325
+ `compile_variant_construction`'s heap-allocation path requires a `classcall_scratch` entry keyed by the `expr` pointer (see `ctx.classcall_scratch.get(&scratch_key)`, which errors with "internal codegen error: missing variant-call scratch slot" if absent). Today, the `Collector`'s `walk_expr` only registers this slot for `ast::Expr::ClassCall` and payload-carrying `ast::Expr::FnCall` (search `classcall_scratch.insert` — there are two near-duplicate `Collector`-like structs in this file per the earlier survey; both need this fix, verify by reading). Add a new arm for `ast::Expr::TypeName(n)`:
326
+
327
+ ```rust
328
+ ast::Expr::TypeName(n) => {
329
+ let carries_baked_in_payload = self.cctx.enum_variants.get(n)
330
+ .map(|info| !info.values.is_empty())
331
+ .unwrap_or(false);
332
+ if carries_baked_in_payload {
333
+ let idx = self.next_classcall_slot;
334
+ self.next_classcall_slot += 1;
335
+ self.classcall_scratch.insert(expr as *const ast::Expr as usize, idx);
336
+ }
337
+ }
338
+ ```
339
+
340
+ Match this file's existing style for where `ast::Expr::TypeName` currently falls in this match (it may currently be in a catch-all arm doing nothing — check, and add this specific arm before/instead of that catch-all as appropriate).
341
+
342
+ - [ ] **Step 5: Field access on a discriminant-enum receiver**
343
+
344
+ In `compile_expr`'s `ast::AttrKind::Field(field_name)` arm (search `"cannot access field"`), add a fallback: if `obj_ty`'s name isn't in `ctx.classes`, check `ctx.enum_params` before erroring — resolve `field_idx`/`field_ty` the same way the class path does, but compute `offset = ((field_idx + 1) as u64) * 8` (the `+1` is the critical difference — slot 0 is always the tag). Compile `attr.object` (pushes the heap pointer, exactly as the class path does) then load at that offset with the width matching `field_ty` (`I64Load`/`F64Load`/`I32Load`, same pattern as the class path).
345
+
346
+ - [ ] **Step 6: Add codegen tests**
347
+
348
+ In `plum-wasm-codegen/tests/codegen_tests.rs`:
349
+ 1. A method reading `self.n` off a `Step`-shaped discriminant enum returns the correct declared value for at least two different variants (proving per-instance correctness, not a hardcoded constant) — compile and `run_main` an end-to-end program using the `Step` example.
350
+ 2. A discriminant-enum value used in `match` (destructuring by variant NAME, not by its field) still resolves and runs correctly — since these variants are now always heap pointers (matching payload-variant layout), this should already work via the existing pattern-match codegen path unchanged; write a test proving it.
351
+
352
+ - [ ] **Step 7: Run the codegen tests**
353
+
354
+ Run: `cargo test -p plum-wasm-codegen 2>&1 | tail -100`
355
+ Expected: PASS, including your new tests and every pre-existing enum/field-access/pattern-match test (in particular, the payload-free-variant and payload-variant tests noted in the design spec's survey — confirm neither regressed).
356
+
357
+ - [ ] **Step 8: Run the full workspace suite**
358
+
359
+ Run: `cargo test --workspace 2>&1 | tail -100`
360
+ Expected: all PASS.
361
+
362
+ - [ ] **Step 9: Commit**
363
+
364
+ ```bash
365
+ git add plum-wasm-codegen/src/lib.rs plum-wasm-codegen/tests/codegen_tests.rs
366
+ git commit -m "feat(plum-wasm-codegen): compile enum discriminant-value construction and field access"
367
+ ```
368
+
369
+ ---
370
+
371
+ ### Task 5: Final verification
372
+
373
+ **Files:** none (verification only).
374
+
375
+ - [ ] **Step 1: Full workspace test suite**
376
+
377
+ Run: `cargo test --workspace 2>&1 | tail -100`
378
+ Expected: all PASS.
379
+
380
+ - [ ] **Step 2: Tree-sitter corpus suite**
381
+
382
+ Run: `cd tooling/tree-sitter-plum && npx --yes tree-sitter-cli test 2>&1 | tail -60`
383
+ Expected: all PASS.
384
+
385
+ - [ ] **Step 3: No commit needed** — verification only.