Skip to content

Commit 1dd2f03

Browse files
committed
Merge remote-tracking branch 'origin/main' into sp-e-cli-parity
2 parents 7a85313 + a7e7625 commit 1dd2f03

34 files changed

Lines changed: 1848 additions & 162 deletions

docs/superpowers/plans/2026-05-31-typed-enums-payload.md

Lines changed: 151 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# Typed Enums in Payload VOs — Cross-Port Design
2+
3+
_Date: 2026-05-31. Status: approved (design). All 5 ports. Builds on the shipped extract tier + the FR-010/011 enum coercion engine._
4+
5+
## Problem
6+
7+
A `field.enum` (closed value set via `@values`) is typed **inconsistently and loosely** in each port's strict payload VO (the `extract<Name>` target): TS `unknown`, Python `str`, C# `object`, Java/Kotlin `String`. None is constrained to the enum's values — even though TS/C#/Kotlin already generate value-constrained enum types for **entities**, the payload path ignores them. The extract tier's whole value is a strongly-typed payload; an enum field typed `object`/`unknown`/`string` undermines that.
8+
9+
## Goal
10+
11+
Every port's strict payload types a `field.enum` with a **value-constrained, idiomatic** type; enum **arrays** likewise. The lenient/recovered mirror stays raw `string`. No engine/behavior change — typed enums are a codegen-output change only.
12+
13+
## Foundation (already in place)
14+
15+
- **Enum metamodel:** value set is inline per field via `@values` (a required, non-empty string array; members constrained to `[A-Za-z_][A-Za-z0-9_]*`, so **symbol == stored string** in every language). No named enum metatype. **Sharing** via abstract-field `extends`: an abstract `field.enum` declares `@values`; concrete fields `extends` it and inherit the effective values.
16+
- **Existing entity enum-type emitters (reuse):** TS `renderEnumTypeAliases` (union aliases, `inferred-types.ts`), C# `CollectEnumDecls` (nested `enum`, `EntityGenerator.cs`), Kotlin `emitEnumFile` (`enum class`, `KotlinEntityGenerator.kt`). Java + Python have none.
17+
- **Shared naming rule (reuse):** `<Super>` when the field extends an abstract enum (one shared type), else `<Owner><Field>` (PascalCase). Members verbatim.
18+
- **Engine enum coercion (unchanged):** normalize → `@enumAlias``@coerceDefault` → MALFORMED, validating against the closed set. By the time `extract` builds the strict payload, an enum value is always a valid member or the field is lost/MALFORMED.
19+
20+
## Design
21+
22+
### Per-port typed enum
23+
24+
| Port | Strict-payload enum type | Where the type is emitted | Emitter | Extract coercion |
25+
|---|---|---|---|---|
26+
| **TS** | `export type X = "A" \| "B"` (string-literal union) | the payload module | reuse `renderEnumTypeAliases` | **identity** — a validated member string already satisfies the union |
27+
| **Python** | `Literal["A","B"]` (a named alias `X = Literal[...]` when shared via `extends`, else inline) | the payload module | new (small — annotation + optional alias) | **identity** — string satisfies `Literal` |
28+
| **C#** | nested `public enum X { A, B }` | inside the payload `record` | reuse the `CollectEnumDecls` pattern | `System.Enum.Parse<X>(s)` |
29+
| **Kotlin** | `enum class X { A, B }` (separate `.kt` file) | alongside the payload | reuse `emitEnumFile` | `X.valueOf(s)` |
30+
| **Java** | `public enum X { A, B }` (**new** emitter; separate file or nested, matching the payload-gen file model) | alongside the payload | new (mirror C#/Kotlin shape + naming) | `X.valueOf(s)` |
31+
32+
Naming is the established rule in all five (`<Super>` / `<Owner><Field>`), so two fields extending one abstract enum yield **one** generated type per output unit (deduped as each entity emitter already dedupes).
33+
34+
### The lenient mirror stays string
35+
36+
The all-nullable `<Name>Extracted` mirror keeps the enum leaf as raw `string` (`string | null`, `str | None`, etc.) — it represents what recover salvaged (canonical member or, when classified, absent). Only the strict `extract` payload carries the typed enum. This is the clean split: **lenient = raw text + report; extract = validated typed value.**
37+
38+
### Extract mapper coercion
39+
40+
The extract mapper (mirror → strict) gains an enum branch:
41+
- **TS / Python:** identity — the mirror string is assigned directly to the union/`Literal` field (it is, by construction, a valid member). No runtime call.
42+
- **C# / Java / Kotlin:** `X.valueOf(s)` / `Enum.Parse<X>(s)` on the validated member string. Safe because the generated enum constants == `@values` and the engine produced a member.
43+
- **Enum arrays:** the scalar-array branch (already hardened to drop nulls + convert per element) converts each element via the same identity/`valueOf` rule, producing `X[]` / `List<X>` / `list[Literal]`.
44+
45+
### Coercion safety / edge cases
46+
47+
- Required enum, present → valid member → `valueOf`/identity safe.
48+
- Required enum, MALFORMED/lost → `extract` throws (existing lost-required path); never reaches `valueOf`.
49+
- Optional enum, absent → null where representable (TS/Python). C#/Kotlin/Java payloads type every field non-null (the existing documented all-`required` divergence), so optional-absent enums aren't representable there — unchanged by this work.
50+
- `valueOf`/`Parse` is defensively safe: `@values` are valid identifiers (metamodel-enforced) and equal the generated constants.
51+
52+
## Testing
53+
54+
- **Engine + conformance unchanged:** the `extract-conformance` corpus is engine-level (string verdicts); it does not change, and the 4 runners stay green. Enum coercion behavior is already covered there.
55+
- **Per-port compile-and-run proof (the gate):** extend each extractor test fixture with (a) a single enum field and (b) an enum array; regenerate; assert the strict payload field is the **typed enum** (TS union value / Python `Literal` member / C#-Java-Kotlin real enum constant e.g. `Status.PUBLISHED`), populated correctly from dirty input, AND that the lenient mirror still returns the raw string. Because the generated code COMPILES, the type is genuinely value-constrained; because it RUNS, the coercion works.
56+
- **Shared-enum naming proof:** one fixture where two payload fields `extends` one abstract enum → exactly one generated type, name `<Super>`, used by both fields — proving dedup + the shared-naming rule per port.
57+
58+
## Out of scope
59+
60+
- **Entity codegen** enum typing (already done in TS/C#/Kotlin; not the extract-target gap). Java/Python entity enum typing is a possible later parity item, not this work.
61+
- **Engine/coercion behavior** — unchanged (pure codegen-typing change).
62+
- The lenient mirror staying string is intentional, not a gap.
63+
- **Publish** — deferred to explicit user confirm.
64+
65+
## Sequencing
66+
67+
Single branch `worktree-typed-enums`, single merge. Per port: payload generator emits the enum type + types the field → extract mapper coerces → enum array → compile-and-run proof. Order likely TS/Python first (identity, simplest), then C#/Kotlin (reuse emitters + valueOf), then Java (new emitter). A final shared-naming fixture + closeout.

server/csharp/MetaObjects.Codegen.Tests/ExtractorCodegenTests.cs

Lines changed: 94 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,8 @@ public sealed class ExtractorCodegenTests
4040
// • quantities: REQUIRED scalar-array long[] (StrictArg long .Value unwrap)
4141
// • weights : REQUIRED scalar-array double[] (StrictArg double .Value unwrap)
4242
// • flagsArr: REQUIRED scalar-array bool[] (StrictArg bool .Value unwrap)
43-
// • priority: REQUIRED enum (strict-map `m.priority!` run-proof)
43+
// • priority: REQUIRED enum -> nested enum OrderPriority (Enum.Parse strict-map run-proof)
44+
// • labels : REQUIRED enum ARRAY -> IReadOnlyList<OrderLabels> (per-element Enum.Parse)
4445
// • note : scalar (not @required)
4546
// • shipTo : single nested Customer (not @required)
4647
private const string Model = """
@@ -62,6 +63,7 @@ public sealed class ExtractorCodegenTests
6263
{ "field.double": { "name": "weights", "isArray": true, "@required": true } },
6364
{ "field.boolean":{ "name": "flagsArr", "isArray": true, "@required": true } },
6465
{ "field.enum": { "name": "priority", "@required": true, "@values": ["LOW", "HIGH"] } },
66+
{ "field.enum": { "name": "labels", "isArray": true, "@required": true, "@values": ["A", "B"] } },
6567
{ "field.string": { "name": "note" } },
6668
{ "field.object": { "name": "shipTo", "@objectRef": "Customer" } }
6769
]}},
@@ -146,6 +148,7 @@ public void Generated_extract_populates_strict_graph_from_dirty_text()
146148
" \"weights\": [1.5, 2.25]," +
147149
" \"flagsArr\": [true, false, true]," +
148150
" \"priority\": \"HIGH\"," +
151+
" \"labels\": [\"A\", \"B\"]," +
149152
" \"shipTo\": { \"name\": \"Grace\" }, }\n```";
150153

151154
var order = extract.Invoke(null, new object?[] { orderMo, dirty })!;
@@ -197,13 +200,32 @@ public void Generated_extract_populates_strict_graph_from_dirty_text()
197200
Assert.Equal(new object[] { true, false, true }, flags.ToArray());
198201
Assert.IsType<bool>(flags[0]);
199202

200-
// required ENUM through the strict tier — the StrictArg enum branch (`m.priority!`,
201-
// string-backed) populates the strict record from dirty input. NOTE the strict property
202-
// type is `object` (not `string`): PayloadCodegen's scalar map has no enum entry, so an enum
203-
// field falls through to the `object` default — but the value is the string-backed member.
203+
// required ENUM through the strict tier — the StrictArg enum branch
204+
// (`System.Enum.Parse<OrderPriority>(m.priority!)`) coerces the string-backed mirror member
205+
// into the generated NESTED enum type. The strict property type is the nested enum
206+
// (Order.OrderPriority), NOT `object` and NOT `string`.
204207
var priorityProp = order.GetType().GetProperty("priority")!;
205-
Assert.Equal(typeof(object), priorityProp.PropertyType);
206-
Assert.Equal("HIGH", priorityProp.GetValue(order));
208+
Assert.True(priorityProp.PropertyType.IsEnum, "priority should be a nested enum type, not object/string");
209+
Assert.Equal("OrderPriority", priorityProp.PropertyType.Name);
210+
var priorityVal = priorityProp.GetValue(order)!;
211+
Assert.Equal("HIGH", priorityVal.ToString());
212+
Assert.Equal(System.Enum.Parse(priorityProp.PropertyType, "HIGH"), priorityVal);
213+
214+
// required ENUM ARRAY -> IReadOnlyList<OrderLabels> (element type IsEnum), per-element
215+
// Enum.Parse from the string-backed mirror list. Proves the enum-array routes through the
216+
// string-LIST reader (not the scalar enum reader) and each element coerces to the nested enum.
217+
var labelsProp = order.GetType().GetProperty("labels")!;
218+
Assert.True(typeof(IEnumerable).IsAssignableFrom(labelsProp.PropertyType));
219+
var labelsElemType = labelsProp.PropertyType.GetGenericArguments().Single();
220+
Assert.True(labelsElemType.IsEnum, "labels element should be a nested enum type");
221+
Assert.Equal("OrderLabels", labelsElemType.Name);
222+
Assert.Equal(new[] { "A", "B" }, labelsElemType.GetEnumNames());
223+
var labels = ((IEnumerable)labelsProp.GetValue(order)!).Cast<object>().ToList();
224+
Assert.Equal(new object[]
225+
{
226+
System.Enum.Parse(labelsElemType, "A"),
227+
System.Enum.Parse(labelsElemType, "B"),
228+
}, labels.ToArray());
207229

208230
// non-@required single nested, present -> populates.
209231
var shipTo = order.GetType().GetProperty("shipTo")!.GetValue(order)!;
@@ -240,6 +262,7 @@ public void Generated_extract_opts_overload_populates_same_strict_graph()
240262
" \"weights\": [3.5]," +
241263
" \"flagsArr\": [false]," +
242264
" \"priority\": \"HIGH\"," +
265+
" \"labels\": [\"B\"]," +
243266
" \"shipTo\": { \"name\": \"Grace\" } }\n```";
244267

245268
var opts = optsType.GetMethod("Defaults")!.Invoke(null, null);
@@ -253,7 +276,9 @@ public void Generated_extract_opts_overload_populates_same_strict_graph()
253276
var quantities = ((IEnumerable)order.GetType().GetProperty("quantities")!.GetValue(order)!)
254277
.Cast<object>().ToList();
255278
Assert.Equal(new object[] { 42L }, quantities.ToArray());
256-
Assert.Equal("HIGH", order.GetType().GetProperty("priority")!.GetValue(order));
279+
var priorityProp = order.GetType().GetProperty("priority")!;
280+
Assert.True(priorityProp.PropertyType.IsEnum);
281+
Assert.Equal("HIGH", priorityProp.GetValue(order)!.ToString());
257282
}
258283

259284
[Fact]
@@ -285,13 +310,73 @@ public void Re_exposed_extract_never_throws_on_clean_input()
285310
const string clean =
286311
"{ \"orderId\": \"A-7\", \"customer\": { \"name\": \"Ada\" }," +
287312
" \"lines\": [ { \"sku\": \"A\", \"qty\": 1 } ], \"tags\": [\"a\"], \"scores\": [1]," +
288-
" \"quantities\": [1], \"weights\": [1.0], \"flagsArr\": [true], \"priority\": \"LOW\" }";
313+
" \"quantities\": [1], \"weights\": [1.0], \"flagsArr\": [true], \"priority\": \"LOW\"," +
314+
" \"labels\": [\"A\"] }";
289315

290316
var result = extractLenient.Invoke(null, new object?[] { orderMo, clean })!;
291317
var report = result.GetType().GetProperty("Report")!.GetValue(result)!;
292318
Assert.False((bool)report.GetType().GetMethod("HasLostRequired")!.Invoke(report, null)!);
293319
}
294320

321+
// ---- nested-enum-typed payload: strict typing + lenient mirror stays string ----
322+
323+
[Fact]
324+
public void Strict_payload_types_enum_as_nested_enum_lenient_mirror_stays_string()
325+
{
326+
var root = Load(Model);
327+
328+
// STRICT payload: enum scalar -> nested enum type; enum array -> IReadOnlyList<NestedEnum>.
329+
var payloadSrc = PayloadCodegen.GeneratePayloadRecords(root, "Order");
330+
Assert.Contains("public enum OrderPriority { LOW, HIGH }", payloadSrc);
331+
Assert.Contains("public enum OrderLabels { A, B }", payloadSrc);
332+
Assert.Contains("public required OrderPriority priority { get; init; }", payloadSrc);
333+
Assert.Contains("public required IReadOnlyList<OrderLabels> labels { get; init; }", payloadSrc);
334+
Assert.DoesNotContain("public required object priority", payloadSrc);
335+
336+
// The extract mapper coerces the string mirror via System.Enum.Parse<NestedEnum>.
337+
var extractorSrc = Assert.Single(new ExtractorGenerator().Generate(Ctx(root))).Content;
338+
Assert.Contains("System.Enum.Parse<Order.OrderPriority>(m.priority!)", extractorSrc);
339+
Assert.Contains("System.Enum.Parse<Order.OrderLabels>(", extractorSrc);
340+
341+
// LENIENT mirror: the enum leaf stays string-backed (scalar string?, array string list).
342+
var ctx = Ctx(root);
343+
var parserSrc = Assert.Single(new OutputParserGenerator().Generate(ctx)).Content;
344+
Assert.Contains("string? priority { get; init; }", parserSrc);
345+
Assert.Contains("IReadOnlyList<string?>? labels { get; init; }", parserSrc);
346+
// No nested enum type bleeds into the lenient mirror.
347+
Assert.DoesNotContain("OrderPriority priority", parserSrc);
348+
Assert.DoesNotContain("OrderLabels", parserSrc);
349+
}
350+
351+
// ---- shared-enum dedup proof: one abstract enum, two extending fields, ONE decl ----
352+
353+
private const string SharedEnumModel = """
354+
{ "metadata.root": { "package": "acme::orders", "children": [
355+
{ "field.enum": { "name": "Priority", "abstract": true, "@values": ["LOW", "HIGH"] } },
356+
{ "object.value": { "name": "Ticket", "children": [
357+
{ "field.string": { "name": "ticketId", "@required": true } },
358+
{ "field.enum": { "name": "currentPriority", "@required": true, "extends": "Priority" } },
359+
{ "field.enum": { "name": "previousPriority", "@required": true, "extends": "Priority" } }
360+
]}}
361+
]}}
362+
""";
363+
364+
[Fact]
365+
public void Shared_abstract_enum_emits_one_nested_enum_both_fields_typed_by_super()
366+
{
367+
var root = Load(SharedEnumModel);
368+
var payloadSrc = PayloadCodegen.GeneratePayloadRecords(root, "Ticket");
369+
370+
// Exactly ONE nested enum declaration, named for the super (deduped), members verbatim.
371+
int enumDeclCount = System.Text.RegularExpressions.Regex.Matches(payloadSrc, @"public enum Priority \{").Count;
372+
Assert.True(enumDeclCount == 1, $"expected exactly one nested enum Priority, got {enumDeclCount}");
373+
Assert.Contains("public enum Priority { LOW, HIGH }", payloadSrc);
374+
375+
// BOTH fields typed by the shared enum.
376+
Assert.Contains("public required Priority currentPriority { get; init; }", payloadSrc);
377+
Assert.Contains("public required Priority previousPriority { get; init; }", payloadSrc);
378+
}
379+
295380
private static Assembly Compile(MetaRoot root)
296381
{
297382
var ctx = Ctx(root);

server/csharp/MetaObjects.Codegen/Generators/ExtractDelegateEmitter.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -281,12 +281,15 @@ private static string MapperArg(MetaData field, MetaData root)
281281
// Enum / scalar / scalar-array: the runtime already coerced; read + light-coerce via Dlg*.
282282
// A scalar ARRAY maps each element through the SAME per-kind Dlg* reader the single scalar
283283
// uses, so the produced element type matches the kind-typed mirror list (int?/long?/...).
284-
if (field.SubType == FIELD_SUBTYPE_ENUM) return $"DlgString(ReadProp(o, {key}))";
284+
// IsArray is checked BEFORE the single-scalar enum return so an ENUM array routes through the
285+
// string-LIST reader (DlgList(..., DlgString)), matching its IReadOnlyList<string?>? mirror —
286+
// NOT the single DlgString reader (the enum-before-isArray ordering fix).
285287
if (Fr010FieldMapping.IsArray(field))
286288
{
287289
string elemReader = field.SubType == FIELD_SUBTYPE_ENUM ? "DlgString" : ScalarReader(field.SubType);
288290
return $"DlgList(ReadProp(o, {key}), {elemReader})";
289291
}
292+
if (field.SubType == FIELD_SUBTYPE_ENUM) return $"DlgString(ReadProp(o, {key}))";
290293
return $"{ScalarReader(field.SubType)}(ReadProp(o, {key}))";
291294
}
292295

0 commit comments

Comments
 (0)