plum

#treesitter#compiler#wasm

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

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


c56df83 — Peter John 2026-11-01T17:31:34+05:30
docs(plans): struct/record + union enums review plan, Phase A machine plan
docs/plans/2026-11-01-struct-record.md ADDED
@@ -0,0 +1,150 @@
1
+ # struct and record declarations (Phase A)
2
+
3
+ **Review plan:** docs/plans/2026-11-01-struct-value-types.review.md
4
+ **Goal:** `struct Name(f: T, ...)` (value type, replaces `value enum`) and `record Name[G](f: T, ...)` (reference type, replaces the self-named single-variant enum), each with an optional `=` + indented body of consts/methods.
5
+ **Architecture:** parser-only lowering. Both forms build today's `ast::Enum` with ONE variant named like the type (`isValue: true` for struct, new `isRecord: true` for record). Checker type rules and codegen are unchanged.
6
+ **Tech stack:** Rust workspace, tree-sitter grammar (`plum-tooling/tree-sitter-plum`), Topiary `format.scm`.
7
+ **Spec:** docs/plans/2026-11-01-struct-value-types.review.md (Phase A, steps 1-5)
8
+ **Detail:** contracts
9
+ **Target-min:** 14
10
+
11
+ ## Global constraints
12
+ - Exact syntax: `struct Vec2(x: Float, y: Float)` with no body has NO `=`. With a body: `struct Vec2(x: Float, y: Float) =` NEWLINE INDENT (fn | const)* DEDENT. Same for `record`, which also allows generics: `record Pair[K, V](key: K, val: V) =`. Fields are `enum_named_field` (named, `fn(...)` types allowed), at least one.
13
+ - `struct` takes no generics (grammar rejects `struct P[T](a: T)`).
14
+ - `value enum` is REMOVED: no `valueKw`, no `value` keyword anywhere (grammar, highlights, format.scm, tmLanguage, LSP keyword list).
15
+ - The self-named single-variant enum form (`enum User = | User(...)`) still parses in Phase A (its removal is Phase B); new code and migrated code use `record`.
16
+ - Checker struct messages, verbatim: `struct field '<name>' must be Int, Float, Bool or a struct` and `struct <Name> cannot contain itself`. Delete the "exactly one constructor", "constructor must be named" and "cannot be generic" checks.
17
+ - Regenerate tree-sitter ONLY with `tree-sitter generate --disable-optimizations` (default optimizer silently breaks unrelated parses).
18
+ - Never run `plum format` on real repo files (it rewrites in place); format tests use temp copies.
19
+ - Read node names via `child_by_field_name`, not positional `named_child(i)` (comments shift positions).
20
+ - `cargo build --quiet` can skip edited files: `touch` edited `.rs` files before trusting a build.
21
+ - Test commands: `cargo test -p <crate>`; `cd plum-tooling/tree-sitter-plum && tree-sitter test`; `./target/release/plum test plum-std plum-examples` after `cargo build --release -p plum-cli`.
22
+
23
+ ## Main diagram
24
+ ```mermaid
25
+ flowchart LR
26
+ G["grammar.js: struct / record rules"] --> PC["parser.c (regenerated)"]
27
+ PC --> P["parser.rs parseStruct/parseRecord"]
28
+ P --> A["ast::Enum isValue / isRecord"]
29
+ A --> C["checker checkValueEnums (renamed msgs)"]
30
+ C --> W["codegen (unchanged)"]
31
+ G --> Q["highlights.scm / format.scm / tmLanguage / LSP"]
32
+ ```
33
+
34
+ ## File structure
35
+ - `plum-tooling/tree-sitter-plum/grammar.js`, `src/*` (generated), `test/corpus/struct.txt`, `test/corpus/enum.txt`: syntax.
36
+ - `plum-core/src/ast.rs`, `plum-core/src/parser.rs`, `plum-core/tests/parser_test.rs`: lowering.
37
+ - `plum-checker/src/lib.rs`, `plum-checker/tests/checker_tests.rs`: struct validation.
38
+ - `plum-tooling/tree-sitter-plum/queries/plum/{highlights,format}.scm`, `plum-tooling/vscode-plum/syntaxes/plum.tmLanguage.json`, `plum-lsp/src/completion.rs`: tooling.
39
+ - Every `.plum` and Rust test snippet declaring a record or value enum: migration.
40
+ - `README.md`: docs.
41
+
42
+ ## Task graph
43
+ ```mermaid
44
+ flowchart LR
45
+ T1[core syntax + lowering]
46
+ T2[tooling queries]
47
+ T3[record migration]
48
+ T4[README]
49
+ %% taskgraph
50
+ T1 --> T2
51
+ T1 --> T3
52
+ ```
53
+
54
+ ```mermaid
55
+ %% schedule
56
+ gantt
57
+ title Predicted schedule (mm:ss)
58
+ dateFormat X
59
+ axisFormat %M:%S
60
+ section lane 1
61
+ T1 core syntax + lowering :t1, 0, 208s
62
+ T3 record migration :t3, 208, 189s
63
+ section lane 2
64
+ T4 README :t4, 0, 94s
65
+ T2 tooling queries :t2, 208, 162s
66
+ ```
67
+
68
+ critical path: T1 → T3 (6.6 min) · peak width 2 · predicted build 6.6 min · predicted total ≈ 10.1 min (target ✓)
69
+ Ruling: sequential - T2 and T3 run the regenerated parser in their tests (topiary format, the full plum suite), so they need T1's grammar committed - cost: about 5 min on the critical path.
70
+
71
+ ## Predicted schedule
72
+
73
+ ## Core
74
+
75
+ ### T1 core syntax + lowering · Complexity 5 · Est. context 55k
76
+ **Owns:** plum-tooling/tree-sitter-plum/grammar.js, plum-tooling/tree-sitter-plum/src/parser.c, plum-tooling/tree-sitter-plum/src/grammar.json, plum-tooling/tree-sitter-plum/src/node-types.json, plum-tooling/tree-sitter-plum/test/corpus/struct.txt, plum-tooling/tree-sitter-plum/test/corpus/enum.txt, plum-core/src/ast.rs, plum-core/src/parser.rs, plum-core/tests/parser_test.rs, plum-checker/src/lib.rs, plum-checker/tests/checker_tests.rs, plum-examples/values.plum, plum-examples/constants.plum, plum-examples/pattern_if.plum, plum-wasm-codegen/tests/codegen_tests.rs
77
+ **Components:** 3
78
+ **LOC owned:** 260
79
+ **Tests:** slow
80
+ **Model:** sonnet
81
+ **Est. time:** 5 min
82
+ **Produces:** tree-sitter nodes `struct` and `record` (fields: `name` type_identifier, `generics` (record only), `field` enum_named_field+, `methods` (fn | const)*) · `ast::Enum.isRecord: bool` · `AstParser::parseStruct(node) -> Enum` · `AstParser::parseRecord(node) -> Enum`
83
+ e.g. parse("struct Vec2(x: Float, y: Float)") -> Item::Enum(Enum{name:"Vec2", isValue:true, isRecord:false, generics:[], variants:[EnumVariant{name:"Vec2", field_names:["x","y"], fields:[Float, Float]}]})
84
+ e.g. parse("record Pair[K, V](key: K, val: V)") -> Item::Enum(Enum{name:"Pair", isValue:false, isRecord:true, generics:[K, V], variants:[EnumVariant{name:"Pair", field_names:["key","val"]}]})
85
+ **Review Focus:** `struct_no_body_then_fun`: `struct A(x: Int)\nfun f() -> Int = 1` parses as 2 items with no ERROR node; `struct_body_comment_before_fn`: a `# c` line between `=` and the first `fun` keeps the name `Vec2`; `value_enum_rejected`: `value enum V =\n | V(x: Int)` has a parse error; `struct_generic_rejected`: `struct P[T](a: T)` has a parse error; `record_fn_field`: `record Num[T](add: fn(T, T) -> T, zero: T)` gives field types [fn(T,T)->T, T]
86
+ **Test code (contracts):**
87
+ - corpus `struct.txt`: struct no body; struct with const + method body; record generic with body; record fn-typed field; struct nested struct field; body-less struct followed by `fun`; body-less record followed by `enum`. Remove the `value enum` case from `enum.txt`.
88
+ - parser_test.rs: `parseStructNoBody`, `parseStructWithBody` (1 const `ZERO`, 1 method `add` with `type_param == Some("Vec2")`), `parseRecordGeneric`, `parseRecordFnField`, plus the 4 Review Focus cases.
89
+ - checker_tests.rs: rewrite the 4 `valueErrors` tests. Drop the multi-constructor one (now a grammar error). `struct Named(label: Str, n: Int)` gives `struct field 'label' must be Int, Float, Bool or a struct`. `struct Ping(b: Pong)` + `struct Pong(a: Ping)` gives `struct Ping cannot contain itself`. `struct Vec2(..)` + `struct Rect(pos: Vec2, size: Vec2)` gives no errors.
90
+ - Migrate every `value enum` (values.plum x4, constants.plum, pattern_if.plum, codegen_tests.rs x3) to `struct`. Then `cargo test -p plum-core -p plum-checker -p plum-wasm-codegen` and `tree-sitter test` are green, and `plum test plum-examples/values.plum plum-examples/constants.plum plum-examples/pattern_if.plum` passes.
91
+ **Steps:** 1 grammar + regenerate + corpus, 2 failing parser tests, 3 parseStruct/parseRecord (dispatch the `struct`/`record` node kinds next to `"enum" =>` at parser.rs:85), 4 checker messages, 5 migrate the value enums, 6 run the listed suites.
92
+ **Notes:** the body reuses the enum body's `(fn | const)` handling so methods get `type_param` = the type name, exactly as enum methods do. Keep `isValue` semantics identical to today's `value enum`.
93
+
94
+ ## Tooling
95
+
96
+ ### T2 tooling queries · Complexity 2 · Est. context 30k
97
+ **Owns:** plum-tooling/tree-sitter-plum/queries/plum/highlights.scm, plum-tooling/tree-sitter-plum/queries/plum/format.scm, plum-tooling/vscode-plum/syntaxes/plum.tmLanguage.json, plum-lsp/src/completion.rs, plum-core/tests/formatter_test.rs
98
+ **Components:** 2
99
+ **LOC owned:** 90
100
+ **Tests:** slow
101
+ **Model:** sonnet
102
+ **Est. time:** 2.5 min
103
+ **Consumes:** tree-sitter nodes `struct` and `record` (T1)
104
+ **Review Focus:** `format_struct_idempotent`: formatting `struct Vec2(x: Float, y: Float) =\n ZERO = Vec2(x: 0.0, y: 0.0)\n\n fun len(self) -> Float =\n self.x\n` twice gives identical output that equals the input; `format_record_no_body`: `record Pair[K, V](key: K, val: V)` stays one line
105
+ **Test code (contracts):** formatter_test.rs: `formatStructWithBody`, `formatStructNoBody`, `formatRecordGeneric`, each asserting output == input and format(format(x)) == format(x). highlights: `struct`/`record` are @keyword, `value` is removed. tmLanguage keyword regex: replace `value` with `struct|record`. completion.rs keyword list: replace `"value"` with `"struct", "record"`. Repo-wide idempotency check on temp copies of `plum-std/*.plum` and `plum-examples/*.plum`, never in place.
106
+ **Steps:** 1 failing formatter tests, 2 format.scm rules (copy the enum rules: keyword space, `, ` between fields, space before `=`, body indent, blank lines kept), 3 the other three files, 4 run `cargo test -p plum-core --test formatter_test -p plum-lsp`.
107
+
108
+ ## Migration
109
+
110
+ ### T3 record migration · Complexity 3 · Est. context 60k
111
+ **Owns:** plum-std/*.plum, plum-examples/chains.plum, plum-examples/closures.plum, plum-examples/forms.plum, plum-examples/functions.plum, plum-examples/generics.plum, plum-examples/match.plum, plum-examples/methods.plum, plum-examples/oop_visitor.plum, plum-examples/operators.plum, plum-examples/optionals.plum, plum-examples/safe_navigation.plum, plum-examples/types.plum, plum-checker/tests/error_union_tests.rs, plum-cli/tests/assert_once_test.rs, plum-core/tests/loader_test.rs, plum-tooling/tree-sitter-plum/test/aa.plum, plum-tooling/tree-sitter-plum/test/highlight/sample.plum, plum-tooling/tree-sitter-plum/test/corpus/const.txt, plum-tooling/tree-sitter-plum/test/corpus/type.txt
112
+ **Components:** 2
113
+ **LOC owned:** 300
114
+ **Tests:** slow
115
+ **Model:** sonnet
116
+ **Est. time:** 5 min
117
+ **Consumes:** tree-sitter nodes `struct` and `record` (T1)
118
+ **Review Focus:** `migrated_methods_keep_receiver`: after migration, `plum test plum-std` passes with the same test count as before (record it first); `no_self_named_single_variant_left`: the scratchpad scan script finds 0 `enum X =\n | X(` declarations in owned files
119
+ **Test code (contracts):** The rewrite script lives in the session scratchpad and is NOT committed. It rewrites `enum X[G] =\n | X(fields)` to `record X[G](fields)`, adding ` =` only when an indented body follows. It handles the `\n`-escaped snippets inside Rust string literals and leaves a payload-less self-named variant alone (`enum Array[T] =\n | Array`), reporting it. It skips files T1 owns (values.plum, constants.plum, pattern_if.plum, checker_tests.rs, codegen_tests.rs, parser_test.rs, corpus enum.txt) and prints every skip and every site it didn't rewrite. Corpus expected trees in const.txt/type.txt are updated with `tree-sitter test --update` only after checking the diff is just enum->record. Green: `cargo test --workspace`, `tree-sitter test`, `plum test plum-std plum-examples` (release build).
120
+ **Steps:** 1 record the baseline test counts, 2 write and run the script, 3 hand-fix the reported sites, 4 run the full suites.
121
+ **Notes:** Ruling: records in T1-owned files (checker_tests.rs, codegen_tests.rs, parser_test.rs, values.plum Player, pattern_if.plum Soldier, constants.plum Tile/Wrap) stay as `enum` in Phase A - the old form still parses, and Phase B's removal error forces them over - cost: mixed style until Phase B.
122
+
123
+ ## Docs
124
+
125
+ ### T4 README · Complexity 1 · Est. context 25k
126
+ **Owns:** README.md
127
+ **Components:** 1
128
+ **LOC owned:** 70
129
+ **Tests:** fast
130
+ **Kind:** mechanical
131
+ **Model:** haiku
132
+ **Est. time:** 1.5 min
133
+ **Review Focus:** `readme_no_value_enum`: `grep -c "value enum" README.md` is 0
134
+ **Test code (contracts):**
135
+ - Rename the section "Types: enums, records and value enums" to "Types: struct, record and enum".
136
+ - In that section, show `struct Vec2(x: Float, y: Float) =` with `ZERO` and `add`, `record User(name: Str, age: Int)`, and a generic `record Pair[K, V](key: K, val: V)`.
137
+ - Add a 4-line copy-vs-share example: `a := Vec2(..); b := a; b.x = 9.0` leaves `a.x` unchanged, while `u2 := u; u2.age = 4` changes `u.age`.
138
+ - Struct fields are only Int/Float/Bool/other structs.
139
+ - Cheat-sheet rows: `a small value type | struct Vec2(x: Float, y: Float)` and `a record | record User(name: Str, age: Int)`.
140
+ - Update the overview snippet (README lines 32-38) and the pattern-if note at about line 343 (`on a struct`).
141
+ - Check: `grep -c "value enum" README.md` gives 0.
142
+ **Steps:** 1 edit, 2 grep check.
143
+
144
+ ## Review Focus
145
+ - Package core (parser/library): deep, encoding, state. Risks: an external-scanner indent opened after a body-less declaration; comment-shifted positional indexing; methods losing `type_param`. **Probe entry:** run=./target/release/plum test plum-examples/values.plum
146
+ - Package tooling (CLI/formatter): file, encoding. Risk: format.scm collapsing struct bodies or breaking idempotency. **Probe entry:** run=cargo test -p plum-core --test formatter_test
147
+ - Package migration (file I/O generator): file, deep. Risk: a dropped body or method during the rewrite. **Probe entry:** run=./target/release/plum test plum-std plum-examples
148
+
149
+ ## Self-review
150
+ Coverage: Phase A steps 1-5 map to T1 (steps 1-3), T2 (tooling in step 1), T3 (step 4) and T4 (step 5). No placeholders. The Consumes in T2/T3 match T1's Produces. The only edges are T1->T2 and T1->T3, both execution dependencies. Owns don't overlap.
docs/plans/2026-11-01-struct-value-types.review.md ADDED
@@ -0,0 +1,135 @@
1
+ # `struct`, `record`, and enums as unions of types (review plan)
2
+
3
+ **Status:** awaiting approval (revision 4: decision 3 replaced by subject narrowing; any type may be a member, so `Json` uses std types directly).
4
+
5
+ ## Goal
6
+ ```plum
7
+ struct Circle(r: Float) = # value type: copied, flattened
8
+ fun area(self) -> Float = self.r * self.r * Float.PI
9
+ struct Rectangle(w: Float, h: Float) =
10
+ fun area(self) -> Float = self.w * self.h
11
+ record User(name: Str, age: Int) # reference type: heap, shared
12
+ record Pair[K, V](key: K, val: V)
13
+
14
+ enum Shape = Circle | Rectangle # a Shape is a Circle or a Rectangle
15
+ enum Color = Red | Green | Blue # undeclared names are payload-less tags
16
+ enum Json = JsonNull | Bool | Float | Str | List[Json] | Map[Str, Json]
17
+
18
+ s: Shape = Circle(r: 2.0) # a member converts to its enum implicitly
19
+ s.area() # auto-dispatch: every member has area()
20
+
21
+ match s
22
+ Circle(r) => r # destructure, as today
23
+ Rectangle => s.w * s.h # bare member name: `s` is a Rectangle here
24
+
25
+ fun toStr(j: Json) -> Str =
26
+ match j
27
+ JsonNull => "null"
28
+ Bool => if j then "true" else "false" # j: Bool
29
+ Float => j.toStr() # j: Float
30
+ Str => "\"" + escapeJsonStr(j) + "\"" # j: Str
31
+ List => "[" + j.join(",") + "]" # j: List[Json]
32
+ Map => "{{" + jsonMapToStr(j) + "}}" # j: Map[Str, Json]
33
+ ```
34
+
35
+ ## Rulings (user, 2026-11-01)
36
+ - `=` follows the header only when an indented body follows. `struct Color(r: Int)` with no body is one line.
37
+ - Members are types or bare tags. Inline payload variants (`| Circle(r: Float)`) are removed. Discriminant enums keep their literal values (`enum Step(n: Int) = | ReadMin(10)`, unchanged).
38
+ - **Auto-dispatch:** `s.area()` works when the enum doesn't define `area` itself and every member defines it with the same signature. Otherwise the error names the first member that lacks it or differs. This also covers operators and `toStr`.
39
+ - `struct`: no generics; fields only `Int`/`Float`/`Bool`/other structs; no cycles. `record`: generics and any field types, including `fn(...)`.
40
+ - `value enum` and `enum User = | User(...)` are removed with no aliases. The error names the new form.
41
+
42
+ ## Changed since revision 3 (please confirm)
43
+ 1. **Decision 3 (`Circle as c`) is dropped and replaced by subject narrowing.**
44
+ - Why I dropped it: `as` would be a new keyword, and `if Circle as c := s` reads badly, with two binders pointing in opposite directions.
45
+ - What replaces it: Plum already uses a bare name as a test pattern (tags, and `if TooBig := e`). So a bare member name in a `match` arm or pattern `if` narrows the **subject variable** to that member type for the arm: `if Circle := s` makes `s` a `Circle` inside the block.
46
+ - It needs no new syntax and works on `self` too (`match self` / `Str => self.length()`).
47
+ - Limits:
48
+ - The subject must be a local, a param or `self`. For an expression subject (`match f()`), assign it first or destructure.
49
+ - For a **struct** member, the narrowed `s` is a copy, so `s.r = 3.0` inside the arm is an error: `s is a copy of the Circle inside Shape; assign it to a local to modify it`. Without that error, the write would silently not stick.
50
+ - For a **record** member, `s` is the same reference, so writes stick.
51
+ 2. **Any type can be a member, and a type may be in several enums.** This answers the Json question, and it reverses your earlier "one enum each" answer, so please confirm.
52
+ - You picked "one enum each" because I said sharing needed runtime type tests. It doesn't: I now lower every member as a **boxed payload** (tag plus value), which is exactly today's `JsonBool(Bool)` layout. `enum Json = Bool | Float | ...` compiles to the same machine shape as today's Json.
53
+ - Primitives (`Float`, `Bool`), std types (`Str`), generic instantiations (`List[Json]`, `Map[Str, Json]`) and recursive references all work, and Circle can be in both `Shape` and `Round`.
54
+ - Cost: a record member sits behind one extra pointer inside an enum. Folding a same-module record's tag into its own object is a possible later optimization.
55
+ - Two members with the same base name (`List[Int] | List[Str]`) is an error, because the pattern `List` would be ambiguous.
56
+
57
+ ## More design (my rulings)
58
+ - **Resolution.** A member name that names a type in scope (declared, imported or built in) is a type member. Any other name is a tag.
59
+ - Risk: a typo (`Strr`) silently becomes a tag. It still fails loudly at the first use (`j: Json = "x"` has no `Str` member), and the error lists the enum's tags.
60
+ - **Typing.**
61
+ - `Circle(r: 2.0)` has type `Circle`. It converts to `Shape` at assignment, argument, return, `List[Shape].push` and `Shape?` (the slot `T -> T?` uses).
62
+ - Literals convert too: `j: Json = 3.0`.
63
+ - `Int` and `Float` are distinct members, and a literal goes to the member of its own type.
64
+ - **Patterns.**
65
+ - A bare member name tests and narrows. `Circle(r)` / `Circle(r: r)` destructures a struct or record member. Tags match by name.
66
+ - Exhaustiveness covers the member list.
67
+ - A generic member is matched by its base name (`List`).
68
+ - **Generic enums:** `enum Tree[T] = Leaf | Node[T]`, with `record Node[T](v: T, l: Tree[T], r: Tree[T])`.
69
+ - **Lowering.** A lowering pass rewrites each enum into today's representation with hidden variants mangled per enum (`Shape.Circle(v: Circle)`). Mangling keeps the global variant map collision-free, which is what makes sharing possible. Codegen sees ordinary positional-payload variants and stays almost unchanged. New checker and lowering work:
70
+ - member-to-enum wrapping at coercion sites;
71
+ - narrowing, which rebinds the subject to the payload in the arm (unboxed into locals for a struct);
72
+ - auto-dispatch, which desugars to a `match` calling each member's method.
73
+
74
+ ```mermaid
75
+ flowchart LR
76
+ src["struct / record / enum A | B"] --> parser["parser"]
77
+ parser --> res["resolve members: type or tag"]
78
+ res --> low["lower: hidden variants Shape.Circle(v: Circle)"]
79
+ low --> chk["checker: wrap coercions, narrowing, auto-dispatch"]
80
+ chk --> cg["codegen: today's variant layout"]
81
+ ```
82
+
83
+ ## Work, in order
84
+ **Phase A: `struct` and `record`.** It ships on its own and doesn't change semantics.
85
+ 1. **Grammar and tooling.**
86
+ - Add the `struct` and `record` rules and regenerate **with `--disable-optimizations`**.
87
+ - Add corpus cases.
88
+ - Update `highlights.scm`, `format.scm` (idempotent; never run `plum format` on real files to check), the tmLanguage file and the LSP keywords.
89
+ 2. **Parser.** Lower to today's single-variant `ast::Enum` (`isValue` / new `isRecord`). Tests, plus errors for `value enum` and `struct P[T]`.
90
+ 3. **Checker.** Rename the struct messages and drop the 3 checks that can no longer fire.
91
+ 4. **Migration script.** About 134 self-named single-variant enums plus the `value enum`s, across std, examples, the Rust test snippets and the corpus.
92
+ 5. **README** "Types", then verify: `cargo test`, `tree-sitter test`, `plum test plum-std plum-examples` (release build).
93
+
94
+ **Phase B: enums as unions.** It depends on A.
95
+
96
+ 6. **Grammar.** `enum Name[G] = A | B[X] | C` on one line or as a `|` list, with an optional `=` body. Inline payload variants are removed.
97
+ 7. **Resolution and lowering pass.** Members and tags, generic members, duplicate-base-name errors, mangled hidden variants. Remove the single-self-variant form, with an error that says `write record X(...)`.
98
+ 8. **Checker.**
99
+ - Wrap coercions, including literals.
100
+ - Narrowing, with the struct-copy write error.
101
+ - Destructuring through a member, and exhaustiveness.
102
+ - Auto-dispatch and its errors.
103
+ 9. **Codegen.** Wrap and unwrap at the coercion and narrowing sites; struct members box their leaves. Auto-dispatch arrives already desugared.
104
+ 10. **Migration script 2.**
105
+ - Hoist each inline payload variant into a `record` after the enum, with positional payloads named `v`, then `v0, v1, …`. That's about 130 variants across about 32 `.plum` files plus the Rust snippets.
106
+ - A single-payload variant whose payload is already a type (`JsonFloat(Float)`) is offered as a direct member, for hand review.
107
+ - The script reports clashes.
108
+ 11. **Json rewrite.** `plum-std/Json.plum` becomes the `enum Json = JsonNull | Bool | Float | Str | List[Json] | Map[Str, Json]` above, and its 37 uses of the old variant names are rewritten with narrowing. `Json.parse` builds values through the implicit conversion.
109
+ 12. **Tests.** `plum-examples/unions.plum` plus checker and codegen tests:
110
+ - struct and record members;
111
+ - tags mixed with types;
112
+ - primitive and generic members;
113
+ - one type in two enums;
114
+ - generic `Tree[T]`;
115
+ - narrowing in `match` / `if` / `self`;
116
+ - the struct-copy write error;
117
+ - the `List[Int] | List[Str]` error;
118
+ - auto-dispatch success, plus its missing-method and mismatch errors;
119
+ - a `List[Shape]` holding both kinds;
120
+ - a Json parse and print round-trip.
121
+
122
+ README: rewrite Enums and Pattern if.
123
+ 13. **Verify** as in step 5, then a final review.
124
+
125
+ ## Risks to watch
126
+ - **Grammar:** `enum A = B | C[D]` on one line next to the `|` list form, and generic member brackets. Needs corpus cases.
127
+ - **Narrowing scope:** the subject is rebound to a different type inside the arm. Closures that capture `s` in the arm must capture the narrowed binding. This needs a test.
128
+ - **Recursive members** (`List[Json]` inside `Json`) go through the GC type registry. Today's `JsonList(List[Json])` already works this way, so keep the round-trip test early.
129
+ - **The 2026-09 "variant-as-type" code** (`v: Red` params, field-access downcast) may be superseded by member types. Check its uses before deleting it.
130
+ - **Branch:** build on its own branch off `master`, not `superlite/map-literal`.
131
+
132
+ ## Not in scope
133
+ - Flow typing outside patterns (`if s is Circle`).
134
+ - Generic structs.
135
+ - The same-module record tag-folding optimization.