plum

#treesitter#compiler#wasm

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

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


aedebc1Peter John 2026-07-20T19:28:05+05:30
docs: add implementation plan for generic-enum multi-instantiation
docs/superpowers/plans/2026-07-20-generic-enum-multi-instantiation.md ADDED
@@ -0,0 +1,464 @@
1
+ # Generic Enum Multi-Instantiation 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:** Remove the "a generic enum may only be instantiated at one concrete type per program" limitation, so `Option<Int>` and `Option<Str>` (or any two concrete instantiations of the same generic enum) can coexist in one program.
6
+
7
+ **Architecture:** Mangle variant names with the same suffix as their enum's own mangled name (`Some` → `Some$Int`/`Some$Str`, `None` → `None$Int`/`None$Str`), rewrite variant-construction call sites to reference the mangled variant name, and rewrite `match` pattern names against the correct specialization (resolved from the subject's inferred concrete type). All changes are contained inside the already-built `plum_checker::monomorphize` module — no new pipeline integration is needed. The existing collision-detection guard (which rejected a second instantiation rather than corrupting the first) becomes unnecessary and is removed, since variant names are now uniquely mangled per specialization.
8
+
9
+ **Tech Stack:** Rust (`plum-checker` crate only for this plan — `plum-core`/`plum-wasm-codegen` need no changes).
10
+
11
+ ## Global Constraints
12
+
13
+ - **Narrower residual limitation, replacing the old one:** a payload-free variant (e.g. `None`) used as a *bare value outside of a `match` pattern* (i.e. parsed as `ast::Expr::TypeName`, not inside a case pattern) still can't be disambiguated between multiple concrete instantiations of its enum, since nothing at that expression alone pins down which instantiation it belongs to. This is out of scope to fix here — such usage will fail to resolve cleanly (an "unknown"/unmodeled-name error from the checker or codegen) rather than silently misbehaving, which is an acceptable, documented trade-off. Constructing via a payload-carrying sibling (`Some(5)`) and matching (`Some(v) => ...`, `None => ...`) — the overwhelmingly common usage pattern — is fully supported.
14
+ - Everything else the generics-monomorphization plan already scoped out (a method introducing its own additional generic parameter; trait-bound enforcement; `libs/std` compiling as-is) is unchanged.
15
+ - Follow existing code style: terse one-line "why" comments only where non-obvious; error messages use the `"monomorphize: ..."` prefix.
16
+ - Must leave `cargo test --workspace` and `npx --yes tree-sitter-cli test` (from `tooling/tree-sitter-plum/`) green.
17
+
18
+ ---
19
+
20
+ ### Task 1: Mangle enum variant names; rewrite construction and match-pattern references; remove the collision guard
21
+
22
+ **Files:**
23
+ - Modify: `plum-checker/src/monomorphize.rs`
24
+ - Modify: `plum-checker/tests/checker_tests.rs`
25
+ - Modify: `plum-wasm-codegen/tests/codegen_tests.rs`
26
+ - Modify: `README.md`
27
+
28
+ **Interfaces:**
29
+ - Consumes: nothing new — this is entirely internal to the already-built `monomorphize.rs`.
30
+ - Produces: `resolve_enum_instantiation`'s signature changes from `(&mut self, call: &ast::FnCall, env: &TypeEnv)` to `(&mut self, call: &mut ast::FnCall, env: &TypeEnv)` (it now rewrites `call.name`, matching the shape `resolve_class_instantiation`/`resolve_fn_instantiation` already have). `Monomorphizer`'s `enum_variant_owner: BTreeMap<String, String>` field is replaced by `enum_variant_mangling: BTreeMap<String, BTreeMap<String, String>>` (mangled enum name -> {original variant name -> mangled variant name}). Nothing outside `plum-checker` depends on either name.
31
+
32
+ - [ ] **Step 1: Write failing tests**
33
+
34
+ Read `plum-checker/tests/checker_tests.rs` around lines 389-466 first to confirm current line numbers still match (these are the two existing tests this step modifies/replaces).
35
+
36
+ Replace the existing `generic_enum_single_instantiation_type_checks` test's assertion (it currently asserts the variant name stays unmangled — that assertion is about to become wrong on purpose). Change:
37
+
38
+ ```rust
39
+ let some = opt.variants.iter().find(|v| v.name == "Some")
40
+ .expect("expected `Some` variant on `Option$Int`");
41
+ assert_eq!(some.fields, vec!["Int".to_string()], "Some's field should be concrete Int");
42
+ ```
43
+
44
+ to:
45
+
46
+ ```rust
47
+ let some = opt.variants.iter().find(|v| v.name == "Some$Int")
48
+ .expect("expected `Some$Int` (mangled) variant on `Option$Int`");
49
+ assert_eq!(some.fields, vec!["Int".to_string()], "Some's field should be concrete Int");
50
+ ```
51
+
52
+ Replace the entire `generic_enum_multi_instantiation_is_a_clear_error` test (currently asserting the old collision-error behavior, which this task removes) with a coexistence test:
53
+
54
+ ```rust
55
+ #[test]
56
+ fn generic_enum_multiple_instantiations_coexist_and_type_check() {
57
+ // The SAME generic enum instantiated at two different concrete types in one
58
+ // program must now type-check correctly for BOTH instantiations — this is the
59
+ // behavior this task adds (previously this was a documented, rejected limitation).
60
+ let src = "\
61
+ enum Option =
62
+ | Some(a)
63
+ | None
64
+
65
+ useInt() -> Int =
66
+ o = Some(5)
67
+ match o
68
+ Some(v) =>
69
+ v
70
+ None =>
71
+ 0
72
+
73
+ useStr() -> Str =
74
+ o = Some(\"x\")
75
+ match o
76
+ Some(v) =>
77
+ v
78
+ None =>
79
+ \"z\"
80
+ ";
81
+ let source = parse(src);
82
+ let result = check_source(&source);
83
+ assert!(result.is_ok(), "expected Ok, got {:?}", result.err());
84
+
85
+ // Directly prove both specializations exist independently, with distinct
86
+ // mangled variant names, so neither collides with the other.
87
+ let mono = plum_checker::monomorphize::monomorphize_source(&source)
88
+ .expect("monomorphize should succeed");
89
+ let has_enum_with_variant = |enum_name: &str, variant_name: &str| {
90
+ mono.items.iter().any(|it| matches!(it, Item::Enum(e) if e.name == enum_name
91
+ && e.variants.iter().any(|v| v.name == variant_name)))
92
+ };
93
+ assert!(has_enum_with_variant("Option$Int", "Some$Int"), "expected Option$Int with Some$Int");
94
+ assert!(has_enum_with_variant("Option$Str", "Some$Str"), "expected Option$Str with Some$Str");
95
+ }
96
+ ```
97
+
98
+ Read `plum-wasm-codegen/tests/codegen_tests.rs` around line 759 (`generic_enum_specialized_and_matched_runs_correctly`) for context, then append a new test right after it:
99
+
100
+ ```rust
101
+ #[test]
102
+ fn generic_enum_multiple_instantiations_coexist_and_run_correctly() {
103
+ let src = "\
104
+ enum Option =
105
+ | Some(a)
106
+ | None
107
+
108
+ unwrapIntOr(o: Option, default: Int) -> Int =
109
+ match o
110
+ Some(v) =>
111
+ v
112
+ None =>
113
+ default
114
+
115
+ unwrapStrLenOr(o: Option, default: Int) -> Int =
116
+ match o
117
+ Some(v) =>
118
+ v.length()
119
+ None =>
120
+ default
121
+
122
+ main() -> Int =
123
+ unwrapIntOr(Some(13), 0) + unwrapStrLenOr(Some(\"abcd\"), 0)
124
+ ";
125
+ let source = parse(src);
126
+ let bytes = compile_source(&source).expect("compile failed");
127
+ assert_eq!(run_main(&bytes), 17);
128
+ }
129
+ ```
130
+
131
+ (If `Str.length()` isn't a real, already-working method in this codebase — check `libs/std/str.plum` or existing string tests — substitute a different `Str`-typed operation you confirm already compiles, such as comparing the string against a literal and branching, so the test still exercises `Option$Str` meaningfully without depending on something unimplemented. Adjust the expected `main` result accordingly if you change the body.)
132
+
133
+ - [ ] **Step 2: Run to see them fail**
134
+
135
+ Run: `cargo test -p plum-checker --test checker_tests generic_enum` and `cargo test -p plum-wasm-codegen --test codegen_tests generic_enum`
136
+ Expected: `generic_enum_single_instantiation_type_checks` fails (variant is still named `Some`, not `Some$Int`); `generic_enum_multiple_instantiations_coexist_and_type_check` and its codegen counterpart fail with the old collision error (`"more than one concrete type"`).
137
+
138
+ - [ ] **Step 3: Read the current file, then make the edits**
139
+
140
+ Read `plum-checker/src/monomorphize.rs` in full first — Task/fix-round history means line numbers may have shifted slightly from what's shown below; verify each snippet still matches before replacing it.
141
+
142
+ **3a. Update `specialize_enum`** to mangle variant names:
143
+
144
+ Replace:
145
+
146
+ ```rust
147
+ /// Produces a concrete, specialized copy of a generic enum under `mangled_name`,
148
+ /// substituting every variant field type name that matches one of the enum's
149
+ /// generic parameters with its resolved concrete type's name.
150
+ pub fn specialize_enum(e: &ast::Enum, subst: &Substitution, mangled_name: &str) -> ast::Enum {
151
+ ast::Enum {
152
+ name: mangled_name.to_string(),
153
+ variants: e.variants.iter().map(|v| ast::EnumVariant {
154
+ name: v.name.clone(),
155
+ fields: v.fields.iter().map(|f| {
156
+ subst.get(f).map(|t| t.to_string()).unwrap_or_else(|| f.clone())
157
+ }).collect(),
158
+ }).collect(),
159
+ }
160
+ }
161
+ ```
162
+
163
+ with:
164
+
165
+ ```rust
166
+ /// Produces a concrete, specialized copy of a generic enum under `mangled_name`,
167
+ /// substituting every variant field type name that matches one of the enum's
168
+ /// generic parameters with its resolved concrete type's name.
169
+ ///
170
+ /// Variant names are ALSO mangled here, with the same suffix as the enum's own
171
+ /// name (e.g. `Some` -> `Some$Int`) — even a payload-free variant like `None`.
172
+ /// This is necessary because the runtime `EnumVariants` table (built by
173
+ /// `build_global_tables`) is keyed by bare variant name globally: without this,
174
+ /// two specializations of the same generic enum would both register a variant
175
+ /// literally named `Some`, colliding in that flat table.
176
+ pub fn specialize_enum(e: &ast::Enum, subst: &Substitution, mangled_name: &str) -> ast::Enum {
177
+ let params = enum_generic_params(e);
178
+ let type_args: Vec<PlumType> = params.iter().filter_map(|p| subst.get(p).cloned()).collect();
179
+ ast::Enum {
180
+ name: mangled_name.to_string(),
181
+ variants: e.variants.iter().map(|v| ast::EnumVariant {
182
+ name: mangle(&v.name, &type_args),
183
+ fields: v.fields.iter().map(|f| {
184
+ subst.get(f).map(|t| t.to_string()).unwrap_or_else(|| f.clone())
185
+ }).collect(),
186
+ }).collect(),
187
+ }
188
+ }
189
+ ```
190
+
191
+ **3b. Replace the `enum_variant_owner` field** on `Monomorphizer` with `enum_variant_mangling`:
192
+
193
+ Replace:
194
+
195
+ ```rust
196
+ /// Bare variant name -> the mangled enum name that has currently "claimed" it.
197
+ /// Because the runtime `EnumVariants` table is keyed by BARE variant name
198
+ /// globally, two specializations of the same generic enum would both try to
199
+ /// register `"Some"`, silently colliding. We detect that here and error rather
200
+ /// than corrupt one specialization (single-instantiation-per-generic-enum is a
201
+ /// documented limitation of this pass).
202
+ enum_variant_owner: BTreeMap<String, String>,
203
+ ```
204
+
205
+ with:
206
+
207
+ ```rust
208
+ /// Mangled enum name -> {original variant name -> mangled variant name}, e.g.
209
+ /// `"Option$Int" -> {"Some": "Some$Int", "None": "None$Int"}`. Populated eagerly
210
+ /// (in `resolve_enum_instantiation`, at the moment an instantiation's concrete
211
+ /// type arguments become known) rather than waiting for the worklist to actually
212
+ /// produce that specialization — so both a construction call site and a later
213
+ /// `match` on the same specialization can rewrite variant names consistently,
214
+ /// regardless of processing order.
215
+ enum_variant_mangling: BTreeMap<String, BTreeMap<String, String>>,
216
+ ```
217
+
218
+ **3c. Replace `resolve_enum_instantiation`** entirely:
219
+
220
+ ```rust
221
+ /// Resolves a construction of a generic enum's variant (e.g. `Some(5)` for
222
+ /// `enum Option = | Some(a) | None`), rewriting `call.name` from the bare
223
+ /// variant name (`Some`) to its mangled form (`Some$Int`) once the enum's own
224
+ /// concrete instantiation is known. Mangling is eager and deterministic — it
225
+ /// doesn't wait for the worklist to actually produce the specialized `ast::Enum`
226
+ /// (see `enum_variant_mangling`'s doc comment).
227
+ ///
228
+ /// A variant that carries no generic fields (e.g. `None`) can't pin down the
229
+ /// enum's type parameters on its own, so such a construction site is left alone
230
+ /// here — some other construction site (e.g. `Some(5)`) is what drives the
231
+ /// specialization. (A bare `None` used as a *value*, not a call, is
232
+ /// `ast::Expr::TypeName` and doesn't go through this function at all — see the
233
+ /// plan's Global Constraints for that narrower, documented residual limitation.)
234
+ fn resolve_enum_instantiation(&mut self, call: &mut ast::FnCall, env: &TypeEnv) -> Result<(), String> {
235
+ let Some(e) = self.enums_generic_by_variant.get(call.name.as_str()).copied() else { return Ok(()) };
236
+ let params = enum_generic_params(e);
237
+ let Some(variant) = e.variants.iter().find(|v| v.name == call.name) else { return Ok(()) };
238
+ let mut bindings: BTreeMap<String, PlumType> = BTreeMap::new();
239
+ for (field_ty_name, arg) in variant.fields.iter().zip(call.args.iter()) {
240
+ if params.contains(field_ty_name) {
241
+ let arg_expr = match arg {
242
+ ast::Arg::Positional(e) => e,
243
+ ast::Arg::Keyword { value, .. } => value,
244
+ ast::Arg::Pair { value, .. } => value,
245
+ };
246
+ bindings.entry(field_ty_name.clone()).or_insert_with(|| self.infer(arg_expr, env));
247
+ }
248
+ }
249
+ // This single construction site couldn't pin down every generic parameter
250
+ // (e.g. a payload-free `None`, or a variant that mentions only some of a
251
+ // multi-parameter enum's params). Leave it for another site to drive.
252
+ if bindings.len() != params.len() {
253
+ return Ok(());
254
+ }
255
+ let type_args: Vec<PlumType> = params.iter().map(|p| bindings[p].clone()).collect();
256
+ let mangled = mangle(&e.name, &type_args);
257
+
258
+ self.enum_variant_mangling.entry(mangled.clone()).or_insert_with(|| {
259
+ e.variants.iter().map(|v| (v.name.clone(), mangle(&v.name, &type_args))).collect()
260
+ });
261
+
262
+ if !self.specialized.contains(&mangled) && !self.enqueued.contains(&mangled) {
263
+ self.enqueued.insert(mangled.clone());
264
+ self.worklist.push(PendingSpecialization::Enum { base: e, subst: Substitution(bindings), mangled: mangled.clone() });
265
+ }
266
+ call.name = self.enum_variant_mangling[&mangled][&variant.name].clone();
267
+ Ok(())
268
+ }
269
+ ```
270
+
271
+ **3d. Add match-pattern rewriting to `rewrite_stmt`'s `Match` arm**:
272
+
273
+ Replace:
274
+
275
+ ```rust
276
+ ast::Stmt::Match(m) => {
277
+ for s in &mut m.subjects {
278
+ self.rewrite_expr(s, env)?;
279
+ }
280
+ let subject_ty = m.subjects.first().map(|s| self.infer(s, env)).unwrap_or(PlumType::TInt);
281
+ for case in &mut m.cases {
282
+ let mut case_env = env.clone();
283
+ if let Some(ast::CasePattern::Name(n)) = case.patterns.first() {
284
+ let is_variant = n.chars().next().map(|c| c.is_uppercase()).unwrap_or(false)
285
+ && self.enum_variants.contains_key(n);
286
+ if !is_variant {
287
+ case_env.insert(n.clone(), TypeScheme::mono(subject_ty.clone()));
288
+ }
289
+ }
290
+ self.rewrite_block(&mut case.body, &mut case_env)?;
291
+ }
292
+ }
293
+ ```
294
+
295
+ with:
296
+
297
+ ```rust
298
+ ast::Stmt::Match(m) => {
299
+ for s in &mut m.subjects {
300
+ self.rewrite_expr(s, env)?;
301
+ }
302
+ let subject_ty = m.subjects.first().map(|s| self.infer(s, env)).unwrap_or(PlumType::TInt);
303
+ // If the subject's concrete type is a specialized generic enum, its
304
+ // variant-name mangling table lets us rewrite this match's patterns
305
+ // (`Some`/`None` -> `Some$Int`/`None$Int`) to reference the correct
306
+ // specialization, so the checker/codegen's unmodified, bare-name-keyed
307
+ // `EnumVariants` lookup still resolves each pattern correctly.
308
+ let variant_mangling: Option<BTreeMap<String, String>> = match &subject_ty {
309
+ PlumType::TNamed(n) => self.enum_variant_mangling.get(n).cloned(),
310
+ _ => None,
311
+ };
312
+ for case in &mut m.cases {
313
+ let mut case_env = env.clone();
314
+ if let Some(pat) = case.patterns.first_mut() {
315
+ match pat {
316
+ ast::CasePattern::Name(n) => {
317
+ let is_variant = n.chars().next().map(|c| c.is_uppercase()).unwrap_or(false)
318
+ && self.enum_variants.contains_key(n.as_str());
319
+ if is_variant {
320
+ if let Some(table) = &variant_mangling {
321
+ if let Some(mangled_variant) = table.get(n) {
322
+ *n = mangled_variant.clone();
323
+ }
324
+ }
325
+ } else {
326
+ case_env.insert(n.clone(), TypeScheme::mono(subject_ty.clone()));
327
+ }
328
+ }
329
+ ast::CasePattern::Class { name, .. } => {
330
+ if let Some(table) = &variant_mangling {
331
+ if let Some(mangled_variant) = table.get(name) {
332
+ *name = mangled_variant.clone();
333
+ }
334
+ }
335
+ }
336
+ _ => {}
337
+ }
338
+ }
339
+ self.rewrite_block(&mut case.body, &mut case_env)?;
340
+ }
341
+ }
342
+ ```
343
+
344
+ **3e. Update the `rewrite_expr`'s `FnCall` arm's comment** (the code itself is unchanged — `resolve_enum_instantiation` is already called before `resolve_fn_instantiation` — only the stale comment claiming enum resolution "never rewrites `call.name`" needs correcting):
345
+
346
+ Replace:
347
+
348
+ ```rust
349
+ // A `FnCall` may name either a generic free function or a generic
350
+ // enum's variant; the two name spaces don't overlap (variants are
351
+ // capitalized), so checking both is safe. Enum resolution never
352
+ // rewrites `call.name`, so order doesn't matter.
353
+ self.resolve_enum_instantiation(call, env)?;
354
+ self.resolve_fn_instantiation(call, env)?;
355
+ ```
356
+
357
+ with:
358
+
359
+ ```rust
360
+ // A `FnCall` may name either a generic free function or a generic
361
+ // enum's variant; the two name spaces don't overlap (variants are
362
+ // capitalized). Enum resolution runs first and rewrites `call.name`
363
+ // to its mangled form when it resolves — `fns_generic` is keyed by
364
+ // the ORIGINAL unmangled free-function names, so a rewritten variant
365
+ // name can never accidentally match it afterward.
366
+ self.resolve_enum_instantiation(call, env)?;
367
+ self.resolve_fn_instantiation(call, env)?;
368
+ ```
369
+
370
+ **3f. Update `monomorphize_source`'s `Monomorphizer` struct literal**:
371
+
372
+ Replace:
373
+
374
+ ```rust
375
+ enum_variant_owner: BTreeMap::new(),
376
+ ```
377
+
378
+ with:
379
+
380
+ ```rust
381
+ enum_variant_mangling: BTreeMap::new(),
382
+ ```
383
+
384
+ **3g. Simplify the worklist's `PendingSpecialization::Enum` arm** — remove the collision-detection loop entirely:
385
+
386
+ Replace:
387
+
388
+ ```rust
389
+ PendingSpecialization::Enum { base, subst, mangled } => {
390
+ if !m.specialized.insert(mangled.clone()) { continue; }
391
+ let spec_enum = specialize_enum(base, &subst, &mangled);
392
+ // Claim each bare variant name for this mangled enum. If a DIFFERENT
393
+ // mangled enum already owns it, this generic enum is being
394
+ // instantiated at more than one concrete type in the same program —
395
+ // which the flat, bare-variant-name-keyed `EnumVariants` runtime
396
+ // table can't represent (both would register under `"Some"`). Rather
397
+ // than silently let the second specialization corrupt the first, we
398
+ // fail with a clear, specific error. (Re-claiming by the SAME mangled
399
+ // enum can't reach here — worklist dedup + the `specialized` guard
400
+ // above ensure each mangled enum is produced exactly once.)
401
+ for v in &spec_enum.variants {
402
+ if let Some(owner) = m.enum_variant_owner.get(&v.name) {
403
+ if owner != &mangled {
404
+ return Err(format!(
405
+ "monomorphize: generic enum '{}' is instantiated at more than one concrete type in the same program ('{}' and '{}'), which is not yet supported. Only a single concrete instantiation per generic enum is allowed (variant '{}' would collide in the global variant table). This is a known, documented limitation, not a bug.",
406
+ base.name, owner, mangled, v.name
407
+ ));
408
+ }
409
+ }
410
+ m.enum_variant_owner.insert(v.name.clone(), mangled.clone());
411
+ }
412
+ m.produced.push(ast::Item::Enum(spec_enum));
413
+ }
414
+ ```
415
+
416
+ with:
417
+
418
+ ```rust
419
+ PendingSpecialization::Enum { base, subst, mangled } => {
420
+ if !m.specialized.insert(mangled.clone()) { continue; }
421
+ let spec_enum = specialize_enum(base, &subst, &mangled);
422
+ m.produced.push(ast::Item::Enum(spec_enum));
423
+ }
424
+ ```
425
+
426
+ (`base` is already available directly as the arm's own field — no re-lookup needed. This is the same dedup-and-produce shape as `PendingSpecialization::Class`/`Fn`, just without the class/fn-specific signature-registration bookkeeping those arms also do, since nothing looks up an enum's "signature" the way a call site looks up a function's.)
427
+
428
+ - [ ] **Step 4: Run the checker and codegen tests**
429
+
430
+ Run: `cargo test -p plum-checker --test checker_tests generic_enum`
431
+ Expected: both pass.
432
+
433
+ Run: `cargo test -p plum-wasm-codegen --test codegen_tests generic_enum`
434
+ Expected: both pass, including the pre-existing `generic_enum_specialized_and_matched_runs_correctly` (single instantiation) — confirm it still passes unchanged, proving this change doesn't regress the already-working single-instantiation case.
435
+
436
+ - [ ] **Step 5: Update README**
437
+
438
+ In `README.md`, replace the sentence (currently around line 243):
439
+
440
+ ```markdown
441
+ One documented limitation: a generic *enum* may only be instantiated at one concrete type per program (instantiating the same generic enum at two different concrete types produces a clear `monomorphize:`-prefixed error, since the runtime's enum-variant table is keyed by bare variant name).
442
+ ```
443
+
444
+ with:
445
+
446
+ ```markdown
447
+ Generic enums support any number of concrete instantiations coexisting in one program (variant names are mangled per instantiation, e.g. `Some` -> `Some$Int`/`Some$Str`, internally — invisible to user code). One narrower residual limitation: a payload-free variant (e.g. `None`) used as a bare value *outside* of a `match` pattern can't be disambiguated between multiple concrete instantiations of its enum from that expression alone; constructing via a payload-carrying sibling (`Some(5)`) and matching (`Some(v) => ...`, `None => ...`) is fully supported and is the overwhelmingly common usage pattern.
448
+ ```
449
+
450
+ - [ ] **Step 6: Run the full workspace and tree-sitter suites**
451
+
452
+ ```bash
453
+ cargo test --workspace
454
+ cd tooling/tree-sitter-plum && npx --yes tree-sitter-cli test
455
+ ```
456
+
457
+ Expected: fully green, zero known failures (aside from the pre-existing, intentionally-`#[ignore]`d slow recursion-guard test, which is unaffected by this change and can be skipped by default).
458
+
459
+ - [ ] **Step 7: Commit**
460
+
461
+ ```bash
462
+ git add plum-checker/src/monomorphize.rs plum-checker/tests/checker_tests.rs plum-wasm-codegen/tests/codegen_tests.rs README.md
463
+ git commit -m "feat(plum-checker): support multiple concrete instantiations of the same generic enum"
464
+ ```