From fc4caf01248564fa0de3cb68b7ecd4eb7889bc10 Mon Sep 17 00:00:00 2001
From: Doug Mealing
Date: Sat, 10 Oct 2026 22:48:28 -0400
Subject: [PATCH 1/7] feat: multi-hop shared-enum dedup, -first/-last list
accessors, value-object rules constant
- Shared-enum resolution walks the extends chain (TypeScript, C#, Java, Python), so an enum
field that extends a field which extends the shared abstract references the shared type
instead of inlining its own. C# EffectiveEnumValues reads through every hop. Kotlin keeps its
per-object projection enum (#259).
- Templates: every map-shaped collection element carries derived -first/-last booleans, so a
list separator is {{^-last}}, {{/-last}} with no payload flag. All five ports render it
identically; verify --templates accepts it inside a container section and flags it elsewhere.
New render-list-separator and verify-positional-accessor-* fixtures.
- TypeScript: a value object's module exports a field-rules constant, like an entity's
..rules, without the table and path keys.
---
CHANGELOG.md | 33 +++++++++++++++++
docs/CONFORMANCE.md | 20 +++++------
docs/features/templates-and-payloads.md | 22 ++++++++++++
.../.metaobjects/.gen-state/.hashes.json | 6 ++--
.../src/generated/InstructorProfile.ts | 35 +++++++++++++++++++
.../generated/ProgramDescriptionPayload.ts | 35 +++++++++++++++++++
.../src/generated/SyllabusSection.ts | 33 +++++++++++++++++
.../.metaobjects/.gen-state/.hashes.json | 2 +-
.../generated/ts/SubscriberBlurbPayload.ts | 15 ++++++++
.../shared-provided-enum/input/meta.json | 6 ++++
.../render-list-separator/expected.txt | 6 ++++
.../render-list-separator/meta.json | 1 +
.../render-list-separator/payload.json | 10 ++++++
.../render-list-separator/template.mustache | 6 ++++
.../expected-drift.json | 1 +
.../payload.json | 8 +++++
.../template.mustache | 1 +
.../expected-drift.json | 1 +
.../payload.json | 8 +++++
.../template.mustache | 1 +
.../expected-drift.json | 1 +
.../payload.json | 8 +++++
.../template.mustache | 1 +
.../Fr019SharedProvidedConformanceTests.cs | 10 ++++++
.../Generators/Fr019SharedEnum.cs | 22 ++++++++----
.../MetaObjects.Render/PayloadAccessors.cs | 23 ++++++++++++
server/csharp/MetaObjects.Render/Verify.cs | 22 ++++++++----
server/csharp/MetaObjects/Meta/MetaField.cs | 2 +-
.../generator/spring/Fr019SharedEnum.java | 20 +++++++----
.../Fr019SharedProvidedConformanceTest.java | 10 ++++++
.../metaobjects/render/PayloadAccessors.java | 34 ++++++++++++++++--
.../java/com/metaobjects/render/Verify.java | 24 ++++++++-----
.../snapshots/render-list-separator.txt | 6 ++++
.../codegen/generators/fr019_shared_enum.py | 25 +++++++------
.../metaobjects/render/payload_accessors.py | 28 ++++++++++++++-
.../python/src/metaobjects/render/verify.py | 28 ++++++++++-----
.../test_fr019_shared_provided_conformance.py | 10 ++++++
.../packages/codegen-ts/src/enum-shared.ts | 22 +++++++-----
.../packages/codegen-ts/src/index.ts | 2 +-
.../src/templates/entity-constants.ts | 25 +++++++++++++
.../src/templates/value-object-file.ts | 6 +++-
.../fr019-shared-provided-conformance.test.ts | 14 ++++++++
.../test/templates/value-object-file.test.ts | 18 +++++++++-
.../packages/render/src/payload-accessors.ts | 24 ++++++++++++-
.../typescript/packages/render/src/verify.ts | 31 ++++++++++++----
45 files changed, 580 insertions(+), 86 deletions(-)
create mode 100644 fixtures/render-conformance/render-list-separator/expected.txt
create mode 100644 fixtures/render-conformance/render-list-separator/meta.json
create mode 100644 fixtures/render-conformance/render-list-separator/payload.json
create mode 100644 fixtures/render-conformance/render-list-separator/template.mustache
create mode 100644 fixtures/verify-conformance/verify-positional-accessor-clean/expected-drift.json
create mode 100644 fixtures/verify-conformance/verify-positional-accessor-clean/payload.json
create mode 100644 fixtures/verify-conformance/verify-positional-accessor-clean/template.mustache
create mode 100644 fixtures/verify-conformance/verify-positional-accessor-outside-element/expected-drift.json
create mode 100644 fixtures/verify-conformance/verify-positional-accessor-outside-element/payload.json
create mode 100644 fixtures/verify-conformance/verify-positional-accessor-outside-element/template.mustache
create mode 100644 fixtures/verify-conformance/verify-positional-accessor-scalar-list/expected-drift.json
create mode 100644 fixtures/verify-conformance/verify-positional-accessor-scalar-list/payload.json
create mode 100644 fixtures/verify-conformance/verify-positional-accessor-scalar-list/template.mustache
create mode 100644 server/java/render/src/test/resources/snapshots/render-list-separator.txt
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 514b4743a..a9d32a0e1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -68,6 +68,28 @@ generator catalog and the Atlas evaluation are 1.2 work.
generated-share figure must carry the net hand-written line change (a figure to report, not
the test) and is never a target.
Documentation only; no code or generator changed.
+- **Templates: `-first` / `-last` list accessors, so a separator needs no payload flag (every
+ port).** `{{#tags}}{{name}}{{^-last}}, {{/-last}}{{/tags}}` renders `a, b, c`. Every map-shaped
+ element of a collection now carries two derived booleans, `-first` and `-last`; the names start
+ with `-`, which no field name can, and they are ordinary Mustache tag names, so a template that
+ uses them is still valid Mustache. An authored key of the same name wins, a nested list keeps
+ its own position, and a template that uses neither renders byte-for-byte as before.
+ `meta verify --templates` accepts them inside a section over a container field and reports them
+ anywhere else as `ERR_VAR_NOT_ON_PAYLOAD`, including inside a list of scalars, where an element
+ cannot carry a key. Gated by the new `render-list-separator` render fixture and three
+ `verify-positional-accessor-*` verify fixtures, run by TypeScript, C#, Java, Kotlin and Python.
+ An adopter's payloads carried hand-computed `last` and `has` flags: `last` is what this
+ removes, and the emptiness half was already derived (`{{#hasTags}}` / `{{^hasTags}}`, see
+ `docs/features/templates-and-payloads.md`, "List separators and emptiness").
+- **TypeScript: a value object gets a field-rules constant.** An entity has long emitted
+ `..rules`; a value object emitted only its interface and Zod schema, so a caller
+ reading a limit such as `maxLength` went through the schema's getters. A value object's module
+ now also exports `const = { $entity, : { name, label, view, rules, ... } }`,
+ the same per-field shape without `$table` and `$path`, since a value object has no source and no
+ route. `meta gen` adds the constant to existing value-object modules; three-way merge preserves
+ hand edits. A TPH subtype and a sourceless entity are unchanged. An ejected `entity` generator
+ needs `meta eject entity --force` to pick it up.
+
- **TypeScript: the `cube-model` reference generator (FR-044 Plan 4).** `cubeModel()` writes the
reporting vocabulary as Cube data-model files, `model/cubes/.yml`: a cube for each
table-backed entity that declares dimensions, measures or segments, a join for each to-one
@@ -490,6 +512,17 @@ until you regenerate.
### Fixed
+- **Shared-enum dedup now survives a multi-hop `extends` (TypeScript, C#, Java, Python).** An enum
+ field that extends another field which itself extends a shared (package-level abstract) enum was
+ resolved only when it extended the abstract directly, so a two-hop field got its own inlined
+ `` enum beside the shared `enums.ts` one. The resolver now walks the `extends`
+ chain and takes the nearest root-level abstract enum, so the field references the shared type,
+ and the chain's `@values` are read through every hop (C# read one). An adopter's generated code
+ changes only where such a field exists: the duplicate enum disappears and the field references
+ the shared one. Kotlin is unchanged: it names a projection's enum per object on purpose (#259),
+ and keeps doing so. Gated by a `Task` entity added to the shared `shared-provided-enum`
+ fixture.
+
- **`meta verify --db` no longer reports a matching view as drift, and says what differs when a
view does not.** An adopter on 1.1.0-rc.2 rebuilt a SQLite database from the committed chain:
every report and projection view's stored SQL was byte-identical to the metadata's, yet
diff --git a/docs/CONFORMANCE.md b/docs/CONFORMANCE.md
index c12553bec..ee3666db8 100644
--- a/docs/CONFORMANCE.md
+++ b/docs/CONFORMANCE.md
@@ -28,9 +28,9 @@ regenerate with `ls -d fixtures//*/ | wc -l` for directory-shaped corpor
|---|---|---|---|---|---|---|
| [`fixtures/conformance/`](../fixtures/conformance/) (metamodel) | 374 | ✓ | ✓ | inherits via `metadata-ktx` | ✓ | ✓ |
| [`fixtures/yaml-conformance/`](../fixtures/yaml-conformance/) | 16 | 16 / 16 | 15 / 16 (1 ledgered: `yaml-quoted-leading-zero` — Java pipeline strips quotes off `"007"`) | inherits via Java | 15 / 16 (1 ledgered: `error-yaml-coerced-hex-in-string` — YamlDotNet doesn't coerce `0xFF`) | 16 / 16 |
-| [`fixtures/verify-conformance/`](../fixtures/verify-conformance/) | 31 | ✓ | ✓ | inherits via Java | ✓ | ✓ |
+| [`fixtures/verify-conformance/`](../fixtures/verify-conformance/) | 34 | ✓ | ✓ | inherits via Java | ✓ | ✓ |
| [`fixtures/verify-strict-conformance/`](../fixtures/verify-strict-conformance/) | 1 | ✓ | — | — | — | ✓ |
-| [`fixtures/render-conformance/`](../fixtures/render-conformance/) | 15 | ✓ | ✓ | inherits via Java | ✓ | ✓ |
+| [`fixtures/render-conformance/`](../fixtures/render-conformance/) | 16 | ✓ | ✓ | inherits via Java | ✓ | ✓ |
| [`fixtures/extract-conformance/`](../fixtures/extract-conformance/) | 48 | ✓ | ✓ | inherits the shared JVM engine | ✓ | ✓ |
| [`fixtures/output-prompt-conformance/`](../fixtures/output-prompt-conformance/) | 17 | ✓ | ✓ | ✓ | ✓ | ✓ |
| [`fixtures/persistence-conformance/`](../fixtures/persistence-conformance/) | 52 (46 query + 6 migration) | all 52 (the 19 `report-*` also on SQLite, MySQL and D1) | 46 query (migrations TS-only, ADR-0015); the 19 `report-*` also on MySQL | 46 query (via Exposed); the 19 `report-*` also on SQLite and MySQL | 46 query; the 19 `report-*` also on SQLite and MySQL | 46 query (Postgres only: pg8000) |
@@ -284,18 +284,18 @@ before its base in one file, etc.) and 7 `error-yaml-*` fixtures that pin the
YAML 1.1 coercion guards (bool / null / hex in string contexts; numeric in enum
contexts; reserved-as-attr).
-### `fixtures/render-conformance/` (15)
+### `fixtures/render-conformance/` (16)
-All 15 fixtures → [features/templates-and-payloads.md](features/templates-and-payloads.md)
+All 16 fixtures → [features/templates-and-payloads.md](features/templates-and-payloads.md)
(render engine output section). 4 are end-to-end shape examples (prompt / email /
-spreadsheet / CSV-injection escape); 10 pin Mustache-engine semantics — dotted-path
+spreadsheet / CSV-injection escape); 11 pin Mustache-engine semantics — dotted-path
lookup, parent-context fallthrough, falsy/empty-array section behavior, inverted
sections, nested partials, standalone-tag whitespace stripping, raw-HTML bypass,
-trailing-newline preservation, and unicode multibyte handling.
+trailing-newline preservation, unicode multibyte handling, and the `-first` / `-last` list-separator accessors.
-### `fixtures/verify-conformance/` (31)
+### `fixtures/verify-conformance/` (34)
-All 31 fixtures → [features/migrations-and-drift.md](features/migrations-and-drift.md)
+All 34 fixtures → [features/migrations-and-drift.md](features/migrations-and-drift.md)
(template drift section — `Renderer.verify`).
### `fixtures/persistence-conformance/` (52 — 46 query + 6 migration)
@@ -522,8 +522,8 @@ files into a real Cube.
## Orphaned fixtures (tested but not yet documented)
-The fixtures in the nine corpora mapped above (metamodel 374 + yaml 16 + verify 31
-+ render 15 + persistence 52 + api-contract 88 + source-resolution 25 + scope 10 +
+The fixtures in the nine corpora mapped above (metamodel 374 + yaml 16 + verify 34
++ render 16 + persistence 52 + api-contract 88 + source-resolution 25 + scope 10 +
dependency 23) each map to a feature doc, and so do the two requirement corpora and the
`cube-model` corpus, whose case lists live in their own READMEs. None are orphaned today. The remaining
corpora in the totals table gate tooling contracts (registry manifests, provider
diff --git a/docs/features/templates-and-payloads.md b/docs/features/templates-and-payloads.md
index 01214c397..f8b5b3fc7 100644
--- a/docs/features/templates-and-payloads.md
+++ b/docs/features/templates-and-payloads.md
@@ -209,6 +209,27 @@ For the `lobby/welcome` template:
```
+## List separators and emptiness
+
+A template needs a separator between list items and a way to ask "is there anything here", and
+neither needs a payload field the caller computes.
+
+- **Separator — `-first` / `-last`.** Every map-shaped element of a collection carries two
+ derived booleans, so `{{#tags}}{{name}}{{^-last}}, {{/-last}}{{/tags}}` renders `a, b, c` with
+ no `last` flag in the payload. `{{#-first}}…{{/-first}}` is the matching lead-in. The names
+ start with `-`, which no field name can, and they are ordinary Mustache tag names, so the
+ template is still valid Mustache and any Mustache tool parses it. An authored key of the same
+ name wins. A nested list keeps its own position, independent of the list around it.
+- **Emptiness — `has`.** `{{#hasTags}}…{{/hasTags}}` / `{{^hasTags}}…{{/hasTags}}` is
+ derived from the field `tags` (string: non-blank, collection: non-empty, object: present).
+ `{{#tags}}` over an empty list already renders nothing; `hasTags` is for the block that
+ surrounds the loop, a heading or a wrapper element.
+
+`meta verify --templates` accepts `-first` / `-last` inside a section over a container field and
+reports anything else as `ERR_VAR_NOT_ON_PAYLOAD`: at the top level, and inside a list of
+scalars, where an element has nowhere to carry a key (give the list an object element instead).
+Output of a template that uses neither is byte-for-byte what it was.
+
## What each port generates
### TypeScript
@@ -680,6 +701,7 @@ for payload typing per #270)
- [`fixtures/render-conformance/render-empty-array-falsiness/`](../../fixtures/render-conformance/render-empty-array-falsiness/) — `{{#xs}}…{{/xs}}` over an empty array renders nothing (vs. iterates)
- [`fixtures/render-conformance/render-falsy-values/`](../../fixtures/render-conformance/render-falsy-values/) — `false`, `null`, empty string, `0` — which are truthy for `{{#x}}` sections (per Mustache spec, not JS truthiness)
- [`fixtures/render-conformance/render-inverted-section/`](../../fixtures/render-conformance/render-inverted-section/) — `{{^x}}…{{/x}}` renders when `x` is falsy/absent
+- [`fixtures/render-conformance/render-list-separator/`](../../fixtures/render-conformance/render-list-separator/) — `-first` / `-last` positional accessors: `{{^-last}}, {{/-last}}` separators, lead-in, nested lists, single-element and empty lists
- [`fixtures/render-conformance/render-nested-partials/`](../../fixtures/render-conformance/render-nested-partials/) — `{{>partial}}` resolves through the provider, supports nesting
- [`fixtures/render-conformance/render-standalone-tag-stripping/`](../../fixtures/render-conformance/render-standalone-tag-stripping/) — a line containing only a section/partial tag is removed (whitespace + newline)
- [`fixtures/render-conformance/render-raw-html-bypass/`](../../fixtures/render-conformance/render-raw-html-bypass/) — `{{{x}}}` (or `{{&x}}`) emits raw, unescaped under `@format=html`
diff --git a/examples/advanced-modeling/.metaobjects/.gen-state/.hashes.json b/examples/advanced-modeling/.metaobjects/.gen-state/.hashes.json
index edb060783..4746a34bd 100644
--- a/examples/advanced-modeling/.metaobjects/.gen-state/.hashes.json
+++ b/examples/advanced-modeling/.metaobjects/.gen-state/.hashes.json
@@ -3,7 +3,7 @@
"src/generated/Author.queries.ts": "42cc1ab2fd442559e1e9c5c3f14978f306efcda5472f8b5370d3339753e5a36f",
"src/generated/Author.routes.ts": "3b0412b4501d02ba8716dae634522202714d467c5f9d577d8ea583868b1187d5",
"src/generated/Author.ts": "36f28e09c3633848bf3bdb45372e03ae8b036792dec05f503a6f0d3abc1bb1cc",
- "src/generated/InstructorProfile.ts": "83aa3425bb0f2ba409865339fa0d3b6af3ae63a4cbf1accc41b0e6e5c1841ccc",
+ "src/generated/InstructorProfile.ts": "2643b88401edf303789461d7d05bafd7cb1979b0ff19c27958d156192265986a",
"src/generated/Lesson.form.tsx": "10435d259837450502de4261ec6220fd6fdeecdf14fb22e377d8fd6d149c172e",
"src/generated/Lesson.queries.ts": "bc8e9bff3cb70c2c47ecfd07619f90d3f26a3981aca8ce6828f75e2a80fb9273",
"src/generated/Lesson.routes.ts": "ab910b2a1cfe1d38a49ce5d54de149bdbdfcd5adb077363b913ce6bebd01de13",
@@ -15,7 +15,7 @@
"src/generated/ProgramDescriptionOutput.output.ts": "f13b7b2108bf8507b6aeb1e819edd06eb2d689ed259dc04784d19d62bee9dfd2",
"src/generated/ProgramDescriptionOutput.render.ts": "77691e7f4c7dd8658ad2842fb5ea68a4b79e5c5ec69bb7221c618db303f2eb9f",
"src/generated/ProgramDescriptionPayload.form.tsx": "a9dff101453450117aff3029b2c5259e7d0866667aaab722ba890beaaa20f95d",
- "src/generated/ProgramDescriptionPayload.ts": "8df741a3271c6e6e8317041dd088f6b5de5f0f2e95c1b637528992c578e487d7",
+ "src/generated/ProgramDescriptionPayload.ts": "18161f91b74c419c062763d21dc5c7395bcb7208ea5adb0661ea91545b12200c",
"src/generated/ProgramSummary.queries.ts": "743e8ce74eee85bb821cf3d909957fae630085fb1f5a7567dd6bfdcae4364910",
"src/generated/ProgramSummary.routes.ts": "995e893a6b21e65d7bc031761c18bb7bb0c790abb31cb72546bf95573c980099",
"src/generated/ProgramSummary.ts": "181f48ed374dfdf2f5b3213e0064833f071fd7029ed9357a6eef764616c2429c",
@@ -23,6 +23,6 @@
"src/generated/Purchase.queries.ts": "ae7896ebdf3a57332e2c79dc9dd99fef9547054371547d6a469a752a7fc64ad6",
"src/generated/Purchase.routes.ts": "b1d1df2277754b63f137d5059603abc812a36795656099e7b9df4cf67928549c",
"src/generated/Purchase.ts": "e37c57f29eda8d7d4a13e8d58ab1695f0d07b8a804b6f5abadaba75adaa66484",
- "src/generated/SyllabusSection.ts": "bade3037cfbab599b92ea4f0b29509edbdbd5014bf29467e392853b124cdd2e3",
+ "src/generated/SyllabusSection.ts": "655f1fdd3c2f104fe48e8fb9a4147c3977a820f82e2b0a6ce1db8e4bd1b3ca2c",
"src/generated/index.ts": "b542ee705d68fe1695f930a14bd7fb54bf8e89f4f0688aaba58b7df2c2fb29db"
}
diff --git a/examples/advanced-modeling/src/generated/InstructorProfile.ts b/examples/advanced-modeling/src/generated/InstructorProfile.ts
index 293317cc7..4ee0b9bb9 100644
--- a/examples/advanced-modeling/src/generated/InstructorProfile.ts
+++ b/examples/advanced-modeling/src/generated/InstructorProfile.ts
@@ -13,3 +13,38 @@ export const InstructorProfileInsertSchema = z.object({
bio: z.string().max(2000).optional(),
credentialsSummary: z.string().max(300).optional(),
});
+/**
+ * Metadata constants for the value object InstructorProfile: each field's name, label, view and the
+ * validation rules derived from its validator children (e.g. `InstructorProfile..rules.maxLength.value`).
+ */
+export const InstructorProfile = {
+ $entity: "InstructorProfile",
+ displayName: {
+ name: "displayName",
+ label: "Display Name",
+ view: "text",
+ htmlType: "text",
+ rules: {
+ required: "Display Name is required",
+ maxLength: { value: 120, message: "Must be 120 characters or fewer" },
+ },
+ },
+ bio: {
+ name: "bio",
+ label: "Bio",
+ view: "text",
+ htmlType: "text",
+ rules: {
+ maxLength: { value: 2000, message: "Must be 2000 characters or fewer" },
+ },
+ },
+ credentialsSummary: {
+ name: "credentialsSummary",
+ label: "Credentials Summary",
+ view: "text",
+ htmlType: "text",
+ rules: {
+ maxLength: { value: 300, message: "Must be 300 characters or fewer" },
+ },
+ },
+} as const;
diff --git a/examples/advanced-modeling/src/generated/ProgramDescriptionPayload.ts b/examples/advanced-modeling/src/generated/ProgramDescriptionPayload.ts
index 751abd467..c2cb80d89 100644
--- a/examples/advanced-modeling/src/generated/ProgramDescriptionPayload.ts
+++ b/examples/advanced-modeling/src/generated/ProgramDescriptionPayload.ts
@@ -10,3 +10,38 @@ export interface ProgramDescriptionPayload {
lessonCount?: number | undefined;
}
export const ProgramDescriptionPayloadInsertSchema = z.object({});
+/**
+ * Metadata constants for the value object ProgramDescriptionPayload: each field's name, label, view and the
+ * validation rules derived from its validator children (e.g. `ProgramDescriptionPayload..rules.maxLength.value`).
+ */
+export const ProgramDescriptionPayload = {
+ $entity: "ProgramDescriptionPayload",
+ title: {
+ name: "title",
+ label: "Title",
+ view: "text",
+ htmlType: "text",
+ rules: {
+ required: "Title is required",
+ maxLength: { value: 200, message: "Must be 200 characters or fewer" },
+ },
+ },
+ summary: {
+ name: "summary",
+ label: "Summary",
+ view: "text",
+ htmlType: "text",
+ },
+ authorName: {
+ name: "authorName",
+ label: "Author Name",
+ view: "text",
+ htmlType: "text",
+ },
+ lessonCount: {
+ name: "lessonCount",
+ label: "Lesson Count",
+ view: "number",
+ htmlType: "number",
+ },
+} as const;
diff --git a/examples/advanced-modeling/src/generated/SyllabusSection.ts b/examples/advanced-modeling/src/generated/SyllabusSection.ts
index 3540fbca0..ae049c1ad 100644
--- a/examples/advanced-modeling/src/generated/SyllabusSection.ts
+++ b/examples/advanced-modeling/src/generated/SyllabusSection.ts
@@ -13,3 +13,36 @@ export const SyllabusSectionInsertSchema = z.object({
summary: z.string().max(500).optional(),
orderIndex: z.number().int(),
});
+/**
+ * Metadata constants for the value object SyllabusSection: each field's name, label, view and the
+ * validation rules derived from its validator children (e.g. `SyllabusSection..rules.maxLength.value`).
+ */
+export const SyllabusSection = {
+ $entity: "SyllabusSection",
+ heading: {
+ name: "heading",
+ label: "Heading",
+ view: "text",
+ htmlType: "text",
+ rules: {
+ required: "Heading is required",
+ maxLength: { value: 120, message: "Must be 120 characters or fewer" },
+ },
+ },
+ summary: {
+ name: "summary",
+ label: "Summary",
+ view: "text",
+ htmlType: "text",
+ rules: {
+ maxLength: { value: 500, message: "Must be 500 characters or fewer" },
+ },
+ },
+ orderIndex: {
+ name: "orderIndex",
+ label: "Order Index",
+ view: "number",
+ htmlType: "number",
+ rules: { required: "Order Index is required" },
+ },
+} as const;
diff --git a/examples/showcase/.metaobjects/.gen-state/.hashes.json b/examples/showcase/.metaobjects/.gen-state/.hashes.json
index a7c20cc3d..a35c3c239 100644
--- a/examples/showcase/.metaobjects/.gen-state/.hashes.json
+++ b/examples/showcase/.metaobjects/.gen-state/.hashes.json
@@ -14,7 +14,7 @@
"generated/ts/Subscriber.queries.ts": "9f1a459c57fafc98bf074b4443b506d4a1e7023ed188f293021ae89c298d4562",
"generated/ts/Subscriber.routes.ts": "147d32ae658f985e024c3de6e8894ca7359f767eeaa765cec70756802e3bd300",
"generated/ts/Subscriber.ts": "a78c17f915222ee108290c28468313d77f77b5cff438f485324409302e389a96",
- "generated/ts/SubscriberBlurbPayload.ts": "9b5e9be314bc0a9feed5fe0ad911d2cd14c961e455e52b87a44b4905353ec19e",
+ "generated/ts/SubscriberBlurbPayload.ts": "83272130c428ad0cc68190cb63e32b418a0bcfd05345804def9bf22e79b55fcc",
"generated/ts/index.ts": "2c915565fd8711503dbcd0b0139c657bb927c7f0f502d8d2a8b51e026b257694",
"generated/ts/prompts.ts": "67365bc5504dbb19fafbf6c654afa822f7fe6af4e5f24fd246d69657698db606",
"generated/ts/requirements/subscriberCanBePausedWithoutErasingHistory.field.enum.test.ts": "a5d2494ce5340673da43f2ee3ef13d7505ee349724e8d85f377a84eafeea50ae"
diff --git a/examples/showcase/generated/ts/SubscriberBlurbPayload.ts b/examples/showcase/generated/ts/SubscriberBlurbPayload.ts
index c2b66c0d9..e8c4f8182 100644
--- a/examples/showcase/generated/ts/SubscriberBlurbPayload.ts
+++ b/examples/showcase/generated/ts/SubscriberBlurbPayload.ts
@@ -13,3 +13,18 @@ export const SubscriberBlurbPayloadInsertSchema = z.object({
name: z.string().optional(),
status: z.enum(["active", "paused", "cancelled"]),
});
+/**
+ * Metadata constants for the value object SubscriberBlurbPayload: each field's name, label, view and the
+ * validation rules derived from its validator children (e.g. `SubscriberBlurbPayload..rules.maxLength.value`).
+ */
+export const SubscriberBlurbPayload = {
+ $entity: "SubscriberBlurbPayload",
+ name: { name: "name", label: "Name", view: "text", htmlType: "text" },
+ status: {
+ name: "status",
+ label: "Status",
+ view: "dropdown",
+ rules: { required: "Status is required" },
+ options: ["active", "paused", "cancelled"] as const,
+ },
+} as const;
diff --git a/fixtures/codegen-conformance/shared-provided-enum/input/meta.json b/fixtures/codegen-conformance/shared-provided-enum/input/meta.json
index eb87f21f3..fd6912743 100644
--- a/fixtures/codegen-conformance/shared-provided-enum/input/meta.json
+++ b/fixtures/codegen-conformance/shared-provided-enum/input/meta.json
@@ -18,6 +18,12 @@
{ "source.rdb": { "@table": "orders" } },
{ "identity.primary": { "name": "pk", "@fields": ["id"] } }
] } },
+ { "object.entity": { "name": "Task", "children": [
+ { "field.long": { "name": "id" } },
+ { "field.enum": { "name": "urgency", "extends": "acme::shop::Ticket.priority" } },
+ { "source.rdb": { "@table": "tasks" } },
+ { "identity.primary": { "name": "pk", "@fields": ["id"] } }
+ ] } },
{ "object.entity": { "name": "Invoice", "children": [
{ "field.long": { "name": "id" } },
{ "field.enum": { "name": "money", "extends": "Money" } },
diff --git a/fixtures/render-conformance/render-list-separator/expected.txt b/fixtures/render-conformance/render-list-separator/expected.txt
new file mode 100644
index 000000000..4072c5c26
--- /dev/null
+++ b/fixtures/render-conformance/render-list-separator/expected.txt
@@ -0,0 +1,6 @@
+items: a, b, c
+lead: (first) a b c
+solo: only
+none: []
+nested: [x|y];[z]
+authored: pq
diff --git a/fixtures/render-conformance/render-list-separator/meta.json b/fixtures/render-conformance/render-list-separator/meta.json
new file mode 100644
index 000000000..d64d7499c
--- /dev/null
+++ b/fixtures/render-conformance/render-list-separator/meta.json
@@ -0,0 +1 @@
+{ "format": "text", "note": "Positional -first/-last accessors on collection elements: separator via {{^-last}}, lead-in via {{#-first}}, nested lists keep their own position, single-element and empty lists, authored key wins" }
diff --git a/fixtures/render-conformance/render-list-separator/payload.json b/fixtures/render-conformance/render-list-separator/payload.json
new file mode 100644
index 000000000..1277589c1
--- /dev/null
+++ b/fixtures/render-conformance/render-list-separator/payload.json
@@ -0,0 +1,10 @@
+{
+ "items": [{ "name": "a" }, { "name": "b" }, { "name": "c" }],
+ "solo": [{ "name": "only" }],
+ "none": [],
+ "groups": [
+ { "tags": [{ "label": "x" }, { "label": "y" }] },
+ { "tags": [{ "label": "z" }] }
+ ],
+ "authored": [{ "name": "p", "-last": true }, { "name": "q", "-last": true }]
+}
diff --git a/fixtures/render-conformance/render-list-separator/template.mustache b/fixtures/render-conformance/render-list-separator/template.mustache
new file mode 100644
index 000000000..8c4981b30
--- /dev/null
+++ b/fixtures/render-conformance/render-list-separator/template.mustache
@@ -0,0 +1,6 @@
+items: {{#items}}{{name}}{{^-last}}, {{/-last}}{{/items}}
+lead:{{#items}}{{#-first}} (first){{/-first}} {{name}}{{/items}}
+solo: {{#solo}}{{name}}{{^-last}}, {{/-last}}{{/solo}}
+none: [{{#none}}{{name}}{{^-last}}, {{/-last}}{{/none}}]
+nested: {{#groups}}[{{#tags}}{{label}}{{^-last}}|{{/-last}}{{/tags}}]{{^-last}};{{/-last}}{{/groups}}
+authored: {{#authored}}{{name}}{{^-last}}, {{/-last}}{{/authored}}
diff --git a/fixtures/verify-conformance/verify-positional-accessor-clean/expected-drift.json b/fixtures/verify-conformance/verify-positional-accessor-clean/expected-drift.json
new file mode 100644
index 000000000..fe51488c7
--- /dev/null
+++ b/fixtures/verify-conformance/verify-positional-accessor-clean/expected-drift.json
@@ -0,0 +1 @@
+[]
diff --git a/fixtures/verify-conformance/verify-positional-accessor-clean/payload.json b/fixtures/verify-conformance/verify-positional-accessor-clean/payload.json
new file mode 100644
index 000000000..15ef2afa5
--- /dev/null
+++ b/fixtures/verify-conformance/verify-positional-accessor-clean/payload.json
@@ -0,0 +1,8 @@
+[
+ { "name": "displayName" },
+ { "name": "posts", "fields": [
+ { "name": "title" },
+ { "name": "tags", "fields": [{ "name": "name" }] }
+ ] },
+ { "name": "labels" }
+]
diff --git a/fixtures/verify-conformance/verify-positional-accessor-clean/template.mustache b/fixtures/verify-conformance/verify-positional-accessor-clean/template.mustache
new file mode 100644
index 000000000..f2b6e32e8
--- /dev/null
+++ b/fixtures/verify-conformance/verify-positional-accessor-clean/template.mustache
@@ -0,0 +1 @@
+{{#posts}}{{title}}{{^-last}}, {{/-last}}{{#-first}}!{{/-first}}{{#tags}}{{name}}{{^-last}}|{{/-last}}{{/tags}}{{/posts}}
diff --git a/fixtures/verify-conformance/verify-positional-accessor-outside-element/expected-drift.json b/fixtures/verify-conformance/verify-positional-accessor-outside-element/expected-drift.json
new file mode 100644
index 000000000..b8eb25ba5
--- /dev/null
+++ b/fixtures/verify-conformance/verify-positional-accessor-outside-element/expected-drift.json
@@ -0,0 +1 @@
+[ { "code": "ERR_VAR_NOT_ON_PAYLOAD", "path": "-last" } ]
diff --git a/fixtures/verify-conformance/verify-positional-accessor-outside-element/payload.json b/fixtures/verify-conformance/verify-positional-accessor-outside-element/payload.json
new file mode 100644
index 000000000..15ef2afa5
--- /dev/null
+++ b/fixtures/verify-conformance/verify-positional-accessor-outside-element/payload.json
@@ -0,0 +1,8 @@
+[
+ { "name": "displayName" },
+ { "name": "posts", "fields": [
+ { "name": "title" },
+ { "name": "tags", "fields": [{ "name": "name" }] }
+ ] },
+ { "name": "labels" }
+]
diff --git a/fixtures/verify-conformance/verify-positional-accessor-outside-element/template.mustache b/fixtures/verify-conformance/verify-positional-accessor-outside-element/template.mustache
new file mode 100644
index 000000000..503a949dc
--- /dev/null
+++ b/fixtures/verify-conformance/verify-positional-accessor-outside-element/template.mustache
@@ -0,0 +1 @@
+{{displayName}}{{^-last}}, {{/-last}}
diff --git a/fixtures/verify-conformance/verify-positional-accessor-scalar-list/expected-drift.json b/fixtures/verify-conformance/verify-positional-accessor-scalar-list/expected-drift.json
new file mode 100644
index 000000000..b8eb25ba5
--- /dev/null
+++ b/fixtures/verify-conformance/verify-positional-accessor-scalar-list/expected-drift.json
@@ -0,0 +1 @@
+[ { "code": "ERR_VAR_NOT_ON_PAYLOAD", "path": "-last" } ]
diff --git a/fixtures/verify-conformance/verify-positional-accessor-scalar-list/payload.json b/fixtures/verify-conformance/verify-positional-accessor-scalar-list/payload.json
new file mode 100644
index 000000000..15ef2afa5
--- /dev/null
+++ b/fixtures/verify-conformance/verify-positional-accessor-scalar-list/payload.json
@@ -0,0 +1,8 @@
+[
+ { "name": "displayName" },
+ { "name": "posts", "fields": [
+ { "name": "title" },
+ { "name": "tags", "fields": [{ "name": "name" }] }
+ ] },
+ { "name": "labels" }
+]
diff --git a/fixtures/verify-conformance/verify-positional-accessor-scalar-list/template.mustache b/fixtures/verify-conformance/verify-positional-accessor-scalar-list/template.mustache
new file mode 100644
index 000000000..250f9e5f5
--- /dev/null
+++ b/fixtures/verify-conformance/verify-positional-accessor-scalar-list/template.mustache
@@ -0,0 +1 @@
+{{#labels}}{{.}}{{^-last}}, {{/-last}}{{/labels}}
diff --git a/server/csharp/MetaObjects.Codegen.Tests/Fr019SharedProvidedConformanceTests.cs b/server/csharp/MetaObjects.Codegen.Tests/Fr019SharedProvidedConformanceTests.cs
index d86ef9166..4f97fca66 100644
--- a/server/csharp/MetaObjects.Codegen.Tests/Fr019SharedProvidedConformanceTests.cs
+++ b/server/csharp/MetaObjects.Codegen.Tests/Fr019SharedProvidedConformanceTests.cs
@@ -61,6 +61,16 @@ public void Shared_abstract_enum_is_materialized_once_and_referenced_by_both_ent
}
}
+ [Fact]
+ public void Multi_hop_enum_extends_still_resolves_to_the_shared_enum()
+ {
+ // Task.urgency extends Ticket.priority, which extends the shared abstract Priority.
+ var files = new EntityGenerator().Generate(Ctx(LoadFixture(), providedNs: "Acme.External")).ToList();
+ var task = Assert.Single(files, f => f.Path == "Task.g.cs");
+ Assert.DoesNotContain("public enum", task.Content);
+ Assert.Contains("public Priority? Urgency", task.Content);
+ }
+
[Fact]
public void Provided_enum_is_not_materialized_and_referenced_at_the_configured_namespace()
{
diff --git a/server/csharp/MetaObjects.Codegen/Generators/Fr019SharedEnum.cs b/server/csharp/MetaObjects.Codegen/Generators/Fr019SharedEnum.cs
index f320a939c..26f038b3b 100644
--- a/server/csharp/MetaObjects.Codegen/Generators/Fr019SharedEnum.cs
+++ b/server/csharp/MetaObjects.Codegen/Generators/Fr019SharedEnum.cs
@@ -44,19 +44,27 @@ public static class Fr019SharedEnum
///
/// The root-level abstract field.enum a concrete enum field resolves to via
/// extends , or when the field is an inline enum (no
- /// root-abstract super). A super qualifies only when it is (a) an abstract field, AND
+ /// root-abstract super). The nearest super in the chain that is (a) an abstract enum field, AND
/// (b) a direct child of the metadata root (a package-level declaration) — NOT an
/// abstract field nested inside another object.
///
public static MetaField? ResolveSharedEnumDecl(MetaField field)
{
if (field.SubType != FIELD_SUBTYPE_ENUM) return null;
- if (field.ResolveSuper() is not { } sup) return null;
- if (!sup.IsAbstract) return null;
- // The declaration must sit at the package/root level (sibling of object.entity),
- // i.e. its parent node is the metadata root.
- if (sup.Parent is not { } parent || parent.Type != BaseTypes.TYPE_METADATA) return null;
- return sup;
+ // Walk the extends chain: the NEAREST root-level abstract enum wins, so a field that
+ // extends a concrete (or nested) enum field which itself extends the shared declaration
+ // still resolves to it. The seen-set guards a malformed cyclic chain.
+ var seen = new HashSet(ReferenceEqualityComparer.Instance) { field };
+ var sup = field.ResolveSuper();
+ while (sup is not null && seen.Add(sup))
+ {
+ if (sup.SubType != FIELD_SUBTYPE_ENUM) return null;
+ // The declaration must sit at the package/root level (sibling of object.entity),
+ // i.e. its parent node is the metadata root.
+ if (sup.IsAbstract && sup.Parent is { } parent && parent.Type == BaseTypes.TYPE_METADATA) return sup;
+ sup = sup.ResolveSuper();
+ }
+ return null;
}
/// The shared-enum descriptor a field resolves to, or null for inline enums.
diff --git a/server/csharp/MetaObjects.Render/PayloadAccessors.cs b/server/csharp/MetaObjects.Render/PayloadAccessors.cs
index c1c56b3f6..7b08b12c7 100644
--- a/server/csharp/MetaObjects.Render/PayloadAccessors.cs
+++ b/server/csharp/MetaObjects.Render/PayloadAccessors.cs
@@ -33,6 +33,23 @@ public static class PayloadAccessors
///
public static string HasAccessorName(string fieldName) => HasPrefix + Capitalize(fieldName);
+ ///
+ /// Positional accessors injected into every map-shaped ELEMENT of a collection, so a list
+ /// separator needs no hand-computed last flag:
+ /// {{#items}}{{name}}{{^-last}}, {{/-last}}{{/items}} . The leading - cannot
+ /// collide with a field name and is still an ordinary Mustache tag name, so the template
+ /// stays valid Mustache. A scalar element has nowhere to hold a key and carries none; an
+ /// authored key of the same name wins, as for has<Field> .
+ ///
+ public const string FirstAccessor = "-first";
+
+ ///
+ public const string LastAccessor = "-last";
+
+ /// True when is -first /-last and the scope is a collection element.
+ public static bool IsPositionalAccessor(bool inElement, string name) =>
+ inElement && (name == FirstAccessor || name == LastAccessor);
+
/// Capitalize the first character, leaving an already-uppercase one untouched.
public static string Capitalize(string s)
{
@@ -122,6 +139,12 @@ public static bool IsBooleanAccessor(List> stack, st
{
var outList = new List();
foreach (var item in seq) outList.Add(WithDerivedAccessors(item, depth + 1));
+ for (int i = 0; i < outList.Count; i++)
+ {
+ if (outList[i] is not Dictionary el) continue;
+ el.TryAdd(FirstAccessor, i == 0);
+ el.TryAdd(LastAccessor, i == outList.Count - 1);
+ }
return outList;
}
diff --git a/server/csharp/MetaObjects.Render/Verify.cs b/server/csharp/MetaObjects.Render/Verify.cs
index 3856c5358..b28fa58b9 100644
--- a/server/csharp/MetaObjects.Render/Verify.cs
+++ b/server/csharp/MetaObjects.Render/Verify.cs
@@ -153,7 +153,7 @@ public static IReadOnlyList Check(
// (no second resolution pass).
var staticTexts = new List { templateText };
- void Walk(IReadOnlyList tokens, List> stack, List seen)
+ void Walk(IReadOnlyList tokens, List> stack, List seen, bool inElement = false)
{
bool atRoot = stack.Count == 1 && ReferenceEquals(stack[0], root);
foreach (var tok in tokens)
@@ -164,22 +164,30 @@ void Walk(IReadOnlyList tokens, List> stack, Li
if (v.Value == ".") break; // implicit iterator — always valid
if (atRoot) referencedAtRoot.Add(v.Value.Split('.')[0]);
if (Resolve(stack, v.Value) is null
- && !PayloadAccessors.IsBooleanAccessor(stack, v.Value))
+ && !PayloadAccessors.IsBooleanAccessor(stack, v.Value)
+ && !PayloadAccessors.IsPositionalAccessor(inElement, v.Value))
errors.Add(new VerifyError(ERR_VAR_NOT_ON_PAYLOAD, v.Value));
break;
case SectionTok s:
- if (s.Value == ".") { Walk(s.Children, stack, seen); break; }
+ if (s.Value == ".") { Walk(s.Children, stack, seen, inElement); break; }
if (atRoot) referencedAtRoot.Add(s.Value.Split('.')[0]);
var field = Resolve(stack, s.Value);
if (field is null)
{
+ // A positional `-first` / `-last` gate is a BOOLEAN on the current
+ // collection element: it pushes nothing, so the body walks in the SAME scope.
+ if (PayloadAccessors.IsPositionalAccessor(inElement, s.Value))
+ {
+ Walk(s.Children, stack, seen, inElement);
+ break;
+ }
// A derived `has` gate is a BOOLEAN over the current
// context: it resolves nothing and pushes nothing, so walk the
// body in the SAME scope — what `{{#hasX}}{{#x}}…` depends on.
if (PayloadAccessors.IsBooleanAccessor(stack, s.Value))
{
- Walk(s.Children, stack, seen);
+ Walk(s.Children, stack, seen, inElement);
break;
}
// Unresolved section head is itself drift; skip the body
@@ -193,9 +201,9 @@ void Walk(IReadOnlyList tokens, List> stack, Li
if (push)
{
var next = new List>(stack) { field.Fields! };
- Walk(s.Children, next, seen);
+ Walk(s.Children, next, seen, true);
}
- else Walk(s.Children, stack, seen);
+ else Walk(s.Children, stack, seen, inElement);
break;
case PartialTok p:
@@ -208,7 +216,7 @@ void Walk(IReadOnlyList tokens, List> stack, Li
break;
}
staticTexts.Add(text);
- Walk(Tokenize(text), stack, new List(seen) { p.Value });
+ Walk(Tokenize(text), stack, new List(seen) { p.Value }, inElement);
break;
}
}
diff --git a/server/csharp/MetaObjects/Meta/MetaField.cs b/server/csharp/MetaObjects/Meta/MetaField.cs
index 9426fd383..93aac3fa0 100644
--- a/server/csharp/MetaObjects/Meta/MetaField.cs
+++ b/server/csharp/MetaObjects/Meta/MetaField.cs
@@ -205,7 +205,7 @@ public long? Scale
/// this field nor any super carries @values .
///
public IReadOnlyList? EffectiveEnumValues =>
- EnumValues ?? (ResolveSuper()?.EnumValues);
+ EnumValues ?? ResolveSuper()?.EffectiveEnumValues;
///
/// True if the field is required (NOT NULL).
diff --git a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/Fr019SharedEnum.java b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/Fr019SharedEnum.java
index fe41ffbde..333d81444 100644
--- a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/Fr019SharedEnum.java
+++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/Fr019SharedEnum.java
@@ -97,17 +97,23 @@ public static Map parsePackageMap(String arg) {
/**
* The root-level abstract {@code field.enum} a concrete enum field resolves to via
* {@code extends}, or {@code null} when the field is an inline enum (no root-abstract super).
- * A super qualifies only when it is (a) an {@link EnumField}, (b) abstract, AND (c) a direct
- * child of the metadata root ({@link MetaRoot}) — NOT an abstract field nested inside an object.
+ * The {@code extends} chain is walked and the NEAREST super that is (a) an {@link EnumField},
+ * (b) abstract, AND (c) a direct child of the metadata root ({@link MetaRoot}) — NOT an
+ * abstract field nested inside an object — wins, so a multi-hop chain through concrete enum
+ * fields still reaches the shared declaration.
*/
public static MetaField> resolveSharedEnumDecl(MetaField> field) {
if (!(field instanceof EnumField)) return null;
+ java.util.Set> seen = java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>());
+ seen.add(field);
MetaField> sup = field.getSuperField();
- if (!(sup instanceof EnumField)) return null;
- if (!GeneratorUtil.isAbstract(sup)) return null;
- // The declaration must sit at the package/root level (a sibling of object.entity).
- if (!(sup.getParent() instanceof MetaRoot)) return null;
- return sup;
+ while (sup != null && seen.add(sup)) {
+ if (!(sup instanceof EnumField)) return null;
+ // The declaration must sit at the package/root level (a sibling of object.entity).
+ if (GeneratorUtil.isAbstract(sup) && sup.getParent() instanceof MetaRoot) return sup;
+ sup = sup.getSuperField();
+ }
+ return null;
}
/** The shared-enum descriptor a field resolves to, or {@code null} for inline enums. */
diff --git a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/Fr019SharedProvidedConformanceTest.java b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/Fr019SharedProvidedConformanceTest.java
index 6bbd2e372..02509f553 100644
--- a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/Fr019SharedProvidedConformanceTest.java
+++ b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/Fr019SharedProvidedConformanceTest.java
@@ -81,6 +81,16 @@ public void sharedEnumIsMaterializedOnceAndReferencedByBothEntities() throws Exc
}
}
+ @Test
+ public void multiHopEnumExtendsStillResolvesToTheSharedEnum() throws Exception {
+ // Task.urgency extends Ticket.priority, which extends the shared abstract Priority.
+ Path out = run(loadShared(), "com.acme.ext");
+ String task = Files.readString(out.resolve("acme/shop/TaskDto.java"));
+ assertTrue("urgency must be typed as the shared enum; saw:\n" + task, task.contains("Priority urgency"));
+ assertFalse("shared enum must NOT be redeclared in TaskDto; saw:\n" + task,
+ task.contains("public enum"));
+ }
+
@Test
public void providedEnumIsNotMaterializedAndReferencedAtTheConfiguredNamespace() throws Exception {
Path out = run(loadShared(), "com.acme.ext");
diff --git a/server/java/render/src/main/java/com/metaobjects/render/PayloadAccessors.java b/server/java/render/src/main/java/com/metaobjects/render/PayloadAccessors.java
index 5b87ffbd4..2183d11bb 100644
--- a/server/java/render/src/main/java/com/metaobjects/render/PayloadAccessors.java
+++ b/server/java/render/src/main/java/com/metaobjects/render/PayloadAccessors.java
@@ -22,8 +22,38 @@ public final class PayloadAccessors {
/** The {@code has} prefix every derived boolean accessor carries. */
public static final String HAS_PREFIX = "has";
+ /**
+ * Positional accessors injected into every map-shaped ELEMENT of a collection, so a list
+ * separator needs no hand-computed {@code last} flag:
+ * {@code {{#items}}{{name}}{{^-last}}, {{/-last}}{{/items}}}. The leading {@code -} cannot
+ * collide with a field name and is still an ordinary Mustache tag name, so the template stays
+ * valid Mustache. A scalar element has nowhere to hold a key and carries none. An authored key
+ * of the same name wins, as for {@code has}.
+ */
+ public static final String FIRST_ACCESSOR = "-first";
+ public static final String LAST_ACCESSOR = "-last";
+
private PayloadAccessors() { /* no instances */ }
+ /** Adds {@code -first}/{@code -last} to the map-shaped elements of an already-derived list. */
+ @SuppressWarnings("unchecked")
+ private static java.util.List withPositionalAccessors(java.util.List elements) {
+ int last = elements.size() - 1;
+ for (int i = 0; i < elements.size(); i++) {
+ if (elements.get(i) instanceof java.util.Map, ?> m) {
+ java.util.Map el = (java.util.Map) m;
+ el.putIfAbsent(FIRST_ACCESSOR, i == 0);
+ el.putIfAbsent(LAST_ACCESSOR, i == last);
+ }
+ }
+ return elements;
+ }
+
+ /** True when {@code name} is {@code -first}/{@code -last} and the scope is a collection element. */
+ public static boolean isPositionalAccessor(boolean inElement, String name) {
+ return inElement && (FIRST_ACCESSOR.equals(name) || LAST_ACCESSOR.equals(name));
+ }
+
/**
* The boolean-accessor method/section name for a payload field:
* {@code "has" + capitalize(fieldName)} (e.g. {@code abilities} →
@@ -149,13 +179,13 @@ private static Object withDerivedAccessors(Object payload, int depth) {
if (payload instanceof java.util.Collection> coll) {
java.util.List out = new java.util.ArrayList<>(coll.size());
for (Object item : coll) out.add(withDerivedAccessors(item, depth + 1));
- return out;
+ return withPositionalAccessors(out);
}
if (payload.getClass().isArray() && !payload.getClass().getComponentType().isPrimitive()) {
int n = java.lang.reflect.Array.getLength(payload);
java.util.List out = new java.util.ArrayList<>(n);
for (int i = 0; i < n; i++) out.add(withDerivedAccessors(java.lang.reflect.Array.get(payload, i), depth + 1));
- return out;
+ return withPositionalAccessors(out);
}
return payload;
}
diff --git a/server/java/render/src/main/java/com/metaobjects/render/Verify.java b/server/java/render/src/main/java/com/metaobjects/render/Verify.java
index ba1c9abd6..333d6ae97 100644
--- a/server/java/render/src/main/java/com/metaobjects/render/Verify.java
+++ b/server/java/render/src/main/java/com/metaobjects/render/Verify.java
@@ -98,7 +98,7 @@ public static List check(
staticTexts.add(templateText);
WalkContext ctx = new WalkContext(provider, root, errors, referencedAtRoot, staticTexts);
- walk(tokenize(templateText), List.of(root), List.of(), ctx);
+ walk(tokenize(templateText), List.of(root), List.of(), ctx, false);
if (opts.requiredSlots() != null) {
for (String slot : opts.requiredSlots()) {
@@ -139,7 +139,8 @@ private static void walk(
List tokens,
List> stack,
List seen,
- WalkContext ctx
+ WalkContext ctx,
+ boolean inElement
) {
boolean atRoot = stack.size() == 1 && stack.get(0) == ctx.root();
for (Tok tok : tokens) {
@@ -147,25 +148,32 @@ private static void walk(
case VarTok v -> {
if (".".equals(v.value())) break; // implicit iterator — always valid
if (atRoot) ctx.referencedAtRoot().add(v.value().split("\\.")[0]);
- if (resolve(stack, v.value()) == null && !isBooleanAccessor(stack, v.value())) {
+ if (resolve(stack, v.value()) == null && !isBooleanAccessor(stack, v.value())
+ && !PayloadAccessors.isPositionalAccessor(inElement, v.value())) {
ctx.errors().add(new VerifyError(ERR_VAR_NOT_ON_PAYLOAD, v.value()));
}
}
case SectionTok s -> {
if (".".equals(s.value())) {
- walk(s.children(), stack, seen, ctx);
+ walk(s.children(), stack, seen, ctx, inElement);
break;
}
if (atRoot) ctx.referencedAtRoot().add(s.value().split("\\.")[0]);
PayloadField field = resolve(stack, s.value());
if (field == null) {
+ // A positional {{#-first}} / {{^-last}} gate is a BOOLEAN on the current
+ // collection element: it pushes nothing, so the body walks in the SAME scope.
+ if (PayloadAccessors.isPositionalAccessor(inElement, s.value())) {
+ walk(s.children(), stack, seen, ctx, inElement);
+ break;
+ }
// An auto-derived boolean accessor ({{#hasAbilities}}) is a
// presence GATE over an optional/collection field reachable on
// the current scope — not a payload slot of its own. It keeps
// the current context (no push); its body still resolves against
// the enclosing scope.
if (isBooleanAccessor(stack, s.value())) {
- walk(s.children(), stack, seen, ctx);
+ walk(s.children(), stack, seen, ctx, inElement);
break;
}
// Unresolved section head is itself drift; skip the body
@@ -179,9 +187,9 @@ private static void walk(
if (push) {
List> next = new ArrayList<>(stack);
next.add(field.fields());
- walk(s.children(), Collections.unmodifiableList(next), seen, ctx);
+ walk(s.children(), Collections.unmodifiableList(next), seen, ctx, true);
} else {
- walk(s.children(), stack, seen, ctx);
+ walk(s.children(), stack, seen, ctx, inElement);
}
}
case PartialTok p -> {
@@ -195,7 +203,7 @@ private static void walk(
ctx.staticTexts().add(text);
List nextSeen = new ArrayList<>(seen);
nextSeen.add(p.value());
- walk(tokenize(text), stack, Collections.unmodifiableList(nextSeen), ctx);
+ walk(tokenize(text), stack, Collections.unmodifiableList(nextSeen), ctx, inElement);
}
}
}
diff --git a/server/java/render/src/test/resources/snapshots/render-list-separator.txt b/server/java/render/src/test/resources/snapshots/render-list-separator.txt
new file mode 100644
index 000000000..4072c5c26
--- /dev/null
+++ b/server/java/render/src/test/resources/snapshots/render-list-separator.txt
@@ -0,0 +1,6 @@
+items: a, b, c
+lead: (first) a b c
+solo: only
+none: []
+nested: [x|y];[z]
+authored: pq
diff --git a/server/python/src/metaobjects/codegen/generators/fr019_shared_enum.py b/server/python/src/metaobjects/codegen/generators/fr019_shared_enum.py
index fb1801336..0e7c85493 100644
--- a/server/python/src/metaobjects/codegen/generators/fr019_shared_enum.py
+++ b/server/python/src/metaobjects/codegen/generators/fr019_shared_enum.py
@@ -49,20 +49,23 @@ def _pascal(name: str) -> str:
def resolve_shared_enum_decl(field: MetaField) -> MetaData | None:
"""The package-level abstract ``field.enum`` *field* resolves to via ``extends``, or ``None``
- for an inline enum. Qualifies only when the immediate super is (a) a ``field.enum``,
- (b) abstract, AND (c) a direct child of the metadata root — a sibling of ``object.entity``,
- not an abstract field nested inside an object."""
+ for an inline enum. The ``extends`` chain is walked and the NEAREST super that is (a) a
+ ``field.enum``, (b) abstract, AND (c) a direct child of the metadata root — a sibling of
+ ``object.entity``, not an abstract field nested inside an object — wins, so a multi-hop
+ chain through concrete enum fields still reaches the shared declaration."""
if field.sub_type != fc.FIELD_SUBTYPE_ENUM:
return None
+ seen = {id(field)}
sup = field.super_data
- if sup is None or sup.sub_type != fc.FIELD_SUBTYPE_ENUM:
- return None
- if not getattr(sup, "is_abstract", False):
- return None
- parent = sup.parent
- if parent is None or parent.type != TYPE_METADATA:
- return None
- return sup
+ while sup is not None and id(sup) not in seen:
+ if sup.sub_type != fc.FIELD_SUBTYPE_ENUM:
+ return None
+ parent = sup.parent
+ if getattr(sup, "is_abstract", False) and parent is not None and parent.type == TYPE_METADATA:
+ return sup
+ seen.add(id(sup))
+ sup = sup.super_data
+ return None
def is_provided(decl: MetaData) -> bool:
diff --git a/server/python/src/metaobjects/render/payload_accessors.py b/server/python/src/metaobjects/render/payload_accessors.py
index 5ec3275f8..c97ac0611 100644
--- a/server/python/src/metaobjects/render/payload_accessors.py
+++ b/server/python/src/metaobjects/render/payload_accessors.py
@@ -27,11 +27,22 @@
"accessor_value",
"with_derived_accessors",
"is_boolean_accessor",
+ "FIRST_ACCESSOR",
+ "LAST_ACCESSOR",
+ "is_positional_accessor",
]
#: The ``has`` prefix every derived boolean accessor carries.
HAS_PREFIX = "has"
+#: Positional accessors injected into every mapping ELEMENT of a collection, so a list
+#: separator needs no hand-computed ``last`` flag: ``{{#items}}{{name}}{{^-last}}, {{/-last}}{{/items}}``.
+#: The leading ``-`` cannot collide with a field name and is still an ordinary Mustache tag
+#: name, so the template stays valid Mustache. Scalar elements carry none (nowhere to hold a
+#: key). An authored key of the same name wins, as for ``has``.
+FIRST_ACCESSOR = "-first"
+LAST_ACCESSOR = "-last"
+
_MAX_DEPTH = 32
@@ -121,7 +132,15 @@ def with_derived_accessors(payload: Any, depth: int = 0) -> Any:
if isinstance(payload, (str, bytes)):
return payload
if isinstance(payload, Sequence):
- return [with_derived_accessors(v, depth + 1) for v in payload]
+ last = len(payload) - 1
+ items = []
+ for i, v in enumerate(payload):
+ el = with_derived_accessors(v, depth + 1)
+ if isinstance(el, dict):
+ el.setdefault(FIRST_ACCESSOR, i == 0)
+ el.setdefault(LAST_ACCESSOR, i == last)
+ items.append(el)
+ return items
return payload
@@ -142,3 +161,10 @@ def is_boolean_accessor(stack: list[list[Any]], name: str) -> bool:
if name == has_accessor_name(f.name):
return True
return False
+
+
+def is_positional_accessor(in_element: bool, name: str) -> bool:
+ """True when ``name`` is ``-first`` / ``-last`` and the current scope is an element of a
+ collection section. Permissive about shape, like ``is_boolean_accessor``: ``in_element`` is
+ true whenever the innermost context was pushed by a ``{{#x}}`` over a container field."""
+ return in_element and name in (FIRST_ACCESSOR, LAST_ACCESSOR)
diff --git a/server/python/src/metaobjects/render/verify.py b/server/python/src/metaobjects/render/verify.py
index 3889f469c..2443c064c 100644
--- a/server/python/src/metaobjects/render/verify.py
+++ b/server/python/src/metaobjects/render/verify.py
@@ -23,7 +23,7 @@
from dataclasses import dataclass
from typing import Protocol
-from .payload_accessors import is_boolean_accessor
+from .payload_accessors import is_boolean_accessor, is_positional_accessor
#: A ``{{var}}`` references a field the (contextual) payload does not declare.
ERR_VAR_NOT_ON_PAYLOAD = "ERR_VAR_NOT_ON_PAYLOAD"
@@ -208,7 +208,10 @@ def verify(
static_texts: list[str] = [template_text]
def walk(
- tokens: list[_Token], stack: list[list[PayloadField]], seen: list[str]
+ tokens: list[_Token],
+ stack: list[list[PayloadField]],
+ seen: list[str],
+ in_element: bool = False,
) -> None:
at_root = len(stack) == 1 and stack[0] is root
for tok in tokens:
@@ -217,23 +220,30 @@ def walk(
continue # implicit iterator — always valid
if at_root:
referenced_at_root.add(tok.value.split(".")[0])
- if _resolve(stack, tok.value) is None and not is_boolean_accessor(
- stack, tok.value
+ if (
+ _resolve(stack, tok.value) is None
+ and not is_boolean_accessor(stack, tok.value)
+ and not is_positional_accessor(in_element, tok.value)
):
errors.append(VerifyError(ERR_VAR_NOT_ON_PAYLOAD, tok.value))
elif isinstance(tok, _Section):
if tok.value == ".":
- walk(tok.children, stack, seen)
+ walk(tok.children, stack, seen, in_element)
continue
if at_root:
referenced_at_root.add(tok.value.split(".")[0])
field = _resolve(stack, tok.value)
if field is None:
+ # A positional `-first` / `-last` gate is a BOOLEAN on the current element:
+ # it pushes nothing, so the body walks in the SAME scope.
+ if is_positional_accessor(in_element, tok.value):
+ walk(tok.children, stack, seen, in_element)
+ continue
# A derived `has` gate is a BOOLEAN over the current context:
# it resolves nothing and pushes nothing, so walk the body in the SAME
# scope — what `{{#hasAbilities}}{{#abilities}}…` depends on.
if is_boolean_accessor(stack, tok.value):
- walk(tok.children, stack, seen)
+ walk(tok.children, stack, seen, in_element)
continue
# Unresolved section head is itself drift; skip the body (its
# context is unknowable, walking it would cascade false errors).
@@ -242,9 +252,9 @@ def walk(
# `#` over a container pushes its element fields; `^` (and `#` over a
# scalar, used as a conditional) keep the current context.
if not tok.inverted and field.fields is not None:
- walk(tok.children, [*stack, field.fields], seen)
+ walk(tok.children, [*stack, field.fields], seen, True)
else:
- walk(tok.children, stack, seen)
+ walk(tok.children, stack, seen, in_element)
else: # _Partial
if provider is None:
continue # can't resolve without a provider
@@ -255,7 +265,7 @@ def walk(
errors.append(VerifyError(ERR_PARTIAL_UNRESOLVED, tok.value))
continue
static_texts.append(body)
- walk(_tokenize(body), stack, [*seen, tok.value])
+ walk(_tokenize(body), stack, [*seen, tok.value], in_element)
walk(_tokenize(template_text), [root], [])
diff --git a/server/python/tests/codegen/test_fr019_shared_provided_conformance.py b/server/python/tests/codegen/test_fr019_shared_provided_conformance.py
index 9ac224ce5..1bfb2b7b8 100644
--- a/server/python/tests/codegen/test_fr019_shared_provided_conformance.py
+++ b/server/python/tests/codegen/test_fr019_shared_provided_conformance.py
@@ -97,3 +97,13 @@ def test_shared_enum_members_are_uppercase_with_wire_value_preserved() -> None:
assert 'STATISTICAL = "statistical"' in enums
assert 'SEARCH_WEB = "search_web"' in enums
assert 'statistical = "statistical"' not in enums # member is not the lowercase value
+
+
+def test_multi_hop_enum_extends_still_resolves_to_the_shared_enum() -> None:
+ """``Task.urgency`` extends ``Ticket.priority`` which extends the shared abstract ``Priority``:
+ the shared declaration is found through the intermediate concrete field."""
+ files = _emit(GenConfig(out_dir="", provided_enum_namespace="acme_ext.enums"))
+ task = files["Task.py"]
+ assert "urgency: Priority" in task
+ assert "from .enums import Priority" in task
+ assert "Literal" not in task
diff --git a/server/typescript/packages/codegen-ts/src/enum-shared.ts b/server/typescript/packages/codegen-ts/src/enum-shared.ts
index 25f0cf632..0a1694f97 100644
--- a/server/typescript/packages/codegen-ts/src/enum-shared.ts
+++ b/server/typescript/packages/codegen-ts/src/enum-shared.ts
@@ -43,14 +43,20 @@ export interface SharedEnum {
*/
export function resolveSharedEnumDecl(field: MetaField): MetaField | undefined {
if (field.subType !== FIELD_SUBTYPE_ENUM) return undefined;
- const sup = field.resolveSuper();
- if (sup === undefined) return undefined;
- if (!sup.isAbstract) return undefined;
- // The declaration must sit at the package/root level (sibling of object.entity),
- // i.e. its parent node is the metadata root.
- const parent = sup.parent;
- if (parent === undefined || parent.type !== TYPE_METADATA) return undefined;
- return sup;
+ // Walk the extends chain: the NEAREST root-level abstract enum wins, so a field
+ // that extends a concrete (or nested) enum field which itself extends the shared
+ // declaration still resolves to it. The seen-set guards a malformed cyclic chain.
+ const seen = new Set([field]);
+ let sup = field.resolveSuper();
+ while (sup !== undefined && !seen.has(sup)) {
+ if (sup.subType !== FIELD_SUBTYPE_ENUM) return undefined;
+ // The declaration must sit at the package/root level (sibling of object.entity),
+ // i.e. its parent node is the metadata root.
+ if (sup.isAbstract && sup.parent !== undefined && sup.parent.type === TYPE_METADATA) return sup;
+ seen.add(sup);
+ sup = sup.resolveSuper();
+ }
+ return undefined;
}
/** The shared-enum descriptor a field resolves to, or undefined for inline enums. */
diff --git a/server/typescript/packages/codegen-ts/src/index.ts b/server/typescript/packages/codegen-ts/src/index.ts
index 552f44bdd..1e0c25ea3 100644
--- a/server/typescript/packages/codegen-ts/src/index.ts
+++ b/server/typescript/packages/codegen-ts/src/index.ts
@@ -234,7 +234,7 @@ export { renderBarrel } from "./templates/barrel.js";
export type { BarrelEntry } from "./templates/barrel.js";
export { renderFilterType } from "./templates/filter-type.js";
export { renderFilterAllowlist, renderSortAllowlist } from "./templates/filter-allowlist.js";
-export { renderEntityConstants, resourcePath } from "./templates/entity-constants.js";
+export { renderEntityConstants, renderValueObjectConstants, resourcePath } from "./templates/entity-constants.js";
export { renderQueriesFile } from "./templates/queries-file.js";
// #348 — which CRUD verbs a generated routes file mounts. Public because an OWNED
// routes generator (ADR-0034) composes the same render call and needs the same option.
diff --git a/server/typescript/packages/codegen-ts/src/templates/entity-constants.ts b/server/typescript/packages/codegen-ts/src/templates/entity-constants.ts
index f11d1c717..e77b9748a 100644
--- a/server/typescript/packages/codegen-ts/src/templates/entity-constants.ts
+++ b/server/typescript/packages/codegen-ts/src/templates/entity-constants.ts
@@ -92,6 +92,31 @@ function renderFieldEntry(f: UiFieldDescriptor): string {
return ` ${f.name}: {\n ${entries.join(",\n ")},\n }`;
}
+/**
+ * A value object's field-rules constant: `export const = { $entity, : { name, label,
+ * view, rules, ... } } as const`. The same per-field shape an entity's constant carries, minus
+ * `$table` and `$path` — a value object has no source and no route — so a caller reads a limit as
+ * `..rules.maxLength.value` instead of reaching through the Zod schema's getters.
+ */
+export function renderValueObjectConstants(obj: MetaObject, emittedName?: string): Code {
+ const descriptor = buildEntityUiDescriptor(obj);
+ // The emitted name (collision-qualified, ADR-0044) matches the value object's interface.
+ const name = emittedName ?? descriptor.entity;
+ const body = joinCode(
+ [code` $entity: ${JSON.stringify(descriptor.entity)}`, ...descriptor.fields.map((f) => code`${renderFieldEntry(f)}`)],
+ { on: ",\n" },
+ );
+ return code`
+/**
+ * Metadata constants for the value object ${name}: each field's name, label, view and the
+ * validation rules derived from its validator children (e.g. \`${name}..rules.maxLength.value\`).
+ */
+export const ${name} = {
+${body},
+} as const;
+`;
+}
+
export function renderEntityConstants(
obj: MetaObject,
/**
diff --git a/server/typescript/packages/codegen-ts/src/templates/value-object-file.ts b/server/typescript/packages/codegen-ts/src/templates/value-object-file.ts
index d149126e6..a566ab23b 100644
--- a/server/typescript/packages/codegen-ts/src/templates/value-object-file.ts
+++ b/server/typescript/packages/codegen-ts/src/templates/value-object-file.ts
@@ -24,7 +24,7 @@ import {
renderTphSubtypeReadSchema,
tphDiscriminatorPin,
} from "./zod-validators.js";
-import { renderEntityConstants } from "./entity-constants.js";
+import { renderEntityConstants, renderValueObjectConstants } from "./entity-constants.js";
import { renderFilterAllowlist, renderSortAllowlist } from "./filter-allowlist.js";
import { renderFilterType } from "./filter-type.js";
import { GENERATED_HEADER, GENERATED_EDIT_NOTE, sidecarLine } from "../constants.js";
@@ -83,6 +83,10 @@ export function renderValueObjectFile(obj: MetaObject, apiPrefix = "", ctx?: Ren
// object stays insert-only (no PATCH semantics — an UpdateSchema would mislead).
tphSubtype || sourceless ? renderZodValidators(obj, ctx) : renderInsertSchemaOnly(obj, ctx),
...(tphConstants !== null ? [tphConstants] : []),
+ // A pure value object (no TPH, no sourceless identity) gets a field-rules constant, so a caller
+ // reads `..rules` rather than the Zod schema's getters. It has no table or route, so
+ // it takes the constant without `$table`/`$path`.
+ ...(!tphSubtype && !sourceless ? [renderValueObjectConstants(obj, ctx ? ctx.valueObjectEmittedName(obj) : undefined)] : []),
...(tphFilterAllowlist !== null ? [tphFilterAllowlist] : []),
...(tphSortAllowlist !== null ? [tphSortAllowlist] : []),
...(tphFilterType !== null ? [tphFilterType] : []),
diff --git a/server/typescript/packages/codegen-ts/test/golden/fr019-shared-provided-conformance.test.ts b/server/typescript/packages/codegen-ts/test/golden/fr019-shared-provided-conformance.test.ts
index 7526a1327..2623aafa2 100644
--- a/server/typescript/packages/codegen-ts/test/golden/fr019-shared-provided-conformance.test.ts
+++ b/server/typescript/packages/codegen-ts/test/golden/fr019-shared-provided-conformance.test.ts
@@ -109,6 +109,20 @@ describe("FR-019 shared + provided enum conformance (TS)", () => {
expect(inv).not.toContain('from "@acme/ext-enums"');
});
+ // MULTI-HOP: `Task.urgency extends Ticket.priority`, and `Ticket.priority` itself extends the
+ // root abstract `Priority`. The shared declaration must be found through the intermediate
+ // concrete field, so `Task` references `enums.ts` instead of inlining its own `TaskUrgency`.
+ test("an enum field extending another enum field that extends the shared abstract still dedups", async () => {
+ const files = await gen(await loadSharedFixture(), "@acme/ext-enums");
+ const enums = files["enums.ts"]!;
+ expect(enums.split("export type Priority =").length - 1).toBe(1);
+ const task = files["Task.ts"]!;
+ expect(task).toBeDefined();
+ expect(task).toContain('from "./enums"');
+ expect(task).toContain("PriorityEnum");
+ expect(task).not.toContain("TaskUrgency");
+ });
+
test("a @provided enum with no providedEnumModule config is a codegen-time error naming the enum", async () => {
let err: unknown;
try {
diff --git a/server/typescript/packages/codegen-ts/test/templates/value-object-file.test.ts b/server/typescript/packages/codegen-ts/test/templates/value-object-file.test.ts
index d147f59f4..7d8a08a36 100644
--- a/server/typescript/packages/codegen-ts/test/templates/value-object-file.test.ts
+++ b/server/typescript/packages/codegen-ts/test/templates/value-object-file.test.ts
@@ -63,8 +63,14 @@ describe("renderValueObjectFile", () => {
expect(out).not.toContain("FilterAllowlist");
expect(out).not.toContain("SortAllowlist");
expect(out).not.toContain("$table:");
- expect(out).not.toContain("$entity:");
+ expect(out).not.toContain("$path:");
expect(out).not.toContain("UpdateSchema");
+
+ // A value object DOES get a field-rules constant (no $table/$path — it has no source or route),
+ // so a caller reads `SampleOutput..rules` instead of the Zod schema's getters.
+ expect(out).toContain("export const SampleOutput = {");
+ expect(out).toContain('$entity: "SampleOutput"');
+ expect(out).toMatch(/takeMarkdown: \{[^}]*rules: \{ required: "Take Markdown is required" \}/s);
});
test("field.object with a FULLY-QUALIFIED @objectRef strips to the bare type + sibling module", () => {
@@ -97,4 +103,14 @@ describe("renderValueObjectFile", () => {
expect(out).not.toMatch(/url\?:/);
expect(out).not.toMatch(/snippet\?:/);
});
+
+ test("a value object's constant carries a field's @maxLength as rules.maxLength.value", () => {
+ const vo = metaObject(OBJECT_SUBTYPE_VALUE, "Address");
+ const line1 = metaField(FIELD_SUBTYPE_STRING, "line1");
+ line1.setAttr("maxLength", 80);
+ vo.addChild(line1);
+ const out = renderValueObjectFile(vo);
+ expect(out).toContain("export const Address = {");
+ expect(out).toContain('maxLength: { value: 80, message: "Must be 80 characters or fewer" }');
+ });
});
diff --git a/server/typescript/packages/render/src/payload-accessors.ts b/server/typescript/packages/render/src/payload-accessors.ts
index 3f6baa195..7b03ab385 100644
--- a/server/typescript/packages/render/src/payload-accessors.ts
+++ b/server/typescript/packages/render/src/payload-accessors.ts
@@ -27,6 +27,17 @@ export function hasAccessorName(fieldName: string): string {
return HAS_PREFIX + capitalize(fieldName);
}
+/**
+ * Positional accessors injected into every ELEMENT of a collection, so a list separator needs
+ * no hand-computed `last` flag in the payload: `{{#items}}{{name}}{{^-last}}, {{/-last}}{{/items}}`.
+ * `-first` / `-last` are booleans on the element; the leading `-` cannot collide with a field
+ * name, and it is still an ordinary Mustache tag name, so the template stays valid Mustache.
+ * Only MAP-shaped elements carry them (a scalar element has nowhere to hold a key). An authored
+ * key of the same name wins, as it does for `has`.
+ */
+export const FIRST_ACCESSOR = "-first";
+export const LAST_ACCESSOR = "-last";
+
/** Capitalize the first character, leaving an already-uppercase one untouched. */
export function capitalize(s: string): string {
if (s.length === 0) return s;
@@ -74,7 +85,18 @@ export function accessorValue(value: unknown): boolean | undefined {
export function withDerivedAccessors(payload: T, depth = 0): T {
if (depth > 32) return payload; // pathological graph; render is not a validator
if (Array.isArray(payload)) {
- return payload.map((v) => withDerivedAccessors(v, depth + 1)) as unknown as T;
+ const last = payload.length - 1;
+ return payload.map((v, i) => {
+ const el = withDerivedAccessors(v, depth + 1);
+ if (el === null || typeof el !== "object" || Array.isArray(el)) return el;
+ const proto = Object.getPrototypeOf(el);
+ if (proto !== Object.prototype && proto !== null) return el;
+ const rec = el as Record;
+ const out: Record = { ...rec };
+ if (!Object.prototype.hasOwnProperty.call(rec, FIRST_ACCESSOR)) out[FIRST_ACCESSOR] = i === 0;
+ if (!Object.prototype.hasOwnProperty.call(rec, LAST_ACCESSOR)) out[LAST_ACCESSOR] = i === last;
+ return out;
+ }) as unknown as T;
}
if (payload === null || typeof payload !== "object") return payload;
// PLAIN objects only. Rebuilding from Object.entries() would flatten anything with
diff --git a/server/typescript/packages/render/src/verify.ts b/server/typescript/packages/render/src/verify.ts
index f086d809b..54a8d7649 100644
--- a/server/typescript/packages/render/src/verify.ts
+++ b/server/typescript/packages/render/src/verify.ts
@@ -11,7 +11,7 @@
import Mustache from "mustache";
import type { Provider } from "./provider.js";
-import { HAS_PREFIX, hasAccessorName } from "./payload-accessors.js";
+import { HAS_PREFIX, FIRST_ACCESSOR, LAST_ACCESSOR, hasAccessorName } from "./payload-accessors.js";
/** A `{{var}}` references a field the (contextual) payload does not declare. */
export const ERR_VAR_NOT_ON_PAYLOAD = "ERR_VAR_NOT_ON_PAYLOAD";
@@ -84,6 +84,17 @@ function isBooleanAccessor(stack: ResolveStack, name:
return false;
}
+/**
+ * True when `name` is a positional accessor (`-first` / `-last`) and the current scope is an
+ * element of a collection section — the engine injects both into every map-shaped element
+ * (payload-accessors.ts). Like `has` it is permissive about shape: `inElement` is true
+ * whenever the innermost context was pushed by a `{{#x}}` over a container field, which an
+ * object-typed field also is.
+ */
+function isPositionalAccessor(inElement: boolean, name: string): boolean {
+ return inElement && (name === FIRST_ACCESSOR || name === LAST_ACCESSOR);
+}
+
function find(fields: F[], name: string): F | undefined {
return fields.find((f) => f.name === name);
}
@@ -172,7 +183,7 @@ export function verify(
// (no second resolution pass).
const staticTexts: string[] = [templateText];
- function walk(tokens: Token[], stack: ResolveStack, seen: readonly string[]): void {
+ function walk(tokens: Token[], stack: ResolveStack, seen: readonly string[], inElement = false): void {
const atRoot = stack.length === 1 && stack[0] === root;
for (const tok of tokens) {
const type = tok[0] as string;
@@ -184,7 +195,7 @@ export function verify(
// {{{x}}} (spec); mustache.js emits "&" for it too
if (value === ".") break; // implicit iterator — always valid
if (atRoot) referencedAtRoot.add(value.split(".")[0]!);
- if (!resolve(stack, value) && !isBooleanAccessor(stack, value))
+ if (!resolve(stack, value) && !isBooleanAccessor(stack, value) && !isPositionalAccessor(inElement, value))
errors.push({ code: ERR_VAR_NOT_ON_PAYLOAD, path: value });
break;
}
@@ -193,17 +204,23 @@ export function verify(
// {{^x}}…{{/x}}
const sub = Array.isArray(tok[4]) ? (tok[4] as Token[]) : [];
if (value === ".") {
- walk(sub, stack, seen);
+ walk(sub, stack, seen, inElement);
break;
}
if (atRoot) referencedAtRoot.add(value.split(".")[0]!);
const field = resolve(stack, value);
if (!field) {
+ // A positional `-first` / `-last` gate is a BOOLEAN on the current element: it
+ // pushes nothing, so the body walks in the SAME scope.
+ if (isPositionalAccessor(inElement, value)) {
+ walk(sub, stack, seen, inElement);
+ break;
+ }
// A derived `has` gate is a BOOLEAN over the current context, so it
// resolves nothing and pushes nothing — walk the body in the SAME scope,
// which is what `{{#hasAbilities}}{{#abilities}}…` depends on.
if (isBooleanAccessor(stack, value)) {
- walk(sub, stack, seen);
+ walk(sub, stack, seen, inElement);
break;
}
// Unresolved section head is itself drift; skip the body (its
@@ -214,7 +231,7 @@ export function verify(
// `#` over a container pushes its element fields; `^` (and `#` over a
// scalar, used as a conditional) keep the current context.
const push = type === "#" && field.fields !== undefined;
- walk(sub, push ? [...stack, field.fields!] : stack, seen);
+ walk(sub, push ? [...stack, field.fields!] : stack, seen, push ? true : inElement);
break;
}
case ">": {
@@ -227,7 +244,7 @@ export function verify(
break;
}
staticTexts.push(text);
- walk(parse(text), stack, [...seen, value]);
+ walk(parse(text), stack, [...seen, value], inElement);
break;
}
default:
From 20ce9ed30df4c501b617a0d110257d2d360706a6 Mon Sep 17 00:00:00 2001
From: Doug Mealing
Date: Sun, 11 Oct 2026 00:08:41 -0400
Subject: [PATCH 2/7] no-mistakes(review): Finish Java render, typed payload
views, JVM shared enums
Assisted-by: no-mistakes:claude:claude-sonnet-5-5
---
CHANGELOG.md | 85 ++++---
docs/CONFORMANCE.md | 16 +-
docs/features/field-types.md | 25 +-
docs/features/migrations-and-drift.md | 3 +-
docs/features/templates-and-payloads.md | 37 ++-
.../.metaobjects/.gen-state/.hashes.json | 2 +-
.../generated/ProgramDescriptionPayload.ts | 35 ---
fixtures/codegen-conformance/README.md | 13 +
.../expected-enum-types.json | 27 ++
.../shared-provided-enum/input/meta.json | 60 ++++-
.../render-list-separator/expected.txt | 3 +-
.../render-list-separator/meta.json | 2 +-
.../render-list-separator/payload.json | 5 +
.../render-list-separator/template.mustache | 3 +-
.../payload.json | 10 +-
.../template.mustache | 2 +-
.../expected-drift.json | 1 +
.../payload.json | 12 +
.../template.mustache | 1 +
.../expected-drift.json | 1 +
.../payload.json | 12 +
.../template.mustache | 1 +
.../payload.json | 10 +-
.../payload.json | 10 +-
.../expected-drift.json | 1 +
.../payload.json | 12 +
.../template.mustache | 1 +
.../Fr019SharedProvidedConformanceTests.cs | 142 ++++++++++-
.../Generators/Fr019SharedEnum.cs | 37 +--
.../Generators/RenderHelperGenerator.cs | 18 +-
.../MetaObjects.Codegen/PayloadFieldTree.cs | 9 +-
.../TypedPayloadTests.cs | 115 +++++++++
.../VerifyConformanceTests.cs | 5 +-
.../MetaObjects.Render.Tests/VerifyTests.cs | 4 +-
.../MetaObjects.Render/PayloadAccessors.cs | 67 ++---
server/csharp/MetaObjects.Render/Verify.cs | 20 +-
.../Core/Field/SharedEnumDeclaration.cs | 66 +++++
.../MetaObjects/Loader/ValidationPasses.cs | 27 +-
.../generator/verify/TemplateVerify.java | 7 +-
.../generator/kotlin/Fr019SharedEnum.kt | 34 ++-
.../generator/kotlin/KotlinEnumEmitter.kt | 7 +-
.../kotlin/KotlinRenderHelperGenerator.kt | 11 +-
.../generator/kotlin/KotlinTypeMapper.kt | 71 ++----
...otlinFr019SharedProvidedConformanceTest.kt | 146 +++++++++++
.../kotlin/KotlinProjectionTwoHopEnumTest.kt | 99 +++++---
.../kotlin/KotlinTypedPayloadRenderTest.kt | 68 ++++++
.../generator/spring/Fr019SharedEnum.java | 103 +++++---
.../generator/spring/SpringDtoGenerator.java | 34 +--
.../spring/SpringOutputParserGenerator.java | 25 +-
.../spring/SpringRenderHelperGenerator.java | 13 +-
.../generator/spring/SpringTypeMapper.java | 40 +--
.../spring/SpringValueObjectGenerator.java | 46 +++-
.../Fr019SharedProvidedConformanceTest.java | 231 +++++++++++++++++-
.../GeneratedTypedEnumCompileRunTest.java | 19 +-
.../java/com/metaobjects/field/EnumField.java | 64 +++++
.../metaobjects/loader/ValidationPhase.java | 18 +-
.../metaobjects/render/PayloadAccessors.java | 86 ++++++-
.../com/metaobjects/render/PayloadField.java | 26 +-
.../java/com/metaobjects/render/Verify.java | 15 +-
.../render/TypedPayloadRenderTest.java | 135 ++++++++++
.../render/VerifyConformanceTest.java | 5 +-
.../com/metaobjects/render/VerifyTest.java | 31 +++
.../snapshots/render-list-separator.txt | 3 +-
.../codegen/generators/fr019_shared_enum.py | 38 +--
.../generators/render_helper_generator.py | 22 +-
.../metaobjects/loader/validation_passes.py | 44 +---
.../metaobjects/meta/core/field/meta_field.py | 68 +++++-
.../metaobjects/render/payload_accessors.py | 16 +-
.../python/src/metaobjects/render/verify.py | 30 ++-
.../test_fr019_shared_provided_conformance.py | 166 ++++++++++++-
.../tests/render/test_render_model_payload.py | 76 ++++++
.../tests/render/test_verify_conformance.py | 12 +-
.../cli/src/lib/payload-field-tree.ts | 12 +-
.../cli/test/unit/payload-field-tree.test.ts | 31 ++-
.../packages/codegen-ts/src/enum-shared.ts | 60 ++---
.../src/generators/template-payload-tree.ts | 1 +
.../src/templates/entity-constants.ts | 9 +-
.../src/templates/inferred-types.ts | 15 +-
.../codegen-ts/src/templates/render-helper.ts | 11 +-
.../src/templates/value-object-file.ts | 21 +-
.../test/generators/entity-file.test.ts | 16 +-
.../fr019-shared-provided-conformance.test.ts | 157 +++++++++++-
.../test/projection/entity-file.test.ts | 12 +
.../metadata/src/attr-schema-validate.ts | 22 +-
.../metadata/src/core/field/shared-enum.ts | 53 ++++
.../typescript/packages/metadata/src/index.ts | 3 +
.../packages/render/src/payload-accessors.ts | 103 +++++---
.../typescript/packages/render/src/verify.ts | 46 ++--
.../render/test/payload-accessors.test.ts | 125 +++++++++-
.../render/test/verify-conformance.test.ts | 4 +-
spec/conformance-tests.md | 10 +-
...DR-0026-shared-and-provided-named-types.md | 50 ++++
92 files changed, 2791 insertions(+), 679 deletions(-)
create mode 100644 fixtures/codegen-conformance/shared-provided-enum/expected-enum-types.json
create mode 100644 fixtures/verify-conformance/verify-positional-accessor-nested-scalar-list/expected-drift.json
create mode 100644 fixtures/verify-conformance/verify-positional-accessor-nested-scalar-list/payload.json
create mode 100644 fixtures/verify-conformance/verify-positional-accessor-nested-scalar-list/template.mustache
create mode 100644 fixtures/verify-conformance/verify-positional-accessor-object-section/expected-drift.json
create mode 100644 fixtures/verify-conformance/verify-positional-accessor-object-section/payload.json
create mode 100644 fixtures/verify-conformance/verify-positional-accessor-object-section/template.mustache
create mode 100644 fixtures/verify-conformance/verify-positional-accessor-transparent-sections-clean/expected-drift.json
create mode 100644 fixtures/verify-conformance/verify-positional-accessor-transparent-sections-clean/payload.json
create mode 100644 fixtures/verify-conformance/verify-positional-accessor-transparent-sections-clean/template.mustache
create mode 100644 server/csharp/MetaObjects.Render.Tests/TypedPayloadTests.cs
create mode 100644 server/csharp/MetaObjects/Core/Field/SharedEnumDeclaration.cs
create mode 100644 server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinTypedPayloadRenderTest.kt
create mode 100644 server/java/render/src/test/java/com/metaobjects/render/TypedPayloadRenderTest.java
create mode 100644 server/typescript/packages/metadata/src/core/field/shared-enum.ts
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a9d32a0e1..4ebda3515 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -42,7 +42,7 @@ number. The first landed on 1.0.x: 439 lines of handler code retired. The second
and it is a SQL reduction only: in an adopter, hand-written SQL went from 75 lines to 15 (-80%).
Total hand-written lines did not fall, so do not read it as a total line reduction.
-**Two diffs to expect when you upgrade.**
+**Three diffs to expect when you upgrade.**
- **An ejected generator needs a resync (G11).** An owned copy keeps the gate and the options it
was copied with, so it does not receive the new report behaviour until you resync it. The
@@ -53,6 +53,11 @@ Total hand-written lines did not fall, so do not read it as a total line reducti
surface of a keyless projection (no `/:id` route, no by-id function, and no write verbs in the
API docs), and `meta verify --docs` reports the affected pages as stale until they are
regenerated. Run `meta gen` and review the diff; three-way merge preserves hand edits.
+- **A multi-hop enum field references the shared enum.** A field that reaches a
+ package-level abstract enum through another field used to get its own enum type; it now
+ references the shared one, in every port. In Kotlin this includes a projection or value-object
+ field: its former `` enum file is no longer generated, and code that imported
+ that type imports the shared enum instead. A model with no such field regenerates unchanged.
**Not in 1.1.0.** Derived measures, the preview `reporting` library, the dbt exporter, the second
requirements slice and the advisory lint port, the JSON Schema and OpenAPI generators, the
@@ -68,27 +73,39 @@ generator catalog and the Atlas evaluation are 1.2 work.
generated-share figure must carry the net hand-written line change (a figure to report, not
the test) and is never a target.
Documentation only; no code or generator changed.
-- **Templates: `-first` / `-last` list accessors, so a separator needs no payload flag (every
- port).** `{{#tags}}{{name}}{{^-last}}, {{/-last}}{{/tags}}` renders `a, b, c`. Every map-shaped
- element of a collection now carries two derived booleans, `-first` and `-last`; the names start
- with `-`, which no field name can, and they are ordinary Mustache tag names, so a template that
- uses them is still valid Mustache. An authored key of the same name wins, a nested list keeps
- its own position, and a template that uses neither renders byte-for-byte as before.
- `meta verify --templates` accepts them inside a section over a container field and reports them
- anywhere else as `ERR_VAR_NOT_ON_PAYLOAD`, including inside a list of scalars, where an element
- cannot carry a key. Gated by the new `render-list-separator` render fixture and three
- `verify-positional-accessor-*` verify fixtures, run by TypeScript, C#, Java, Kotlin and Python.
- An adopter's payloads carried hand-computed `last` and `has` flags: `last` is what this
- removes, and the emptiness half was already derived (`{{#hasTags}}` / `{{^hasTags}}`, see
- `docs/features/templates-and-payloads.md`, "List separators and emptiness").
+- **Templates: a `-last` list accessor, so a separator needs no payload flag (every port).**
+ `{{#tags}}{{name}}{{^-last}}, {{/-last}}{{/tags}}` renders `a, b, c`. Every object element of a
+ collection now carries a derived boolean `-last`; the name starts with `-`, which no field name
+ can, and it is an ordinary Mustache tag name, so a template that uses it is still valid
+ Mustache. An authored member of the same name wins, a nested list keeps its own position, and
+ a template that does not use it renders byte-for-byte as before. It works whatever shape the
+ payload arrives in, and so does `has`: a map or plain object, a TypeScript class
+ instance, a C# object with or without `[JsonPropertyName]`, a Java record or getter-based
+ bean, a Kotlin data class, a Pydantic model. The same data renders the same bytes.
+ `meta verify --templates` accepts `-last` only when the innermost enclosing section over a
+ list iterates objects, and reports it as `ERR_VAR_NOT_ON_PAYLOAD` anywhere else: at the top
+ level, inside a list of scalars, and inside a list of scalars nested in a list of objects,
+ where the lookup would bind to the outer element and separate the inner list wrongly. A
+ scalar conditional or a single nested object inside a list is transparent, so `-last` there
+ is the enclosing element's. To decide that, the verify field tree now records which fields
+ are lists; a generated render helper for a payload with a list field is rewritten by `meta
+ gen` to carry that flag, and a helper with none is unchanged. Gated by the
+ `render-list-separator` render fixture and six `verify-positional-accessor-*` verify
+ fixtures, run by TypeScript, C#, Java, Kotlin and Python, plus a typed-payload render test in
+ each port. An adopter's payloads carried hand-computed `last` and `has` flags: `last`
+ is what this removes, and the emptiness half was already derived (`{{#hasTags}}` /
+ `{{^hasTags}}`, see `docs/features/templates-and-payloads.md`, "List separators and
+ emptiness").
- **TypeScript: a value object gets a field-rules constant.** An entity has long emitted
`..rules`; a value object emitted only its interface and Zod schema, so a caller
- reading a limit such as `maxLength` went through the schema's getters. A value object's module
- now also exports `const = { $entity, : { name, label, view, rules, ... } }`,
- the same per-field shape without `$table` and `$path`, since a value object has no source and no
- route. `meta gen` adds the constant to existing value-object modules; three-way merge preserves
- hand edits. A TPH subtype and a sourceless entity are unchanged. An ejected `entity` generator
- needs `meta eject entity --force` to pick it up.
+ reading a limit such as `maxLength` went through the schema's getters. An `object.value`
+ module now also exports `const = { $entity, : { name, label, view,
+ rules, ... } }`, the same per-field shape without `$table` and `$path`, since a value object
+ has no source and no route. Only the value subtype takes it: an abstract object, a TPH
+ subtype, a sourceless entity, a sourceless projection and an entity emitted to a contract-only
+ target are unchanged. `meta gen` adds the constant to existing value-object modules;
+ three-way merge preserves hand edits. An ejected `entity` generator picks it up without a
+ resync, because the owned copy calls the package's value-object composer.
- **TypeScript: the `cube-model` reference generator (FR-044 Plan 4).** `cubeModel()` writes the
reporting vocabulary as Cube data-model files, `model/cubes/.yml`: a cube for each
@@ -512,16 +529,24 @@ until you regenerate.
### Fixed
-- **Shared-enum dedup now survives a multi-hop `extends` (TypeScript, C#, Java, Python).** An enum
- field that extends another field which itself extends a shared (package-level abstract) enum was
- resolved only when it extended the abstract directly, so a two-hop field got its own inlined
- `` enum beside the shared `enums.ts` one. The resolver now walks the `extends`
- chain and takes the nearest root-level abstract enum, so the field references the shared type,
- and the chain's `@values` are read through every hop (C# read one). An adopter's generated code
- changes only where such a field exists: the duplicate enum disappears and the field references
- the shared one. Kotlin is unchanged: it names a projection's enum per object on purpose (#259),
- and keeps doing so. Gated by a `Task` entity added to the shared `shared-provided-enum`
- fixture.
+- **One shared-enum rule in all five ports: a field keeps the shared type through any number
+ of `extends` hops.** An enum field that extends another field which itself reaches a shared
+ (package-level abstract) enum was resolved only when it extended the abstract directly, so a
+ two-hop field got its own inlined enum beside the shared one, and each port decided the
+ question in more than one place. The rule is now one function per port, in the metadata
+ layer, that the loader and every generator call (ADR-0026 Amendment 1): walk the `extends`
+ chain from the field; the nearest package-level abstract enum is the field's type; a field
+ that declares its own `@values` or `@intValueMap` on the way starts a new vocabulary and
+ keeps a per-object inline enum, as does a chain that never reaches a shared declaration. The
+ hosting object does not matter: an entity, projection or value-object field all reference the
+ one shared type. The shared type's members and int map are read from the declaration, so what
+ is generated no longer depends on which consumer is visited first, and it is generated
+ whenever any consumer references it, including when the only consumer is a projection or a
+ value object. `ERR_ENUM_EXTENDS_VALUES_CONFLICT` is unchanged: it still fires for own
+ `@values` or `@intValueMap` directly under a shared declaration, and a redeclaration further
+ down the chain still loads. This supersedes the per-projection enum copy #259 introduced in
+ Kotlin. Gated by a decision table every port answers
+ (`fixtures/codegen-conformance/shared-provided-enum/expected-enum-types.json`).
- **`meta verify --db` no longer reports a matching view as drift, and says what differs when a
view does not.** An adopter on 1.1.0-rc.2 rebuilt a SQLite database from the committed chain:
diff --git a/docs/CONFORMANCE.md b/docs/CONFORMANCE.md
index ee3666db8..9081b3e82 100644
--- a/docs/CONFORMANCE.md
+++ b/docs/CONFORMANCE.md
@@ -28,7 +28,7 @@ regenerate with `ls -d fixtures//*/ | wc -l` for directory-shaped corpor
|---|---|---|---|---|---|---|
| [`fixtures/conformance/`](../fixtures/conformance/) (metamodel) | 374 | ✓ | ✓ | inherits via `metadata-ktx` | ✓ | ✓ |
| [`fixtures/yaml-conformance/`](../fixtures/yaml-conformance/) | 16 | 16 / 16 | 15 / 16 (1 ledgered: `yaml-quoted-leading-zero` — Java pipeline strips quotes off `"007"`) | inherits via Java | 15 / 16 (1 ledgered: `error-yaml-coerced-hex-in-string` — YamlDotNet doesn't coerce `0xFF`) | 16 / 16 |
-| [`fixtures/verify-conformance/`](../fixtures/verify-conformance/) | 34 | ✓ | ✓ | inherits via Java | ✓ | ✓ |
+| [`fixtures/verify-conformance/`](../fixtures/verify-conformance/) | 37 | ✓ | ✓ | inherits via Java | ✓ | ✓ |
| [`fixtures/verify-strict-conformance/`](../fixtures/verify-strict-conformance/) | 1 | ✓ | — | — | — | ✓ |
| [`fixtures/render-conformance/`](../fixtures/render-conformance/) | 16 | ✓ | ✓ | inherits via Java | ✓ | ✓ |
| [`fixtures/extract-conformance/`](../fixtures/extract-conformance/) | 48 | ✓ | ✓ | inherits the shared JVM engine | ✓ | ✓ |
@@ -38,7 +38,7 @@ regenerate with `ls -d fixtures//*/ | wc -l` for directory-shaped corpor
| [`fixtures/validation-conformance/`](../fixtures/validation-conformance/) | 42 cases | ✓ (generated Zod + run-time `runValidators`) | ✓ | ✓ | ✓ | ✓ (generated Pydantic + run-time `run_validators`) |
| [`fixtures/registry-conformance/`](../fixtures/registry-conformance/) | 1 canonical manifest | ✓ (reference emitter) | ✓ | ✓ | ✓ | ✓ |
| [`fixtures/object-model-conformance/`](../fixtures/object-model-conformance/) | 1 shared metadata fixture (per-port scenarios) | ✓ | ✓ | ✓ | ✓ | ✓ |
-| [`fixtures/codegen-conformance/`](../fixtures/codegen-conformance/) | 4 | ✓ | ✓ | ✓ | ✓ | ✓ |
+| [`fixtures/codegen-conformance/`](../fixtures/codegen-conformance/) | 5 shared inputs; `shared-provided-enum` also carries the enum-type decision table every port answers | ✓ | ✓ | ✓ | ✓ | ✓ |
| [`fixtures/template-codegen-conformance/`](../fixtures/template-codegen-conformance/) | 3 | ✓ | ✓ | ✓ | ✓ | ✓ |
| [`fixtures/template-output-render-conformance/`](../fixtures/template-output-render-conformance/) | 5 | ✓ | ✓ | ✓ | ✓ | ✓ |
| [`fixtures/generator-registry-conformance/`](../fixtures/generator-registry-conformance/) | 1 canonical manifest | ✓ | ✓ | ✓ | ✓ | ✓ |
@@ -291,12 +291,14 @@ All 16 fixtures → [features/templates-and-payloads.md](features/templates-and-
spreadsheet / CSV-injection escape); 11 pin Mustache-engine semantics — dotted-path
lookup, parent-context fallthrough, falsy/empty-array section behavior, inverted
sections, nested partials, standalone-tag whitespace stripping, raw-HTML bypass,
-trailing-newline preservation, unicode multibyte handling, and the `-first` / `-last` list-separator accessors.
+trailing-newline preservation, unicode multibyte handling, and the `-last` list-separator accessor.
-### `fixtures/verify-conformance/` (34)
+### `fixtures/verify-conformance/` (37)
-All 34 fixtures → [features/migrations-and-drift.md](features/migrations-and-drift.md)
-(template drift section — `Renderer.verify`).
+All 37 fixtures → [features/migrations-and-drift.md](features/migrations-and-drift.md)
+(template drift section — `Renderer.verify`). A node of a fixture's `payload.json` field tree
+may carry `"isArray": true`; with `fields` it is a list of objects, without it a list of
+scalars. The six `verify-positional-accessor-*` cases pin where `-last` is available.
### `fixtures/persistence-conformance/` (52 — 46 query + 6 migration)
@@ -522,7 +524,7 @@ files into a real Cube.
## Orphaned fixtures (tested but not yet documented)
-The fixtures in the nine corpora mapped above (metamodel 374 + yaml 16 + verify 34
+The fixtures in the nine corpora mapped above (metamodel 374 + yaml 16 + verify 37
+ render 16 + persistence 52 + api-contract 88 + source-resolution 25 + scope 10 +
dependency 23) each map to a feature doc, and so do the two requirement corpora and the
`cube-model` corpus, whose case lists live in their own READMEs. None are orphaned today. The remaining
diff --git a/docs/features/field-types.md b/docs/features/field-types.md
index 10dac1d74..19570f511 100644
--- a/docs/features/field-types.md
+++ b/docs/features/field-types.md
@@ -222,8 +222,9 @@ Two rules follow from int-backing being a **persistence-layer codec**:
Reuse a constraint set across entities by declaring one **abstract** `field.enum`
(with the `@values`) and having each concrete field `extends` it — including
**across packages** (declare the shared enum in a common package and reference it
-by FQN). Every field that extends the same abstract enum collapses onto one
-generated enum type, so the members stay in exactly one place:
+by FQN). Every field that reaches the same abstract enum, directly or through other
+fields, collapses onto one generated enum type, so the members stay in exactly one
+place:
```jsonc
// meta.common.json — package acme::common
@@ -243,11 +244,21 @@ Two rules the loader/codegen enforce:
inherit the shared set. If you need a **different** member set, declare a
separate `field.enum` with its own `@values` rather than extending the shared
one — extending is for *reuse* of one member set, not for overriding it.
-- **Inherited members resolve through any number of `extends` hops.** A projection
- field that extends an entity field which itself extends a shared abstract enum
- gets the shared members transitively — the projection still materializes its own
- per-projection enum type, populated from the inherited set (a projection is
- self-contained: its enum lives in the projection's own package).
+- **A field keeps the shared type through any number of `extends` hops.** A field
+ that extends another field which itself reaches the shared abstract enum has the
+ SAME generated type as a field that extends it directly, whatever object hosts it
+ (entity, projection, value object):
+
+ ```jsonc
+ { "field.enum": { "name": "status", "extends": "acme::common::RecordStatus" } } // on Order
+ { "field.enum": { "name": "current", "extends": "acme::orders::Order.status" } } // on OrderView: RecordStatus too
+ ```
+
+ The walk stops at the first field that declares its own `@values` or
+ `@intValueMap`: that field starts a new vocabulary, so it and everything extending
+ it get a per-object inline enum instead. A chain that never reaches a package-level
+ abstract enum is inline as well. Declaring the vocabulary at package level is how
+ you ask for one type.
## Embedded value objects — `field.object` + `@storage`
diff --git a/docs/features/migrations-and-drift.md b/docs/features/migrations-and-drift.md
index c4fdbe164..53c9cffb0 100644
--- a/docs/features/migrations-and-drift.md
+++ b/docs/features/migrations-and-drift.md
@@ -474,12 +474,13 @@ The following conformance fixtures gate this feature's behavior across ports:
**Template drift (`fixtures/verify-conformance/`)** — the `Renderer.verify` engine
asserts that every variable, section, partial, and required-tag in a template
-resolves against its declared payload. 31 fixtures, grouped:
+resolves against its declared payload. 37 fixtures, grouped:
- *Variables*: `verify-var-known-clean`, `verify-var-unknown`, `verify-var-implicit-iterator-clean`, `verify-var-unescaped-and-triple-unknown`
- *Dotted paths*: `verify-dotted-path-clean`, `verify-dotted-path-head-nonfield`, `verify-dotted-path-tail-nonfield`
- *Sections*: `verify-section-over-nonfield`, `verify-section-pushes-element-clean`, `verify-section-element-nonfield`, `verify-nested-sections-clean`, `verify-nested-section-nonfield`, `verify-parent-context-in-section-clean`, `verify-scalar-section-conditional-clean`, `verify-scalar-section-conditional-nonfield`
- *Inverted sections*: `verify-inverted-section-clean`, `verify-inverted-section-over-nonfield`
+- *Positional accessor (`-last`)*: `verify-positional-accessor-clean`, `verify-positional-accessor-transparent-sections-clean`, `verify-positional-accessor-outside-element`, `verify-positional-accessor-object-section`, `verify-positional-accessor-scalar-list`, `verify-positional-accessor-nested-scalar-list`
- *Partials*: `verify-partial-no-provider`, `verify-partial-unresolved`, `verify-partial-resolved-clean`, `verify-partial-resolved-drift`, `verify-partial-in-section-context`
- *Required slots / tags*: `verify-required-slot-used-clean`, `verify-required-slot-unused`, `verify-required-slot-via-section`, `verify-required-tags-present`, `verify-required-tag-in-partial`, `verify-required-tag-self-closing`, `verify-required-tag-missing-open`, `verify-required-tag-missing-close`, `verify-required-tag-prefix-no-overmatch`
diff --git a/docs/features/templates-and-payloads.md b/docs/features/templates-and-payloads.md
index f8b5b3fc7..c3f6f03b9 100644
--- a/docs/features/templates-and-payloads.md
+++ b/docs/features/templates-and-payloads.md
@@ -214,21 +214,36 @@ For the `lobby/welcome` template:
A template needs a separator between list items and a way to ask "is there anything here", and
neither needs a payload field the caller computes.
-- **Separator — `-first` / `-last`.** Every map-shaped element of a collection carries two
- derived booleans, so `{{#tags}}{{name}}{{^-last}}, {{/-last}}{{/tags}}` renders `a, b, c` with
- no `last` flag in the payload. `{{#-first}}…{{/-first}}` is the matching lead-in. The names
- start with `-`, which no field name can, and they are ordinary Mustache tag names, so the
- template is still valid Mustache and any Mustache tool parses it. An authored key of the same
- name wins. A nested list keeps its own position, independent of the list around it.
+- **Separator — `-last`.** Every object element of a collection carries a derived boolean, so
+ `{{#tags}}{{name}}{{^-last}}, {{/-last}}{{/tags}}` renders `a, b, c` with no `last` flag in
+ the payload. The name starts with `-`, which no field name can, and it is an ordinary Mustache
+ tag name, so the template is still valid Mustache and any Mustache tool parses it. An authored
+ member of the same name wins. A nested list keeps its own position, independent of the list
+ around it.
- **Emptiness — `has`.** `{{#hasTags}}…{{/hasTags}}` / `{{^hasTags}}…{{/hasTags}}` is
derived from the field `tags` (string: non-blank, collection: non-empty, object: present).
`{{#tags}}` over an empty list already renders nothing; `hasTags` is for the block that
surrounds the loop, a heading or a wrapper element.
-`meta verify --templates` accepts `-first` / `-last` inside a section over a container field and
-reports anything else as `ERR_VAR_NOT_ON_PAYLOAD`: at the top level, and inside a list of
-scalars, where an element has nowhere to carry a key (give the list an object element instead).
-Output of a template that uses neither is byte-for-byte what it was.
+Both work the same whatever shape the payload arrives in: a map or plain object, or the port's
+typed payload (a TypeScript class instance, a C# object with or without `[JsonPropertyName]`, a
+Java record or getter-based bean, a Kotlin data class, a Pydantic model). The same data renders
+the same bytes.
+
+**Where `-last` is available.** `meta verify --templates` accepts `-last` only when the
+innermost enclosing section over a LIST iterates objects. Anything else is reported as
+`ERR_VAR_NOT_ON_PAYLOAD`:
+
+| Where `-last` appears | Verdict |
+|---|---|
+| Inside `{{#posts}}`, a list of objects | accepted |
+| Inside a scalar conditional or a single nested object within that list (`{{#posts}}{{#note}}…{{/note}}{{/posts}}`) | accepted: those sections do not iterate, so the post's position is still the one in reach |
+| Inside `{{#labels}}`, a list of scalars | reported: a scalar element has nowhere to carry a key. Give the list an object element instead |
+| Inside a list of scalars nested in a list of objects (`{{#posts}}{{#labels}}…{{/labels}}{{/posts}}`) | reported: the lookup would fall through to the POST's position and separate the labels wrongly |
+| At the top level, or inside a section over a single object that no list encloses | reported |
+
+A template that verify accepts renders correctly; that is the rule the table follows. Output of a
+template that does not use `-last` is byte-for-byte what it was.
## What each port generates
@@ -701,7 +716,7 @@ for payload typing per #270)
- [`fixtures/render-conformance/render-empty-array-falsiness/`](../../fixtures/render-conformance/render-empty-array-falsiness/) — `{{#xs}}…{{/xs}}` over an empty array renders nothing (vs. iterates)
- [`fixtures/render-conformance/render-falsy-values/`](../../fixtures/render-conformance/render-falsy-values/) — `false`, `null`, empty string, `0` — which are truthy for `{{#x}}` sections (per Mustache spec, not JS truthiness)
- [`fixtures/render-conformance/render-inverted-section/`](../../fixtures/render-conformance/render-inverted-section/) — `{{^x}}…{{/x}}` renders when `x` is falsy/absent
-- [`fixtures/render-conformance/render-list-separator/`](../../fixtures/render-conformance/render-list-separator/) — `-first` / `-last` positional accessors: `{{^-last}}, {{/-last}}` separators, lead-in, nested lists, single-element and empty lists
+- [`fixtures/render-conformance/render-list-separator/`](../../fixtures/render-conformance/render-list-separator/) — the `-last` positional accessor: `{{^-last}}, {{/-last}}` separators, nested lists, single-element and empty lists, a scalar conditional and a single nested object inside a list, an authored key
- [`fixtures/render-conformance/render-nested-partials/`](../../fixtures/render-conformance/render-nested-partials/) — `{{>partial}}` resolves through the provider, supports nesting
- [`fixtures/render-conformance/render-standalone-tag-stripping/`](../../fixtures/render-conformance/render-standalone-tag-stripping/) — a line containing only a section/partial tag is removed (whitespace + newline)
- [`fixtures/render-conformance/render-raw-html-bypass/`](../../fixtures/render-conformance/render-raw-html-bypass/) — `{{{x}}}` (or `{{&x}}`) emits raw, unescaped under `@format=html`
diff --git a/examples/advanced-modeling/.metaobjects/.gen-state/.hashes.json b/examples/advanced-modeling/.metaobjects/.gen-state/.hashes.json
index 4746a34bd..06ad48809 100644
--- a/examples/advanced-modeling/.metaobjects/.gen-state/.hashes.json
+++ b/examples/advanced-modeling/.metaobjects/.gen-state/.hashes.json
@@ -15,7 +15,7 @@
"src/generated/ProgramDescriptionOutput.output.ts": "f13b7b2108bf8507b6aeb1e819edd06eb2d689ed259dc04784d19d62bee9dfd2",
"src/generated/ProgramDescriptionOutput.render.ts": "77691e7f4c7dd8658ad2842fb5ea68a4b79e5c5ec69bb7221c618db303f2eb9f",
"src/generated/ProgramDescriptionPayload.form.tsx": "a9dff101453450117aff3029b2c5259e7d0866667aaab722ba890beaaa20f95d",
- "src/generated/ProgramDescriptionPayload.ts": "18161f91b74c419c062763d21dc5c7395bcb7208ea5adb0661ea91545b12200c",
+ "src/generated/ProgramDescriptionPayload.ts": "8df741a3271c6e6e8317041dd088f6b5de5f0f2e95c1b637528992c578e487d7",
"src/generated/ProgramSummary.queries.ts": "743e8ce74eee85bb821cf3d909957fae630085fb1f5a7567dd6bfdcae4364910",
"src/generated/ProgramSummary.routes.ts": "995e893a6b21e65d7bc031761c18bb7bb0c790abb31cb72546bf95573c980099",
"src/generated/ProgramSummary.ts": "181f48ed374dfdf2f5b3213e0064833f071fd7029ed9357a6eef764616c2429c",
diff --git a/examples/advanced-modeling/src/generated/ProgramDescriptionPayload.ts b/examples/advanced-modeling/src/generated/ProgramDescriptionPayload.ts
index c2cb80d89..751abd467 100644
--- a/examples/advanced-modeling/src/generated/ProgramDescriptionPayload.ts
+++ b/examples/advanced-modeling/src/generated/ProgramDescriptionPayload.ts
@@ -10,38 +10,3 @@ export interface ProgramDescriptionPayload {
lessonCount?: number | undefined;
}
export const ProgramDescriptionPayloadInsertSchema = z.object({});
-/**
- * Metadata constants for the value object ProgramDescriptionPayload: each field's name, label, view and the
- * validation rules derived from its validator children (e.g. `ProgramDescriptionPayload..rules.maxLength.value`).
- */
-export const ProgramDescriptionPayload = {
- $entity: "ProgramDescriptionPayload",
- title: {
- name: "title",
- label: "Title",
- view: "text",
- htmlType: "text",
- rules: {
- required: "Title is required",
- maxLength: { value: 200, message: "Must be 200 characters or fewer" },
- },
- },
- summary: {
- name: "summary",
- label: "Summary",
- view: "text",
- htmlType: "text",
- },
- authorName: {
- name: "authorName",
- label: "Author Name",
- view: "text",
- htmlType: "text",
- },
- lessonCount: {
- name: "lessonCount",
- label: "Lesson Count",
- view: "number",
- htmlType: "number",
- },
-} as const;
diff --git a/fixtures/codegen-conformance/README.md b/fixtures/codegen-conformance/README.md
index a29f2803f..b86d565f5 100644
--- a/fixtures/codegen-conformance/README.md
+++ b/fixtures/codegen-conformance/README.md
@@ -37,6 +37,19 @@ closed the project's normal way: **adding new metamodel behavior ⇒ add a behav
exercises it** (`spec/conformance-tests.md`). No standing codegen-manifest corpus is needed; add a
persistence/api-contract fixture when one of those types/validators is touched.
+## `shared-provided-enum/` is a shared input with a decision table, not an output corpus
+
+`shared-provided-enum/` holds one metadata input every port's FR-019 test loads, and
+`expected-enum-types.json`, a port-neutral DECISION TABLE: for every object-hosted enum field
+in that input, the shared declaration its type resolves to (or `null`), whether that declaration
+is `@provided`, and the declarations that must be materialized. It records a RESOLUTION, never
+generated bytes, so each port stays free to emit its own idiom.
+
+It meets the bar this README sets. The incident: a two-hop enum field (`A.x extends B.y`, where
+`B.y extends` a shared abstract enum) generated a per-object copy of the enum in one port and
+the shared type in the others, and every behavior corpus stayed green, because both shapes
+store and validate the same members. The rule it gates is ADR-0026 Amendment 1.
+
## Do not delete this README / do not re-propose FR-007
Keep this file so the gap-that-isn't stays documented. If you believe a codegen corpus is warranted,
diff --git a/fixtures/codegen-conformance/shared-provided-enum/expected-enum-types.json b/fixtures/codegen-conformance/shared-provided-enum/expected-enum-types.json
new file mode 100644
index 000000000..22bf2201d
--- /dev/null
+++ b/fixtures/codegen-conformance/shared-provided-enum/expected-enum-types.json
@@ -0,0 +1,27 @@
+{
+ "fields": {
+ "acme::shop::Ticket.priority": { "shared": "acme::shop::Priority", "provided": false },
+ "acme::shop::Ticket.severity": { "shared": "acme::shop::Priority", "provided": false },
+ "acme::shop::Ticket.tier": { "shared": "acme::shop::Priority", "provided": false },
+ "acme::shop::Ticket.currency": { "shared": "acme::shop::Currency", "provided": true },
+ "acme::shop::Order.priority": { "shared": "acme::shop::Priority", "provided": false },
+ "acme::shop::Order.status": { "shared": null, "provided": false },
+ "acme::shop::Task.urgency": { "shared": "acme::shop::Priority", "provided": false },
+ "acme::shop::Task.importance": { "shared": "acme::shop::Priority", "provided": false },
+ "acme::shop::Task.phase": { "shared": null, "provided": false },
+ "acme::shop::Task.weight": { "shared": null, "provided": false },
+ "acme::shop::Subtask.rank": { "shared": "acme::shop::Priority", "provided": false },
+ "acme::shop::Subtask.phase": { "shared": null, "provided": false },
+ "acme::shop::Subtask.weight": { "shared": null, "provided": false },
+ "acme::shop::Invoice.money": { "shared": "acme::shop::Money", "provided": false },
+ "acme::shop::Invoice.tier": { "shared": "acme::shop::Money", "provided": false },
+ "acme::shop::Refund.unit": { "shared": "acme::shop::Money", "provided": false },
+ "acme::shop::Refund.tender": { "shared": "acme::shop::Currency", "provided": true },
+ "acme::shop::Refund.orderStatus": { "shared": null, "provided": false },
+ "acme::shop::Refund.ticketTier": { "shared": "acme::shop::Priority", "provided": false },
+ "acme::shop::Refund.invoiceTier": { "shared": "acme::shop::Money", "provided": false },
+ "acme::shop::TicketSummary.level": { "shared": "acme::shop::Priority", "provided": false },
+ "acme::shop::TicketBadge.badge": { "shared": "acme::shop::Priority", "provided": false }
+ },
+ "materialized": ["Money", "Priority"]
+}
diff --git a/fixtures/codegen-conformance/shared-provided-enum/input/meta.json b/fixtures/codegen-conformance/shared-provided-enum/input/meta.json
index fd6912743..029f5b1fa 100644
--- a/fixtures/codegen-conformance/shared-provided-enum/input/meta.json
+++ b/fixtures/codegen-conformance/shared-provided-enum/input/meta.json
@@ -6,29 +6,63 @@
{ "field.enum": { "name": "Currency", "abstract": true, "@provided": true, "@values": ["USD", "EUR", "GBP"] } },
{ "field.enum": { "name": "Money", "abstract": true, "extends": "Currency" } },
{ "object.entity": { "name": "Ticket", "children": [
- { "field.long": { "name": "id" } },
- { "field.enum": { "name": "priority", "extends": "Priority" } },
- { "field.enum": { "name": "currency", "extends": "Currency" } },
- { "source.rdb": { "@table": "tickets" } },
+ { "field.long": { "name": "id" } },
+ { "field.enum": { "name": "priority", "extends": "Priority" } },
+ { "field.enum": { "name": "severity", "extends": "Priority" } },
+ { "field.enum": { "name": "tier", "extends": "Priority" } },
+ { "field.enum": { "name": "currency", "extends": "Currency" } },
+ { "source.rdb": { "@table": "tickets" } },
{ "identity.primary": { "name": "pk", "@fields": ["id"] } }
] } },
{ "object.entity": { "name": "Order", "children": [
- { "field.long": { "name": "id" } },
- { "field.enum": { "name": "priority", "extends": "Priority" } },
- { "source.rdb": { "@table": "orders" } },
+ { "field.long": { "name": "id" } },
+ { "field.enum": { "name": "priority", "extends": "Priority" } },
+ { "field.enum": { "name": "status", "@values": ["NEW", "PAID"] } },
+ { "source.rdb": { "@table": "orders" } },
{ "identity.primary": { "name": "pk", "@fields": ["id"] } }
] } },
{ "object.entity": { "name": "Task", "children": [
- { "field.long": { "name": "id" } },
- { "field.enum": { "name": "urgency", "extends": "acme::shop::Ticket.priority" } },
- { "source.rdb": { "@table": "tasks" } },
+ { "field.long": { "name": "id" } },
+ { "field.enum": { "name": "urgency", "extends": "acme::shop::Ticket.priority" } },
+ { "field.enum": { "name": "importance", "extends": "acme::shop::Ticket.severity" } },
+ { "field.enum": { "name": "phase", "extends": "acme::shop::Ticket.tier", "@values": ["OPEN", "DONE"] } },
+ { "field.enum": { "name": "weight", "extends": "acme::shop::Ticket.severity", "@intValueMap": { "LOW": 1, "MEDIUM": 2, "HIGH": 3 } } },
+ { "source.rdb": { "@table": "tasks" } },
+ { "identity.primary": { "name": "pk", "@fields": ["id"] } }
+ ] } },
+ { "object.entity": { "name": "Subtask", "children": [
+ { "field.long": { "name": "id" } },
+ { "field.enum": { "name": "rank", "extends": "acme::shop::Task.importance" } },
+ { "field.enum": { "name": "phase", "extends": "acme::shop::Task.phase" } },
+ { "field.enum": { "name": "weight", "extends": "acme::shop::Task.weight" } },
+ { "source.rdb": { "@table": "subtasks" } },
{ "identity.primary": { "name": "pk", "@fields": ["id"] } }
] } },
{ "object.entity": { "name": "Invoice", "children": [
- { "field.long": { "name": "id" } },
- { "field.enum": { "name": "money", "extends": "Money" } },
- { "source.rdb": { "@table": "invoices" } },
+ { "field.long": { "name": "id" } },
+ { "field.enum": { "name": "money", "extends": "Money" } },
+ { "field.enum": { "name": "tier", "extends": "Money" } },
+ { "source.rdb": { "@table": "invoices" } },
+ { "identity.primary": { "name": "pk", "@fields": ["id"] } }
+ ] } },
+ { "object.entity": { "name": "Refund", "children": [
+ { "field.long": { "name": "id" } },
+ { "field.enum": { "name": "unit", "extends": "acme::shop::Invoice.money" } },
+ { "field.enum": { "name": "tender", "extends": "acme::shop::Ticket.currency" } },
+ { "field.enum": { "name": "orderStatus", "extends": "acme::shop::Order.status" } },
+ { "field.enum": { "name": "ticketTier", "extends": "acme::shop::Ticket.tier" } },
+ { "field.enum": { "name": "invoiceTier", "extends": "acme::shop::Invoice.tier" } },
+ { "source.rdb": { "@table": "refunds" } },
{ "identity.primary": { "name": "pk", "@fields": ["id"] } }
+ ] } },
+ { "object.projection": { "name": "TicketSummary", "children": [
+ { "source.rdb": { "@kind": "view", "@view": "v_ticket_summary" } },
+ { "field.long": { "name": "id", "extends": "acme::shop::Ticket.id" } },
+ { "field.enum": { "name": "level", "extends": "acme::shop::Ticket.severity" } },
+ { "identity.primary": { "name": "pk", "extends": "acme::shop::Ticket.pk" } }
+ ] } },
+ { "object.value": { "name": "TicketBadge", "children": [
+ { "field.enum": { "name": "badge", "extends": "acme::shop::Ticket.severity" } }
] } }
]
}
diff --git a/fixtures/render-conformance/render-list-separator/expected.txt b/fixtures/render-conformance/render-list-separator/expected.txt
index 4072c5c26..c786b4029 100644
--- a/fixtures/render-conformance/render-list-separator/expected.txt
+++ b/fixtures/render-conformance/render-list-separator/expected.txt
@@ -1,6 +1,7 @@
items: a, b, c
-lead: (first) a b c
solo: only
none: []
nested: [x|y];[z]
+conditional: a(x); bc(z)
+object: ann, bob, cy
authored: pq
diff --git a/fixtures/render-conformance/render-list-separator/meta.json b/fixtures/render-conformance/render-list-separator/meta.json
index d64d7499c..0fdd6d37f 100644
--- a/fixtures/render-conformance/render-list-separator/meta.json
+++ b/fixtures/render-conformance/render-list-separator/meta.json
@@ -1 +1 @@
-{ "format": "text", "note": "Positional -first/-last accessors on collection elements: separator via {{^-last}}, lead-in via {{#-first}}, nested lists keep their own position, single-element and empty lists, authored key wins" }
+{ "format": "text", "note": "Positional -last accessor on collection elements: separator via {{^-last}}, nested lists keep their own position, single-element and empty lists, a scalar conditional and a single nested object inside a list see the enclosing element's position, authored key wins" }
diff --git a/fixtures/render-conformance/render-list-separator/payload.json b/fixtures/render-conformance/render-list-separator/payload.json
index 1277589c1..4bf973660 100644
--- a/fixtures/render-conformance/render-list-separator/payload.json
+++ b/fixtures/render-conformance/render-list-separator/payload.json
@@ -6,5 +6,10 @@
{ "tags": [{ "label": "x" }, { "label": "y" }] },
{ "tags": [{ "label": "z" }] }
],
+ "entries": [
+ { "name": "a", "note": "x", "owner": { "nick": "ann" } },
+ { "name": "b", "owner": { "nick": "bob" } },
+ { "name": "c", "note": "z", "owner": { "nick": "cy" } }
+ ],
"authored": [{ "name": "p", "-last": true }, { "name": "q", "-last": true }]
}
diff --git a/fixtures/render-conformance/render-list-separator/template.mustache b/fixtures/render-conformance/render-list-separator/template.mustache
index 8c4981b30..a7d11096f 100644
--- a/fixtures/render-conformance/render-list-separator/template.mustache
+++ b/fixtures/render-conformance/render-list-separator/template.mustache
@@ -1,6 +1,7 @@
items: {{#items}}{{name}}{{^-last}}, {{/-last}}{{/items}}
-lead:{{#items}}{{#-first}} (first){{/-first}} {{name}}{{/items}}
solo: {{#solo}}{{name}}{{^-last}}, {{/-last}}{{/solo}}
none: [{{#none}}{{name}}{{^-last}}, {{/-last}}{{/none}}]
nested: {{#groups}}[{{#tags}}{{label}}{{^-last}}|{{/-last}}{{/tags}}]{{^-last}};{{/-last}}{{/groups}}
+conditional: {{#entries}}{{name}}{{#note}}({{note}}){{^-last}}; {{/-last}}{{/note}}{{/entries}}
+object: {{#entries}}{{#owner}}{{nick}}{{^-last}}, {{/-last}}{{/owner}}{{/entries}}
authored: {{#authored}}{{name}}{{^-last}}, {{/-last}}{{/authored}}
diff --git a/fixtures/verify-conformance/verify-positional-accessor-clean/payload.json b/fixtures/verify-conformance/verify-positional-accessor-clean/payload.json
index 15ef2afa5..ecf481bd5 100644
--- a/fixtures/verify-conformance/verify-positional-accessor-clean/payload.json
+++ b/fixtures/verify-conformance/verify-positional-accessor-clean/payload.json
@@ -1,8 +1,12 @@
[
{ "name": "displayName" },
- { "name": "posts", "fields": [
+ { "name": "posts", "isArray": true, "fields": [
{ "name": "title" },
- { "name": "tags", "fields": [{ "name": "name" }] }
+ { "name": "note" },
+ { "name": "author", "fields": [{ "name": "name" }] },
+ { "name": "tags", "isArray": true, "fields": [{ "name": "name" }] },
+ { "name": "labels", "isArray": true }
] },
- { "name": "labels" }
+ { "name": "owner", "fields": [{ "name": "name" }] },
+ { "name": "labels", "isArray": true }
]
diff --git a/fixtures/verify-conformance/verify-positional-accessor-clean/template.mustache b/fixtures/verify-conformance/verify-positional-accessor-clean/template.mustache
index f2b6e32e8..ab48fe2d2 100644
--- a/fixtures/verify-conformance/verify-positional-accessor-clean/template.mustache
+++ b/fixtures/verify-conformance/verify-positional-accessor-clean/template.mustache
@@ -1 +1 @@
-{{#posts}}{{title}}{{^-last}}, {{/-last}}{{#-first}}!{{/-first}}{{#tags}}{{name}}{{^-last}}|{{/-last}}{{/tags}}{{/posts}}
+{{#posts}}{{title}}{{^-last}}, {{/-last}}{{#tags}}{{name}}{{^-last}}|{{/-last}}{{/tags}}{{/posts}}
diff --git a/fixtures/verify-conformance/verify-positional-accessor-nested-scalar-list/expected-drift.json b/fixtures/verify-conformance/verify-positional-accessor-nested-scalar-list/expected-drift.json
new file mode 100644
index 000000000..b8eb25ba5
--- /dev/null
+++ b/fixtures/verify-conformance/verify-positional-accessor-nested-scalar-list/expected-drift.json
@@ -0,0 +1 @@
+[ { "code": "ERR_VAR_NOT_ON_PAYLOAD", "path": "-last" } ]
diff --git a/fixtures/verify-conformance/verify-positional-accessor-nested-scalar-list/payload.json b/fixtures/verify-conformance/verify-positional-accessor-nested-scalar-list/payload.json
new file mode 100644
index 000000000..ecf481bd5
--- /dev/null
+++ b/fixtures/verify-conformance/verify-positional-accessor-nested-scalar-list/payload.json
@@ -0,0 +1,12 @@
+[
+ { "name": "displayName" },
+ { "name": "posts", "isArray": true, "fields": [
+ { "name": "title" },
+ { "name": "note" },
+ { "name": "author", "fields": [{ "name": "name" }] },
+ { "name": "tags", "isArray": true, "fields": [{ "name": "name" }] },
+ { "name": "labels", "isArray": true }
+ ] },
+ { "name": "owner", "fields": [{ "name": "name" }] },
+ { "name": "labels", "isArray": true }
+]
diff --git a/fixtures/verify-conformance/verify-positional-accessor-nested-scalar-list/template.mustache b/fixtures/verify-conformance/verify-positional-accessor-nested-scalar-list/template.mustache
new file mode 100644
index 000000000..631dc3b47
--- /dev/null
+++ b/fixtures/verify-conformance/verify-positional-accessor-nested-scalar-list/template.mustache
@@ -0,0 +1 @@
+{{#posts}}{{title}}: {{#labels}}{{.}}{{^-last}}, {{/-last}}{{/labels}}{{/posts}}
diff --git a/fixtures/verify-conformance/verify-positional-accessor-object-section/expected-drift.json b/fixtures/verify-conformance/verify-positional-accessor-object-section/expected-drift.json
new file mode 100644
index 000000000..b8eb25ba5
--- /dev/null
+++ b/fixtures/verify-conformance/verify-positional-accessor-object-section/expected-drift.json
@@ -0,0 +1 @@
+[ { "code": "ERR_VAR_NOT_ON_PAYLOAD", "path": "-last" } ]
diff --git a/fixtures/verify-conformance/verify-positional-accessor-object-section/payload.json b/fixtures/verify-conformance/verify-positional-accessor-object-section/payload.json
new file mode 100644
index 000000000..ecf481bd5
--- /dev/null
+++ b/fixtures/verify-conformance/verify-positional-accessor-object-section/payload.json
@@ -0,0 +1,12 @@
+[
+ { "name": "displayName" },
+ { "name": "posts", "isArray": true, "fields": [
+ { "name": "title" },
+ { "name": "note" },
+ { "name": "author", "fields": [{ "name": "name" }] },
+ { "name": "tags", "isArray": true, "fields": [{ "name": "name" }] },
+ { "name": "labels", "isArray": true }
+ ] },
+ { "name": "owner", "fields": [{ "name": "name" }] },
+ { "name": "labels", "isArray": true }
+]
diff --git a/fixtures/verify-conformance/verify-positional-accessor-object-section/template.mustache b/fixtures/verify-conformance/verify-positional-accessor-object-section/template.mustache
new file mode 100644
index 000000000..2225bd4b1
--- /dev/null
+++ b/fixtures/verify-conformance/verify-positional-accessor-object-section/template.mustache
@@ -0,0 +1 @@
+{{#owner}}{{name}}{{^-last}}, {{/-last}}{{/owner}}
diff --git a/fixtures/verify-conformance/verify-positional-accessor-outside-element/payload.json b/fixtures/verify-conformance/verify-positional-accessor-outside-element/payload.json
index 15ef2afa5..ecf481bd5 100644
--- a/fixtures/verify-conformance/verify-positional-accessor-outside-element/payload.json
+++ b/fixtures/verify-conformance/verify-positional-accessor-outside-element/payload.json
@@ -1,8 +1,12 @@
[
{ "name": "displayName" },
- { "name": "posts", "fields": [
+ { "name": "posts", "isArray": true, "fields": [
{ "name": "title" },
- { "name": "tags", "fields": [{ "name": "name" }] }
+ { "name": "note" },
+ { "name": "author", "fields": [{ "name": "name" }] },
+ { "name": "tags", "isArray": true, "fields": [{ "name": "name" }] },
+ { "name": "labels", "isArray": true }
] },
- { "name": "labels" }
+ { "name": "owner", "fields": [{ "name": "name" }] },
+ { "name": "labels", "isArray": true }
]
diff --git a/fixtures/verify-conformance/verify-positional-accessor-scalar-list/payload.json b/fixtures/verify-conformance/verify-positional-accessor-scalar-list/payload.json
index 15ef2afa5..ecf481bd5 100644
--- a/fixtures/verify-conformance/verify-positional-accessor-scalar-list/payload.json
+++ b/fixtures/verify-conformance/verify-positional-accessor-scalar-list/payload.json
@@ -1,8 +1,12 @@
[
{ "name": "displayName" },
- { "name": "posts", "fields": [
+ { "name": "posts", "isArray": true, "fields": [
{ "name": "title" },
- { "name": "tags", "fields": [{ "name": "name" }] }
+ { "name": "note" },
+ { "name": "author", "fields": [{ "name": "name" }] },
+ { "name": "tags", "isArray": true, "fields": [{ "name": "name" }] },
+ { "name": "labels", "isArray": true }
] },
- { "name": "labels" }
+ { "name": "owner", "fields": [{ "name": "name" }] },
+ { "name": "labels", "isArray": true }
]
diff --git a/fixtures/verify-conformance/verify-positional-accessor-transparent-sections-clean/expected-drift.json b/fixtures/verify-conformance/verify-positional-accessor-transparent-sections-clean/expected-drift.json
new file mode 100644
index 000000000..fe51488c7
--- /dev/null
+++ b/fixtures/verify-conformance/verify-positional-accessor-transparent-sections-clean/expected-drift.json
@@ -0,0 +1 @@
+[]
diff --git a/fixtures/verify-conformance/verify-positional-accessor-transparent-sections-clean/payload.json b/fixtures/verify-conformance/verify-positional-accessor-transparent-sections-clean/payload.json
new file mode 100644
index 000000000..ecf481bd5
--- /dev/null
+++ b/fixtures/verify-conformance/verify-positional-accessor-transparent-sections-clean/payload.json
@@ -0,0 +1,12 @@
+[
+ { "name": "displayName" },
+ { "name": "posts", "isArray": true, "fields": [
+ { "name": "title" },
+ { "name": "note" },
+ { "name": "author", "fields": [{ "name": "name" }] },
+ { "name": "tags", "isArray": true, "fields": [{ "name": "name" }] },
+ { "name": "labels", "isArray": true }
+ ] },
+ { "name": "owner", "fields": [{ "name": "name" }] },
+ { "name": "labels", "isArray": true }
+]
diff --git a/fixtures/verify-conformance/verify-positional-accessor-transparent-sections-clean/template.mustache b/fixtures/verify-conformance/verify-positional-accessor-transparent-sections-clean/template.mustache
new file mode 100644
index 000000000..38152320a
--- /dev/null
+++ b/fixtures/verify-conformance/verify-positional-accessor-transparent-sections-clean/template.mustache
@@ -0,0 +1 @@
+{{#posts}}{{title}}{{#note}} ({{note}}){{^-last}}; {{/-last}}{{/note}}{{#author}} by {{name}}{{^-last}}, {{/-last}}{{/author}}{{/posts}}
diff --git a/server/csharp/MetaObjects.Codegen.Tests/Fr019SharedProvidedConformanceTests.cs b/server/csharp/MetaObjects.Codegen.Tests/Fr019SharedProvidedConformanceTests.cs
index 4f97fca66..15a90c6ce 100644
--- a/server/csharp/MetaObjects.Codegen.Tests/Fr019SharedProvidedConformanceTests.cs
+++ b/server/csharp/MetaObjects.Codegen.Tests/Fr019SharedProvidedConformanceTests.cs
@@ -1,10 +1,13 @@
+using System.Collections.Generic;
using System.IO;
using System.Linq;
+using System.Text.Json;
using System.Text.RegularExpressions;
using MetaObjects.Codegen;
using MetaObjects.Codegen.Generators;
using MetaObjects.Loader;
using MetaObjects.Meta;
+using static MetaObjects.Core.Field.FieldConstants;
using Xunit;
namespace MetaObjects.Codegen.Tests;
@@ -64,11 +67,19 @@ public void Shared_abstract_enum_is_materialized_once_and_referenced_by_both_ent
[Fact]
public void Multi_hop_enum_extends_still_resolves_to_the_shared_enum()
{
- // Task.urgency extends Ticket.priority, which extends the shared abstract Priority.
+ // Task.urgency extends Ticket.priority, which extends the shared abstract Priority
+ // (two hops); Task.importance extends Ticket.severity, same chain. Task ALSO carries
+ // Task.phase/Task.weight, which declare their own @values/@intValueMap and so keep
+ // their own per-object nested enum (rule (b)) — the fixture now exercises both in one
+ // object, so the assertion below is scoped to the SHARED type, not "any nested enum".
var files = new EntityGenerator().Generate(Ctx(LoadFixture(), providedNs: "Acme.External")).ToList();
var task = Assert.Single(files, f => f.Path == "Task.g.cs");
- Assert.DoesNotContain("public enum", task.Content);
+ Assert.DoesNotContain("public enum Priority", task.Content);
Assert.Contains("public Priority? Urgency", task.Content);
+ Assert.Contains("public Priority? Importance", task.Content);
+ // The negatives: own @values / own @intValueMap stop resolution, so these stay inline.
+ Assert.Contains("public enum TaskPhase", task.Content);
+ Assert.Contains("public enum TaskWeight", task.Content);
}
[Fact]
@@ -90,4 +101,131 @@ public void Provided_enum_with_no_namespace_config_is_a_codegen_error_naming_the
new EntityGenerator().Generate(Ctx(LoadFixture(), providedNs: null)).ToList());
Assert.Contains("Currency", ex.Message);
}
+
+ // ── Decision table (#246/FR-019 chain rule) ──
+ //
+ // For every object-hosted field.enum in the fixture, compute the SAME decision the
+ // generators use — Fr019SharedEnum.ResolveSharedEnumDecl (the resolved declaration, or
+ // null for inline) + Fr019SharedEnum.SharedEnumForField (its @provided-ness) — keyed
+ // `::.`, and assert exact equality (no missing key, no extra
+ // key) against the shared fixture's expected-enum-types.json. The chain rule is a
+ // walk-order-sensitive decision (nearest shared declaration wins; an own @values/
+ // @intValueMap partway up stops it), so a table beats one assertion per case.
+
+ private static JsonDocument LoadExpectedEnumTypes()
+ {
+ var path = Path.Combine(
+ LocateRepoRoot(), "fixtures", "codegen-conformance", "shared-provided-enum", "expected-enum-types.json");
+ return JsonDocument.Parse(File.ReadAllText(path));
+ }
+
+ private static (string? Shared, bool Provided) Decide(MetaField field)
+ {
+ var decl = Fr019SharedEnum.ResolveSharedEnumDecl(field);
+ var provided = decl is not null && (Fr019SharedEnum.SharedEnumForField(field)?.Provided ?? false);
+ return (decl?.ResolutionKey(), provided);
+ }
+
+ [Fact]
+ public void Decision_table_matches_the_shared_fixture_exactly()
+ {
+ var root = LoadFixture();
+ using var expected = LoadExpectedEnumTypes();
+ var expectedFields = expected.RootElement.GetProperty("fields");
+
+ var actual = new Dictionary(System.StringComparer.Ordinal);
+ foreach (var obj in root.Objects())
+ foreach (var field in obj.Fields())
+ {
+ if (field.SubType != FIELD_SUBTYPE_ENUM) continue;
+ actual[$"{obj.ResolutionKey()}.{field.Name}"] = Decide(field);
+ }
+
+ var expectedKeys = new HashSet(
+ expectedFields.EnumerateObject().Select(p => p.Name), System.StringComparer.Ordinal);
+ var missing = expectedKeys.Except(actual.Keys).ToList();
+ var extra = actual.Keys.Except(expectedKeys).ToList();
+ Assert.Empty(missing);
+ Assert.Empty(extra);
+
+ foreach (var prop in expectedFields.EnumerateObject())
+ {
+ var sharedEl = prop.Value.GetProperty("shared");
+ string? expectedShared = sharedEl.ValueKind == JsonValueKind.Null ? null : sharedEl.GetString();
+ var expectedProvided = prop.Value.GetProperty("provided").GetBoolean();
+ var (actualShared, actualProvided) = actual[prop.Name];
+ Assert.Equal(expectedShared, actualShared);
+ Assert.Equal(expectedProvided, actualProvided);
+ }
+
+ var expectedMaterialized = expected.RootElement.GetProperty("materialized")
+ .EnumerateArray().Select(e => e.GetString()!).ToList();
+ var actualMaterialized = Fr019SharedEnum.MaterializedSharedEnums(root)
+ .Select(e => e.Name).OrderBy(n => n, System.StringComparer.Ordinal).ToList();
+ Assert.Equal(expectedMaterialized, actualMaterialized);
+ }
+
+ [Fact]
+ public void Generated_output_covers_every_multi_hop_and_negative_case_in_the_decision_table()
+ {
+ var files = new EntityGenerator().Generate(Ctx(LoadFixture(), providedNs: "Acme.External")).ToList();
+
+ // Priority (and its sibling chained declaration Money) are each emitted exactly
+ // once, no matter how many fields across how many hops consume them.
+ foreach (var name in new[] { "Priority", "Money" })
+ {
+ var decls = files.Sum(f => Regex.Matches(f.Content, $@"public enum {name}\b").Count);
+ Assert.Equal(1, decls);
+ }
+
+ // Multi-hop consumers reference the shared Priority type and nest nothing.
+ var task = Assert.Single(files, f => f.Path == "Task.g.cs");
+ Assert.Contains("public Priority? Importance", task.Content); // Task.importance -> Ticket.severity -> Priority
+
+ var subtask = Assert.Single(files, f => f.Path == "Subtask.g.cs");
+ Assert.Contains("public Priority? Rank", subtask.Content); // Subtask.rank: three hops to Priority
+ Assert.DoesNotContain("public enum Priority", subtask.Content);
+
+ var ticketSummary = Assert.Single(files, f => f.Path == "TicketSummary.g.cs"); // projection, view source
+ Assert.Contains("public Priority? Level", ticketSummary.Content);
+ Assert.DoesNotContain("public enum", ticketSummary.Content);
+
+ var ticketBadge = Assert.Single(files, f => f.Path == "TicketBadge.g.cs"); // value object
+ Assert.Contains("public Priority? Badge", ticketBadge.Content);
+ Assert.DoesNotContain("public enum", ticketBadge.Content);
+
+ // Refund: two fields whose immediate supers are same-named fields of different
+ // objects, resolving to two different shared declarations.
+ var refund = Assert.Single(files, f => f.Path == "Refund.g.cs");
+ Assert.Contains("public Priority? TicketTier", refund.Content); // Refund.ticketTier -> Ticket.tier -> Priority
+ Assert.Contains("public Money? InvoiceTier", refund.Content); // Refund.invoiceTier -> Invoice.tier -> Money
+ Assert.Contains("public Money? Unit", refund.Content); // Refund.unit -> Invoice.money -> Money (materialized)
+ Assert.Contains("public Acme.External.Currency? Tender", refund.Content); // @provided, never materialized
+ Assert.DoesNotContain("public enum Currency", refund.Content);
+
+ // Negatives: an own @values / own @intValueMap partway up the chain stops sharing,
+ // so these keep their per-object inline nested enum.
+ Assert.Contains("public enum TaskPhase", task.Content);
+ Assert.Contains("public enum TaskWeight", task.Content);
+ Assert.Contains("public enum SubtaskPhase", subtask.Content);
+ Assert.Contains("public enum SubtaskWeight", subtask.Content);
+ Assert.Contains("public enum RefundOrderStatus", refund.Content);
+ }
+
+ [Fact]
+ public void A_run_narrowed_to_only_the_projection_still_materializes_the_shared_enum_it_consumes()
+ {
+ var root = LoadFixture();
+ var narrowed = root.Objects().Where(o => o.Name == "TicketSummary").ToList();
+ var ctx = new GenContext
+ {
+ Entities = narrowed,
+ Root = root,
+ Config = new GenConfig { OutDir = "/tmp", Namespace = "Acme.Gen", ProvidedEnumNamespace = "Acme.External" },
+ };
+
+ var files = new EntityGenerator().Generate(ctx).ToList();
+ var enums = Assert.Single(files, f => f.Path == "Enums.g.cs");
+ Assert.Contains("public enum Priority { LOW, MEDIUM, HIGH }", enums.Content);
+ }
}
diff --git a/server/csharp/MetaObjects.Codegen/Generators/Fr019SharedEnum.cs b/server/csharp/MetaObjects.Codegen/Generators/Fr019SharedEnum.cs
index 26f038b3b..7325f6bce 100644
--- a/server/csharp/MetaObjects.Codegen/Generators/Fr019SharedEnum.cs
+++ b/server/csharp/MetaObjects.Codegen/Generators/Fr019SharedEnum.cs
@@ -22,6 +22,7 @@
// enum. Shared + @provided are additive branches gated on "root-level abstract
// field.enum", so the inline default output is byte-identical (the drift gate).
+using MetaObjects.Core.Field;
using MetaObjects.Meta;
using MetaObjects.Shared;
using static MetaObjects.Core.Field.FieldConstants;
@@ -42,36 +43,24 @@ public sealed record SharedEnum(string Name, IReadOnlyList Values, bool
public static class Fr019SharedEnum
{
///
- /// The root-level abstract field.enum a concrete enum field resolves to via
- /// extends , or when the field is an inline enum (no
- /// root-abstract super). The nearest super in the chain that is (a) an abstract enum field, AND
- /// (b) a direct child of the metadata root (a package-level declaration) — NOT an
- /// abstract field nested inside another object.
+ /// The shared enum declaration a concrete enum field resolves to via extends , or
+ /// when the field is an inline enum. Delegates the walk to
+ /// — the single
+ /// cross-port/loader authority (#246 / FR-019) — so naming and materialization can never
+ /// drift from what the loader's conflict check agrees is "shared".
///
- public static MetaField? ResolveSharedEnumDecl(MetaField field)
- {
- if (field.SubType != FIELD_SUBTYPE_ENUM) return null;
- // Walk the extends chain: the NEAREST root-level abstract enum wins, so a field that
- // extends a concrete (or nested) enum field which itself extends the shared declaration
- // still resolves to it. The seen-set guards a malformed cyclic chain.
- var seen = new HashSet(ReferenceEqualityComparer.Instance) { field };
- var sup = field.ResolveSuper();
- while (sup is not null && seen.Add(sup))
- {
- if (sup.SubType != FIELD_SUBTYPE_ENUM) return null;
- // The declaration must sit at the package/root level (sibling of object.entity),
- // i.e. its parent node is the metadata root.
- if (sup.IsAbstract && sup.Parent is { } parent && parent.Type == BaseTypes.TYPE_METADATA) return sup;
- sup = sup.ResolveSuper();
- }
- return null;
- }
+ public static MetaField? ResolveSharedEnumDecl(MetaField field) =>
+ SharedEnumDeclaration.ResolveSharedEnumDeclaration(field);
/// The shared-enum descriptor a field resolves to, or null for inline enums.
public static SharedEnum? SharedEnumForField(MetaField field)
{
if (ResolveSharedEnumDecl(field) is not { } decl) return null;
- if (field.EffectiveEnumValues is not { Count: > 0 } values) return null;
+ // Read the member set from the DECLARATION, not the consuming field: every field
+ // sharing this declaration must agree on its materialized shape, so the read must be
+ // anchored at the one node the shape is owned by rather than wherever the walk
+ // happened to start — output cannot depend on which consumer codegen visits first.
+ if (decl.EffectiveEnumValues is not { Count: > 0 } values) return null;
return new SharedEnum(
Name: CSharpNaming.Pascal(decl.Name),
Values: values,
diff --git a/server/csharp/MetaObjects.Codegen/Generators/RenderHelperGenerator.cs b/server/csharp/MetaObjects.Codegen/Generators/RenderHelperGenerator.cs
index fc31a0e6e..07843efb1 100644
--- a/server/csharp/MetaObjects.Codegen/Generators/RenderHelperGenerator.cs
+++ b/server/csharp/MetaObjects.Codegen/Generators/RenderHelperGenerator.cs
@@ -286,6 +286,11 @@ private static IReadOnlyList DerivePayloadFieldTree(
var fields = new List();
foreach (var f in vo.Children().Where(c => c.Type == TYPE_FIELD))
{
+ // ADR-0039: ResolvedIsArray() — the effective array-ness — is the one fact Verify's
+ // positional-accessor rule needs to tell an array-of-objects/array-of-scalars
+ // section from a single-object/scalar one (both carry child Fields for the object
+ // case; only IsArray tells them apart).
+ var isArray = f.ResolvedIsArray();
if (f.SubType == FIELD_SUBTYPE_OBJECT &&
// ADR-0039: resolving — @objectRef may be inherited via extends (TS reads f.attr).
f.Attr(FIELD_ATTR_OBJECT_REF) is string refName)
@@ -300,11 +305,11 @@ private static IReadOnlyList DerivePayloadFieldTree(
if (target is not null && target.SubType == OBJECT_SUBTYPE_VALUE)
{
var children = DerivePayloadFieldTree(root, target, new HashSet(seen, StringComparer.Ordinal));
- fields.Add(new PayloadField(f.Name, children));
+ fields.Add(new PayloadField(f.Name, children, isArray));
continue;
}
}
- fields.Add(new PayloadField(f.Name));
+ fields.Add(new PayloadField(f.Name, IsArray: isArray));
}
return fields;
}
@@ -329,7 +334,9 @@ private static IReadOnlyList DerivePayloadFieldTree(
/// Emit a IReadOnlyList<PayloadField> as a deterministic C# array
/// literal: new PayloadField[] { new PayloadField("name"),
/// new PayloadField("nested", new PayloadField[] { ... }) } . An empty tree
- /// emits new global::MetaObjects.Render.PayloadField[] { } .
+ /// emits new global::MetaObjects.Render.PayloadField[] { } . The array flag is
+ /// emitted ONLY when true (IsArray: true ), so a helper with no array field in
+ /// its payload stays byte-identical to before this flag existed.
///
private static string FieldTreeLiteral(IReadOnlyList fields)
{
@@ -338,10 +345,11 @@ private static string FieldTreeLiteral(IReadOnlyList fields)
{
if (i > 0) sb.Append(", ");
var f = fields[i];
+ var arrayArg = f.IsArray ? ", IsArray: true" : "";
if (f.Fields is not null)
- sb.Append($"new global::MetaObjects.Render.PayloadField({Quote(f.Name)}, {FieldTreeLiteral(f.Fields)})");
+ sb.Append($"new global::MetaObjects.Render.PayloadField({Quote(f.Name)}, {FieldTreeLiteral(f.Fields)}{arrayArg})");
else
- sb.Append($"new global::MetaObjects.Render.PayloadField({Quote(f.Name)})");
+ sb.Append($"new global::MetaObjects.Render.PayloadField({Quote(f.Name)}{arrayArg})");
}
sb.Append(" }");
return sb.ToString();
diff --git a/server/csharp/MetaObjects.Codegen/PayloadFieldTree.cs b/server/csharp/MetaObjects.Codegen/PayloadFieldTree.cs
index 4f7a99cf5..b0ebbcfe1 100644
--- a/server/csharp/MetaObjects.Codegen/PayloadFieldTree.cs
+++ b/server/csharp/MetaObjects.Codegen/PayloadFieldTree.cs
@@ -56,6 +56,11 @@ private static IReadOnlyList BuildTree(MetaData root, string voNam
var fields = new List();
foreach (var f in vo.Children().Where(c => c.Type == TYPE_FIELD))
{
+ // ADR-0039: ResolvedIsArray() — the EFFECTIVE (inherited-via-extends) array-ness,
+ // never the own flag — is what Verify's positional-accessor rule needs to tell an
+ // array-of-scalars (no `-last`) from a single scalar, and an array-of-objects from
+ // a single nested object (both carry child Fields; only IsArray disambiguates).
+ var isArray = f.ResolvedIsArray();
// ADR-0039: resolving — @objectRef may be inherited via extends (TS reads f.attr).
if (f.SubType == FIELD_SUBTYPE_OBJECT && f.Attr(FIELD_ATTR_OBJECT_REF) is string refName)
{
@@ -65,10 +70,10 @@ private static IReadOnlyList BuildTree(MetaData root, string voNam
var fieldPkg = f.Parent is not null
? global::MetaObjects.NamingRefs.EffectivePackage(f.Parent)
: voPkg;
- fields.Add(new PayloadField(f.Name, BuildTree(root, refName, fieldPkg, visiting)));
+ fields.Add(new PayloadField(f.Name, BuildTree(root, refName, fieldPkg, visiting), isArray));
}
else
- fields.Add(new PayloadField(f.Name));
+ fields.Add(new PayloadField(f.Name, IsArray: isArray));
}
visiting.Remove(vo.ResolutionKey());
return fields;
diff --git a/server/csharp/MetaObjects.Render.Tests/TypedPayloadTests.cs b/server/csharp/MetaObjects.Render.Tests/TypedPayloadTests.cs
new file mode 100644
index 000000000..062d45b3d
--- /dev/null
+++ b/server/csharp/MetaObjects.Render.Tests/TypedPayloadTests.cs
@@ -0,0 +1,115 @@
+using System.Text.Json.Serialization;
+using Xunit;
+
+namespace MetaObjects.Render.Tests;
+
+///
+/// A typed payload must render byte-identically to the equal map payload: -last and
+/// has<Field> come from one mechanism, whatever the payload's shape. The oracle is
+/// the shared render-list-separator fixture, whose expected.txt is shared by every port.
+///
+public class TypedPayloadTests
+{
+ // Generated-POCO shape: PascalCase members carrying the metadata field name as a wire name.
+ private sealed class WireItem { [JsonPropertyName("name")] public string Name { get; set; } = ""; }
+ private sealed class WireTag { [JsonPropertyName("label")] public string Label { get; set; } = ""; }
+ private sealed class WireGroup { [JsonPropertyName("tags")] public List Tags { get; set; } = new(); }
+ private sealed class WireOwner { [JsonPropertyName("nick")] public string Nick { get; set; } = ""; }
+ private sealed class WireEntry
+ {
+ [JsonPropertyName("name")] public string Name { get; set; } = "";
+ [JsonPropertyName("note")] public string? Note { get; set; }
+ [JsonPropertyName("owner")] public WireOwner Owner { get; set; } = new();
+ }
+ private sealed class WirePayload
+ {
+ [JsonPropertyName("items")] public List Items { get; set; } = new();
+ [JsonPropertyName("solo")] public List Solo { get; set; } = new();
+ [JsonPropertyName("none")] public List None { get; set; } = new();
+ [JsonPropertyName("groups")] public List Groups { get; set; } = new();
+ [JsonPropertyName("entries")] public List Entries { get; set; } = new();
+ [JsonPropertyName("authored")] public List> Authored { get; set; } = new();
+ }
+
+ // Plain shape: members ARE the field names, no attributes.
+ private sealed record PlainItem(string name);
+ private sealed record PlainTag(string label);
+ private sealed record PlainGroup(List tags);
+ private sealed record PlainOwner(string nick);
+ private sealed record PlainEntry(string name, string? note, PlainOwner owner);
+ private sealed record PlainPayload(
+ List items, List solo, List none,
+ List groups, List entries, List> authored);
+
+ private static string FixtureDir()
+ {
+ string root = AppContext.BaseDirectory;
+ while (!Directory.Exists(Path.Combine(root, "fixtures", "render-conformance")))
+ root = Directory.GetParent(root)?.FullName ?? throw new InvalidOperationException("fixtures not found");
+ return Path.Combine(root, "fixtures", "render-conformance", "render-list-separator");
+ }
+
+ private static string Render(string template, object? payload) =>
+ Renderer.Render(new RenderRequest { Template = template, Payload = payload, Format = "text", Provider = new InMemoryProvider(new Dictionary()) });
+
+ private static Dictionary Authored(string name) =>
+ new() { ["name"] = name, ["-last"] = true };
+
+ [Fact]
+ public void WireNamedPayloadRendersTheSharedFixtureByteIdentically()
+ {
+ var payload = new WirePayload
+ {
+ Items = { new() { Name = "a" }, new() { Name = "b" }, new() { Name = "c" } },
+ Solo = { new() { Name = "only" } },
+ Groups =
+ {
+ new() { Tags = { new() { Label = "x" }, new() { Label = "y" } } },
+ new() { Tags = { new() { Label = "z" } } },
+ },
+ Entries =
+ {
+ new() { Name = "a", Note = "x", Owner = new() { Nick = "ann" } },
+ new() { Name = "b", Owner = new() { Nick = "bob" } },
+ new() { Name = "c", Note = "z", Owner = new() { Nick = "cy" } },
+ },
+ Authored = { Authored("p"), Authored("q") },
+ };
+ var template = File.ReadAllText(Path.Combine(FixtureDir(), "template.mustache"));
+ var expected = File.ReadAllText(Path.Combine(FixtureDir(), "expected.txt"));
+ Assert.Equal(expected, Render(template, payload));
+ }
+
+ [Fact]
+ public void PlainMemberPayloadRendersTheSharedFixtureByteIdentically()
+ {
+ var payload = new PlainPayload(
+ [new("a"), new("b"), new("c")], [new("only")], [],
+ [new([new("x"), new("y")]), new([new("z")])],
+ [new("a", "x", new("ann")), new("b", null, new("bob")), new("c", "z", new("cy"))],
+ [Authored("p"), Authored("q")]);
+ var template = File.ReadAllText(Path.Combine(FixtureDir(), "template.mustache"));
+ var expected = File.ReadAllText(Path.Combine(FixtureDir(), "expected.txt"));
+ Assert.Equal(expected, Render(template, payload));
+ }
+
+ [Fact]
+ public void HasFieldGateWorksOnTypedElementsLikeOnMaps()
+ {
+ const string template = "{{#items}}{{name}}:{{#hasNote}}y{{/hasNote}}{{^hasNote}}n{{/hasNote}} {{/items}}";
+ var typed = new List
+ {
+ new() { Name = "a", Note = "x" },
+ new() { Name = "b", Note = " " },
+ };
+ var maps = new List>
+ {
+ new() { ["name"] = "a", ["note"] = "x" },
+ new() { ["name"] = "b", ["note"] = " " },
+ };
+ var viaTyped = Render(template, new Dictionary { ["items"] = typed });
+ var viaMaps = Render(template, new Dictionary { ["items"] = maps });
+ Assert.Equal("a:y b:n ", viaMaps);
+ Assert.Equal(viaMaps, viaTyped);
+ }
+}
diff --git a/server/csharp/MetaObjects.Render.Tests/VerifyConformanceTests.cs b/server/csharp/MetaObjects.Render.Tests/VerifyConformanceTests.cs
index 2c03fcd62..22a0f04d2 100644
--- a/server/csharp/MetaObjects.Render.Tests/VerifyConformanceTests.cs
+++ b/server/csharp/MetaObjects.Render.Tests/VerifyConformanceTests.cs
@@ -68,9 +68,10 @@ private static IReadOnlyList ParseFields(JsonElement array) =>
private static PayloadField ParseField(JsonElement el)
{
var name = el.GetProperty("name").GetString()!;
+ var isArray = el.TryGetProperty("isArray", out var ia) && ia.ValueKind == JsonValueKind.True;
return el.TryGetProperty("fields", out var fields) && fields.ValueKind == JsonValueKind.Array
- ? new PayloadField(name, ParseFields(fields))
- : new PayloadField(name);
+ ? new PayloadField(name, ParseFields(fields), isArray)
+ : new PayloadField(name, IsArray: isArray);
}
// (code, path) ordinal order, so the comparison is a sorted multiset independent of
diff --git a/server/csharp/MetaObjects.Render.Tests/VerifyTests.cs b/server/csharp/MetaObjects.Render.Tests/VerifyTests.cs
index 94ac87023..fbef95046 100644
--- a/server/csharp/MetaObjects.Render.Tests/VerifyTests.cs
+++ b/server/csharp/MetaObjects.Render.Tests/VerifyTests.cs
@@ -18,8 +18,8 @@ public class VerifyTests
new PayloadField("posts",
[
new PayloadField("title"),
- new PayloadField("tags", [new PayloadField("name")]),
- ]),
+ new PayloadField("tags", [new PayloadField("name")], IsArray: true),
+ ], IsArray: true),
];
private static List Codes(string text, IReadOnlyList fields, VerifyOptions? o = null) =>
diff --git a/server/csharp/MetaObjects.Render/PayloadAccessors.cs b/server/csharp/MetaObjects.Render/PayloadAccessors.cs
index 7b08b12c7..021979b91 100644
--- a/server/csharp/MetaObjects.Render/PayloadAccessors.cs
+++ b/server/csharp/MetaObjects.Render/PayloadAccessors.cs
@@ -34,21 +34,18 @@ public static class PayloadAccessors
public static string HasAccessorName(string fieldName) => HasPrefix + Capitalize(fieldName);
///
- /// Positional accessors injected into every map-shaped ELEMENT of a collection, so a list
- /// separator needs no hand-computed last flag:
+ /// The positional accessor injected into every map-shaped ELEMENT of a collection, so a
+ /// list separator needs no hand-computed last flag:
/// {{#items}}{{name}}{{^-last}}, {{/-last}}{{/items}} . The leading - cannot
/// collide with a field name and is still an ordinary Mustache tag name, so the template
/// stays valid Mustache. A scalar element has nowhere to hold a key and carries none; an
/// authored key of the same name wins, as for has<Field> .
///
- public const string FirstAccessor = "-first";
-
- ///
public const string LastAccessor = "-last";
- /// True when is -first /-last and the scope is a collection element.
+ /// True when is -last and the scope is a collection element.
public static bool IsPositionalAccessor(bool inElement, string name) =>
- inElement && (name == FirstAccessor || name == LastAccessor);
+ inElement && name == LastAccessor;
/// Capitalize the first character, leaving an already-uppercase one untouched.
public static string Capitalize(string s)
@@ -142,46 +139,50 @@ public static bool IsBooleanAccessor(List> stack, st
for (int i = 0; i < outList.Count; i++)
{
if (outList[i] is not Dictionary el) continue;
- el.TryAdd(FirstAccessor, i == 0);
el.TryAdd(LastAccessor, i == outList.Count - 1);
}
return outList;
}
- // ADR-0056 — a generated value-object POCO. Its members are PascalCase C# properties
- // that carry the metadata field name in [JsonPropertyName], while a template names the
- // FIELD (`{{bio}}`). So the POCO is viewed as the map of its wire names, which also gives
- // it the derived has accessors a map payload gets. Only a type that declares wire
- // names is viewed this way: a record or anonymous object whose members ARE the field
- // names keeps resolving by reflection, exactly as before.
- if (WireNamedProperties(payload.GetType()) is { } props)
+ // ADR-0056 — a typed payload (a generated value-object POCO, a record, an anonymous
+ // object). A template names the FIELD (`{{bio}}`), and a generated POCO carries that
+ // name in [JsonPropertyName] on a PascalCase member, so a typed object is viewed as the
+ // map of its readable public members keyed by wire name (or the member's own name when
+ // it declares none — exactly the name reflection lookup resolved before). The view is
+ // what carries the derived has and -last accessors, so a typed payload renders
+ // byte-identically to the equal map payload. A scalar-like value (number, enum, date,
+ // Guid, JsonElement, ...) is never viewed: its string form is its own.
+ if (IsScalarLike(payload.GetType())) return payload;
{
var asMap = new Dictionary(StringComparer.Ordinal);
- foreach (var (name, prop) in props) asMap[name] = prop.GetValue(payload);
+ foreach (var (name, read) in MemberReaders(payload.GetType())) asMap.TryAdd(name, read(payload));
return WithDerivedAccessors(asMap, depth);
}
-
- return payload;
}
- private static readonly System.Collections.Concurrent.ConcurrentDictionary
- WireNameCache = new();
+ private static bool IsScalarLike(Type t) =>
+ t.IsPrimitive || t.IsEnum || t.Namespace is "System" || t.Namespace?.StartsWith("System.", StringComparison.Ordinal) == true;
+
+ private static readonly System.Collections.Concurrent.ConcurrentDictionary)[]>
+ MemberCache = new();
///
- /// The readable public properties of , each keyed by its
- /// [JsonPropertyName] (or its own name when it has none) — or null when no property
- /// declares a wire name, which leaves the type to Mustache's reflection lookup.
+ /// The readable public instance members of , each keyed by its
+ /// [JsonPropertyName] (or its own name when it has none). Cached per type.
///
- private static (string, System.Reflection.PropertyInfo)[]? WireNamedProperties(Type type) =>
- WireNameCache.GetOrAdd(type, static t =>
+ private static (string, Func)[] MemberReaders(Type type) =>
+ MemberCache.GetOrAdd(type, static t =>
{
- var props = t.GetProperties(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance)
- .Where(p => p.CanRead && p.GetIndexParameters().Length == 0)
- .ToArray();
- string? WireName(System.Reflection.PropertyInfo p) =>
- (p.GetCustomAttributes(typeof(System.Text.Json.Serialization.JsonPropertyNameAttribute), inherit: true)
- .FirstOrDefault() as System.Text.Json.Serialization.JsonPropertyNameAttribute)?.Name;
- if (!props.Any(p => WireName(p) is not null)) return null;
- return props.Select(p => (WireName(p) ?? p.Name, p)).ToArray();
+ const System.Reflection.BindingFlags Flags =
+ System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance;
+ string WireName(System.Reflection.MemberInfo m) =>
+ (m.GetCustomAttributes(typeof(System.Text.Json.Serialization.JsonPropertyNameAttribute), inherit: true)
+ .FirstOrDefault() as System.Text.Json.Serialization.JsonPropertyNameAttribute)?.Name ?? m.Name;
+ var readers = new List<(string, Func)>();
+ foreach (var p in t.GetProperties(Flags).Where(p => p.CanRead && p.GetIndexParameters().Length == 0))
+ readers.Add((WireName(p), o => p.GetValue(o)));
+ foreach (var f in t.GetFields(Flags))
+ readers.Add((WireName(f), o => f.GetValue(o)));
+ return readers.ToArray();
});
}
diff --git a/server/csharp/MetaObjects.Render/Verify.cs b/server/csharp/MetaObjects.Render/Verify.cs
index b28fa58b9..ce225b24d 100644
--- a/server/csharp/MetaObjects.Render/Verify.cs
+++ b/server/csharp/MetaObjects.Render/Verify.cs
@@ -17,8 +17,11 @@ namespace MetaObjects.Render;
/// A plain field-tree node mirroring an object.value view-object's field
/// walk. present → a context-pushing field (object /
/// array-of-object); null → a scalar (string/number/boolean/scalar-array).
+/// is the field's EFFECTIVE (resolving) array-ness — the one fact
+/// Verify needs to tell a single nested object's conditional {{#x}} apart
+/// from an array-of-objects' iterating {{#x}} , since both carry .
///
-public sealed record PayloadField(string Name, IReadOnlyList? Fields = null);
+public sealed record PayloadField(string Name, IReadOnlyList? Fields = null, bool IsArray = false);
/// A single drift finding: a code + the offending var path / partial ref / slot.
public sealed record VerifyError(string Code, string Path);
@@ -175,7 +178,7 @@ void Walk(IReadOnlyList tokens, List> stack, Li
var field = Resolve(stack, s.Value);
if (field is null)
{
- // A positional `-first` / `-last` gate is a BOOLEAN on the current
+ // A positional `-last` gate is a BOOLEAN on the current
// collection element: it pushes nothing, so the body walks in the SAME scope.
if (PayloadAccessors.IsPositionalAccessor(inElement, s.Value))
{
@@ -198,12 +201,21 @@ void Walk(IReadOnlyList tokens, List> stack, Li
// `#` over a container pushes its element fields; `^` (and `#`
// over a scalar, used as a conditional) keep the current context.
bool push = !s.Inverted && field.Fields is not null;
+ // The ONE positional-accessor rule (#304-class hardening): `-last` is
+ // available in the body only when the INNERMOST enclosing ARRAY section
+ // iterates OBJECT elements. A non-inverted section over an array field
+ // sets the flag fresh — true for array-of-objects (Fields present), false
+ // for array-of-scalars (no per-element Fields, so no `-last` even though
+ // an outer object array still encloses it). Any other section (scalar
+ // conditional, single nested object, `^`-inverted) is not an array
+ // iteration at all, so it inherits the current scope's flag unchanged.
+ bool childInElement = !s.Inverted && field.IsArray ? field.Fields is not null : inElement;
if (push)
{
var next = new List>(stack) { field.Fields! };
- Walk(s.Children, next, seen, true);
+ Walk(s.Children, next, seen, childInElement);
}
- else Walk(s.Children, stack, seen, inElement);
+ else Walk(s.Children, stack, seen, childInElement);
break;
case PartialTok p:
diff --git a/server/csharp/MetaObjects/Core/Field/SharedEnumDeclaration.cs b/server/csharp/MetaObjects/Core/Field/SharedEnumDeclaration.cs
new file mode 100644
index 000000000..e9f873d40
--- /dev/null
+++ b/server/csharp/MetaObjects/Core/Field/SharedEnumDeclaration.cs
@@ -0,0 +1,66 @@
+// Shared-enum resolution — the ONE authority for "what field.enum declaration does this
+// field's vocabulary come from?" (#246 / FR-019). Lives in the core metadata assembly so
+// both the loader's #246 validator and every codegen port's enum naming/materialization
+// call the SAME predicate and the SAME walk; neither may keep its own copy.
+//
+// THE RULE. A "shared enum declaration" is a field.enum that is abstract AND a direct
+// child of the metadata root. To resolve a field: start at the field itself and walk its
+// extends chain; at each node N, in this order:
+// (a) N is a shared enum declaration -> the answer is N (nearest wins: a chained root
+// abstract such as `Money extends Currency` answers Money, not Currency).
+// (b) N declares its OWN @values or its OWN @intValueMap -> the answer is NONE (N starts
+// a new vocabulary; the field keeps its per-object inline enum type).
+// (c) otherwise continue to N's super.
+// A non-enum node, the end of the chain, or a cycle answers NONE. There is no condition on
+// the hosting object's subtype (entity, projection, value object all qualify) and no hop
+// limit.
+
+using MetaObjects.Meta;
+using static MetaObjects.Core.Field.FieldConstants;
+
+namespace MetaObjects.Core.Field;
+
+///
+/// The single cross-port authority for shared (#246 / FR-019) field.enum resolution.
+/// The loader's ERR_ENUM_EXTENDS_VALUES_CONFLICT validator and every codegen port's enum
+/// naming/materialization call THIS predicate/walk — never a local re-derivation.
+///
+public static class SharedEnumDeclaration
+{
+ ///
+ /// True when is a shared enum DECLARATION: an abstract
+ /// field.enum that is a direct child of the metadata root (a package-level
+ /// declaration, not one nested under an object). Used both for the #246 validator's
+ /// immediate-super check and as the stopping condition inside
+ /// 's chain walk.
+ ///
+ public static bool IsSharedEnumDeclaration(MetaField node) =>
+ node.SubType == FIELD_SUBTYPE_ENUM
+ && node.IsAbstract
+ && node.Parent is { } parent
+ && parent.Type == TYPE_METADATA;
+
+ ///
+ /// Resolves to the shared enum declaration its vocabulary
+ /// comes from, or when it has none (an inline enum: no reachable
+ /// shared declaration, or a node along the way starts its own vocabulary — see rule (b)
+ /// on the type). Walks the extends chain starting at the field itself.
+ ///
+ public static MetaField? ResolveSharedEnumDeclaration(MetaField field)
+ {
+ if (field.SubType != FIELD_SUBTYPE_ENUM) return null;
+
+ var seen = new HashSet(ReferenceEqualityComparer.Instance);
+ MetaField? node = field;
+ while (node is not null && node.SubType == FIELD_SUBTYPE_ENUM && seen.Add(node))
+ {
+ if (IsSharedEnumDeclaration(node)) return node;
+ // Rule (b): THIS node owning @values or @intValueMap starts a new vocabulary —
+ // own-only reads, matching the #246 validator's own-attrs-only content rules.
+ if (node.OwnAttr(FIELD_ATTR_VALUES) is not null) return null;
+ if (node.OwnAttr(FIELD_ATTR_INT_VALUE_MAP) is not null) return null;
+ node = node.ResolveSuper();
+ }
+ return null;
+ }
+}
diff --git a/server/csharp/MetaObjects/Loader/ValidationPasses.cs b/server/csharp/MetaObjects/Loader/ValidationPasses.cs
index 032427cae..08f636d74 100644
--- a/server/csharp/MetaObjects/Loader/ValidationPasses.cs
+++ b/server/csharp/MetaObjects/Loader/ValidationPasses.cs
@@ -10,6 +10,7 @@
using System.Text.RegularExpressions;
using MetaObjects.Core.Attr;
+using MetaObjects.Core.Field;
using MetaObjects.Core.Relationship;
using MetaObjects.Core.Requirement;
using MetaObjects.Meta;
@@ -2559,21 +2560,19 @@ public static IReadOnlyList ValidateEnumValues(MetaData root)
///
/// The shared-enum super of , or null when it has none.
- /// "Shared" (FR-019 / #246) means the immediate super is abstract AND declared at
- /// metadata-root — a metadata.root child, not one nested under an object. Such a
- /// declaration is materialized ONCE per port as a single named type, so anything a
- /// consuming field re-declares that belongs to the shared TYPE's contract (its
- /// @values member set, or the @intValueMap integer backing of that set) is a conflict.
- /// Immediate-super-only, matching codegen's Fr019SharedEnum.ResolveSharedEnumDecl so
- /// the validator and the shared-enum collapse agree on what "shared" means.
+ /// "Shared" (FR-019 / #246) means the IMMEDIATE super is a shared enum declaration per
+ /// — the single cross-port
+ /// authority also used by every codegen port's naming/materialization walk
+ /// ( ), so this check and
+ /// that walk can never disagree about what "shared" means. Such a declaration is
+ /// materialized ONCE per port as a single named type, so anything a consuming field
+ /// re-declares that belongs to the shared TYPE's contract (its @values member set, or
+ /// the @intValueMap integer backing of that set) is a conflict — checked immediate-super-only
+ /// because that is the one hop a field can own a conflicting redeclaration against; a
+ /// mid-chain redeclaration further up the chain is a different field's own concern.
///
- private static MetaData? SharedEnumSuper(MetaField field)
- {
- var sup = field.SuperData;
- return sup is not null && sup.IsAbstract && sup.Parent is { } p && p.Type == TYPE_METADATA
- ? sup
- : null;
- }
+ private static MetaField? SharedEnumSuper(MetaField field) =>
+ field.ResolveSuper() is { } sup && SharedEnumDeclaration.IsSharedEnumDeclaration(sup) ? sup : null;
private static void WalkEnumValues(MetaData node, List errors)
{
diff --git a/server/java/codegen-base/src/main/java/com/metaobjects/generator/verify/TemplateVerify.java b/server/java/codegen-base/src/main/java/com/metaobjects/generator/verify/TemplateVerify.java
index 2e4e38543..690068363 100644
--- a/server/java/codegen-base/src/main/java/com/metaobjects/generator/verify/TemplateVerify.java
+++ b/server/java/codegen-base/src/main/java/com/metaobjects/generator/verify/TemplateVerify.java
@@ -189,16 +189,19 @@ private static List derivePayloadFieldTree(
seen.add(vo.getName());
List fields = new ArrayList<>();
for (MetaField> field : vo.getMetaFields()) {
+ // ADR-0039: isArrayType() is the EFFECTIVE (inherited-via-extends) array-ness; the
+ // own-only isArray() flag would read false for a field that inherits @isArray.
+ boolean isArray = field.isArrayType();
if (field instanceof ObjectField of) {
MetaObject target = resolveNestedObjectRef(loader, of);
if (target != null && MetaObject.SUBTYPE_VALUE.equals(target.getSubType())) {
List children =
derivePayloadFieldTree(loader, target, new LinkedHashSet<>(seen));
- fields.add(PayloadField.object(field.getName(), children));
+ fields.add(PayloadField.object(field.getName(), children, isArray));
continue;
}
}
- fields.add(PayloadField.scalar(field.getName()));
+ fields.add(PayloadField.scalar(field.getName(), isArray));
}
return fields;
}
diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/Fr019SharedEnum.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/Fr019SharedEnum.kt
index 6e098b657..851398d44 100644
--- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/Fr019SharedEnum.kt
+++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/Fr019SharedEnum.kt
@@ -1,10 +1,8 @@
package com.metaobjects.generator.kotlin
-import com.metaobjects.MetaRoot
import com.metaobjects.field.EnumField
import com.metaobjects.field.MetaField
import com.metaobjects.generator.GeneratorException
-import com.metaobjects.generator.util.GeneratorUtil
import com.squareup.kotlinpoet.ClassName
/**
@@ -13,11 +11,13 @@ import com.squareup.kotlinpoet.ClassName
*
* Kotlin already materializes a package-level abstract `field.enum` ONCE as a standalone
* `enum class` ([KotlinEnumEmitter] + [KotlinTypeMapper.enumTypeName], deduped by FQN), so the
- * shared-materialize half of FR-019 is already satisfied. This object adds the `@provided` arm:
- * detecting a `@provided` shared enum (skip materialization) and resolving its reference to a
- * per-port-configured namespace (ADR-0001 — never an FQN in metadata). The namespace binds to the
- * enum's declaring metadata package via a package→namespace map + a single fallback, both
- * generator args.
+ * shared-materialize half of FR-019 is already satisfied — [KotlinTypeMapper.enumTypeName] and
+ * [resolveSharedEnumDecl] below both delegate to the SAME cross-generator authority,
+ * `EnumField.resolveSharedEnumDeclaration` (metadata module), so the two can never disagree. This
+ * object adds the `@provided` arm: detecting a `@provided` shared enum (skip materialization) and
+ * resolving its reference to a per-port-configured namespace (ADR-0001 — never an FQN in
+ * metadata). The namespace binds to the enum's declaring metadata package via a
+ * package→namespace map + a single fallback, both generator args.
*/
object Fr019SharedEnum {
@@ -48,18 +48,16 @@ object Fr019SharedEnum {
}
/**
- * The package-level abstract `field.enum` a concrete enum field resolves to via `extends`, or
- * `null` for an inline enum. Qualifies only when the immediate super is (a) an [EnumField],
- * (b) abstract, AND (c) a direct child of the metadata root ([MetaRoot]) — a sibling of
- * `object.entity`, not an abstract field nested inside an object.
+ * The shared-enum declaration [field] resolves to via `extends`, or `null` when the field is
+ * an inline enum. Delegates entirely to the single cross-generator authority,
+ * `EnumField.resolveSharedEnumDeclaration` (metadata module) — the SAME function
+ * [KotlinTypeMapper.enumTypeName], the Java/Spring port, and the loader's #246 validator call,
+ * so no two of them can disagree about what "shared" means. See that method's Javadoc for the
+ * full rule (nearest-declaration-wins, an own-`@values`/`@intValueMap` node stops the walk, no
+ * hop limit, no hosting-object-subtype condition).
*/
- fun resolveSharedEnumDecl(field: MetaField<*>): EnumField? {
- if (field !is EnumField) return null
- val sup = field.superField as? EnumField ?: return null
- if (!GeneratorUtil.isAbstract(sup)) return null
- if (sup.parent !is MetaRoot) return null
- return sup
- }
+ fun resolveSharedEnumDecl(field: MetaField<*>): EnumField? =
+ EnumField.resolveSharedEnumDeclaration(field) as? EnumField
/**
* Read `@provided` on an enum declaration. ADR-0039: `@provided` is a
diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinEnumEmitter.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinEnumEmitter.kt
index ec21d2520..ea7c8452b 100644
--- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinEnumEmitter.kt
+++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinEnumEmitter.kt
@@ -40,7 +40,12 @@ object KotlinEnumEmitter {
): Boolean {
val enumClassName = KotlinTypeMapper.enumTypeName(field, owner) ?: return false
if (!emittedFqns.add(enumClassName.canonicalName)) return false // dedupe by enum FQN
- val members = readEnumValues(field)
+ // Read members from the DECLARATION (FR-019: EnumField.resolveSharedEnumDeclaration — the
+ // same authority enumTypeName just resolved), never from whichever consuming field is
+ // visited first, so output cannot depend on visit order. For an inline field (no reachable
+ // declaration) the field itself IS the declaration-equivalent — its own resolving read.
+ val declaration = EnumField.resolveSharedEnumDeclaration(field) as? EnumField ?: field
+ val members = readEnumValues(declaration)
if (members.isNullOrEmpty()) {
// #259: a field.enum must resolve to a NON-EMPTY @values set — the loader's ValidationPhase
// guarantees it (a root enum owns @values or fails ERR_MISSING_REQUIRED_ATTR; a field with a
diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinRenderHelperGenerator.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinRenderHelperGenerator.kt
index 8445b9ba1..f0b9db157 100644
--- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinRenderHelperGenerator.kt
+++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinRenderHelperGenerator.kt
@@ -252,6 +252,9 @@ open class KotlinRenderHelperGenerator : MultiFileDirectGeneratorBase()
for (field in vo.metaFields) {
+ // ADR-0039: isArrayType() is the EFFECTIVE (inherited-via-extends) array-ness; the
+ // own-only isArray flag would read false for a field that inherits @isArray.
+ val isArray = field.isArrayType
if (field is ObjectField) {
// Resolve the nested @objectRef by BARE short-name — the cross-port
// render-helper consensus. Do NOT use the package-FOLDING resolver here;
@@ -259,11 +262,11 @@ open class KotlinRenderHelperGenerator : MultiFileDirectGeneratorBase}, in the same Kotlin package as the
+ * fall through to the generic [kotlinTypeName] mapping).
+ *
+ * Shared-enum naming (FR-019 / ADR-0026): when [field]'s `extends` chain reaches a
+ * package-level ABSTRACT {@code field.enum} that is a direct child of the metadata root — a
+ * shared-enum DECLARATION, resolved by the single cross-generator authority
+ * [EnumField.resolveSharedEnumDeclaration] (metadata module; the SAME function the Java/Spring
+ * port and the loader's #246 validator use) — the type is named for THAT declaration and
+ * collapses every field reaching it onto ONE class, however many hops away (a projection or
+ * value-object field included: #259's per-object remedy is superseded — see that method's
+ * Javadoc for the full rule, including the own-{@code @values}/{@code @intValueMap}
+ * short-circuit). With no reachable declaration the field keeps its per-object
+ * {@code } naming, in the same Kotlin package as the
* entity (derived from the entity's metadata FQN via [PackageMapping.splitFqn]).
*
- * Entity-prefixing prevents collisions across entities (e.g., {@code Player.status} →
- * {@code PlayerStatus}; {@code Game.status} → {@code GameStatus}). When [entity] is
- * null (e.g., bare-mapper unit tests) the enum class is rendered with no package
- * prefix and no entity-name prefix — only the field name pascalised — so the helper
- * still produces a useful ClassName for documentation / debug output. Generators that
+ * Entity-prefixing (the inline case) prevents collisions across entities (e.g.,
+ * {@code Player.status} → {@code PlayerStatus}; {@code Game.status} → {@code GameStatus}).
+ * When [entity] is null (e.g., bare-mapper unit tests) the inline-case enum class is rendered
+ * with no package prefix and no entity-name prefix — only the field name pascalised — so the
+ * helper still produces a useful ClassName for documentation / debug output. Generators that
* actually emit code always pass the owning entity.
*/
fun enumTypeName(field: MetaField<*>, entity: MetaObject?): ClassName? {
if (field !is EnumField) return null
- // Shared-enum naming: when this field `extends` a package-level ABSTRACT enum super (e.g.
- // two fields both `extends: Priority`), ALL such fields collapse onto ONE enum class named
- // for the top-most super (`Priority`) so the generated type is shared (and deduped by FQN at
- // emission). When there is no super, fall back to the per-entity ``
- // naming. Mirrors the C#/TS/Python ports.
- //
- // The collapse decision keys on the DIRECT (immediate) super, NOT the top-most root: collapse
- // onto a SHARED enum type ONLY when the field's IMMEDIATE `extends` target is a package-level
- // abstract enum — identified by having NO declaring object. A field whose direct super is a
- // CONCRETE entity/projection field (e.g. a read-model projection `extends`-ing `ActiveNpc.status`)
- // does NOT collapse — even when that entity field itself `extends` a shared abstract enum (#259,
- // the two-hop case): it falls through to the per-object naming below, so the projection gets its
- // OWN `` enum in its OWN package (self-contained — no cross-package
- // reference), populated with the values it inherits via `extends` ([KotlinEnumEmitter.readEnumValues]
- // is inheritance-aware across ANY number of hops). Keying on the top-most root instead (an earlier
- // bug) walked PAST the concrete entity super to the shared abstract root and wrongly collapsed the
- // projection onto the shared type, so its per-projection enum was never emitted. Without any guard
- // at all, a concrete-super field named the enum from the super's bare short name (`status`), which
- // `splitFqn` collapsed to a root-package `Status` — colliding across every entity with a `status`.
- val immediateSuper = field.superField
- if (immediateSuper != null && runCatching { immediateSuper.declaringObject }.getOrNull() == null) {
- // The DIRECT super is a package-level abstract enum → collapse onto the shared type, named
- // for THAT declaration — the immediate super, NOT the top-most root of an abstract chain.
- //
- // Naming by the top-most root (the previous behaviour) contradicted this file's own FR-019
- // arm, which resolves the shared declaration from the IMMEDIATE super
- // ([Fr019SharedEnum.resolveSharedEnumDecl]) exactly as TS/C#/Java/Python do. On a CHAINED
- // declaration (root abstract `Money extends` root abstract `@provided Currency`) the two
- // halves disagreed: the materialize-vs-reference decision saw `Money` (own @provided absent
- // → materialize) while the NAME collapsed to `Currency`, so Kotlin emitted a local
- // `Currency.kt` that collided with the `com.acme.ext.Currency` reference emitted for fields
- // extending `Currency` directly.
- //
- // Per ADR-0026 §2 a materialized type is named for ITS OWN declaration, so a chain yields one
- // type per declaration, each carrying the member set it inherits ([KotlinEnumEmitter
- // .readEnumValues] is inheritance-aware across any number of hops). #246 guarantees a chained
- // declaration can never MUTATE the vocabulary it inherits — it may rename it, nothing more.
- //
- // Non-chained output is byte-identical: with no further super the root walk returned the
- // immediate super anyway. The #259 two-hop projection guard is the `declaringObject == null`
- // condition above, which is evaluated FIRST and is unaffected.
- val (superPkg, superShort) = PackageMapping.splitFqn(immediateSuper.name)
- return ClassName(superPkg, superShort.replaceFirstChar { it.uppercase() })
+ val declaration = EnumField.resolveSharedEnumDeclaration(field)
+ if (declaration != null) {
+ val (declPkg, declShort) = PackageMapping.splitFqn(declaration.name)
+ return ClassName(declPkg, declShort.replaceFirstChar { it.uppercase() })
}
val fieldPascal = field.name.replaceFirstChar { it.uppercase() }
diff --git a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinFr019SharedProvidedConformanceTest.kt b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinFr019SharedProvidedConformanceTest.kt
index 9d7a36982..3d9144220 100644
--- a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinFr019SharedProvidedConformanceTest.kt
+++ b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinFr019SharedProvidedConformanceTest.kt
@@ -1,12 +1,17 @@
package com.metaobjects.generator.kotlin
+import com.fasterxml.jackson.databind.ObjectMapper
+import com.metaobjects.field.EnumField
+import com.metaobjects.field.MetaField
import com.metaobjects.metadata.ktx.loadString
import com.metaobjects.loader.MetaDataLoader
+import com.metaobjects.`object`.MetaObject
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import java.nio.file.Files
import java.nio.file.Path
import kotlin.io.path.readText
+import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
@@ -119,4 +124,145 @@ class KotlinFr019SharedProvidedConformanceTest {
assertTrue(ex.message?.contains("Currency") == true,
"error must name the provided enum Currency; was: ${ex.message}")
}
+
+ // =========================================================================
+ // Decision table — one shared-enum rule for BOTH JVM generators
+ // =========================================================================
+
+ private fun readExpectedEnumTypes(): com.fasterxml.jackson.databind.JsonNode {
+ val path = Path.of(System.getProperty("user.dir")).resolve("../../..").normalize()
+ .resolve("fixtures/codegen-conformance/shared-provided-enum/expected-enum-types.json")
+ assertTrue(Files.exists(path), "expected-enum-types.json must exist at $path")
+ return ObjectMapper().readTree(Files.readString(path))
+ }
+
+ /** `::.` -> `{"shared": |null, "provided": bool}`,
+ * computed via [Fr019SharedEnum.resolveSharedEnumDecl] for every object-hosted `field.enum`
+ * on every concrete entity/projection/value object — no condition on the hosting object's
+ * subtype. The SAME function [KotlinTypeMapper.enumTypeName] calls for naming. */
+ private fun computeDecisionTable(loader: MetaDataLoader): Map> {
+ val out = LinkedHashMap>()
+ for (owner in loader.metaObjects) {
+ val subType = owner.subType
+ if (subType != MetaObject.SUBTYPE_ENTITY && subType != MetaObject.SUBTYPE_PROJECTION
+ && subType != MetaObject.SUBTYPE_VALUE) continue
+ for (field in owner.metaFields) {
+ if (field !is EnumField) continue
+ val key = "${owner.name}.${field.name}"
+ val decl = Fr019SharedEnum.resolveSharedEnumDecl(field)
+ out[key] = if (decl == null) {
+ mapOf("shared" to null, "provided" to false)
+ } else {
+ mapOf("shared" to decl.name, "provided" to Fr019SharedEnum.isProvided(decl))
+ }
+ }
+ }
+ return out
+ }
+
+ @Test
+ fun `decision table matches shared fixture expectation`() {
+ val loader = loadFixture()
+ val actual = computeDecisionTable(loader)
+ val expected = readExpectedEnumTypes()
+
+ val expectedFields = expected.get("fields")
+ val expectedKeys = expectedFields.fieldNames().asSequence().toList()
+ for (key in expectedKeys) {
+ assertTrue(actual.containsKey(key), "decision table missing key $key; actual keys: ${actual.keys}")
+ val expectedEntry = expectedFields.get(key)
+ val expectedShared = if (expectedEntry.get("shared").isNull) null else expectedEntry.get("shared").asText()
+ val actualEntry = actual.getValue(key)
+ assertEquals(expectedShared, actualEntry["shared"], "mismatched 'shared' for $key")
+ assertEquals(expectedEntry.get("provided").asBoolean(), actualEntry["provided"], "mismatched 'provided' for $key")
+ }
+ assertEquals(expectedKeys.size, actual.size, "decision table has extra keys beyond the fixture; actual: ${actual.keys}")
+
+ val expectedMaterialized = expected.get("materialized").map { it.asText() }.sorted()
+ val out = Files.createTempDirectory("kgen-fr019-decision-")
+ try {
+ KotlinEntityGenerator().apply { setArgs(mapOf("outputDir" to out.toString(), "providedEnumNamespace" to "com.acme.ext")) }
+ .execute(loadFixture())
+ val actualMaterialized = expectedMaterialized.filter { name ->
+ Files.exists(out.resolve("acme/shop/$name.kt"))
+ }.sorted()
+ assertEquals(expectedMaterialized, actualMaterialized,
+ "materialized shared enums must match the fixture")
+ // And nothing ELSE claiming to be a shared declaration got materialized either —
+ // the @provided Currency must never be written.
+ assertFalse(Files.exists(out.resolve("acme/shop/Currency.kt")),
+ "@provided Currency must never be materialized")
+ } finally {
+ out.toFile().deleteRecursively()
+ }
+ }
+
+ // =========================================================================
+ // Generated-output assertions — the multi-hop / provided / negative cases
+ // =========================================================================
+
+ @Test
+ fun `multi-hop projection and value-object fields reference the shared type`() {
+ val out = generate("com.acme.ext")
+ try {
+ // Task.importance (two hops) and Subtask.rank (three hops) both reach Priority.
+ // Multi-hop fields reference the ONE shared Priority — on an entity (Task.importance,
+ // two hops; Subtask.rank, three hops), a projection (TicketSummary.level) and a value
+ // object (TicketBadge.badge) alike — and no per-object enum is written for any of them.
+ val shop = out.resolve("acme/shop")
+ assertTrue(Files.exists(shop.resolve("Priority.kt")), "shared Priority.kt must be emitted")
+ for (stale in listOf("TaskImportance", "TaskUrgency", "SubtaskRank", "TicketSummaryLevel", "TicketBadgeBadge"))
+ assertFalse(Files.exists(shop.resolve("$stale.kt")),
+ "$stale.kt must NOT be materialized — the field references the shared type")
+ val task = shop.resolve("Task.kt").readText()
+ assertTrue("val importance: Priority?" in task, "Task.importance must be typed Priority; saw:\n$task")
+ val subtask = shop.resolve("Subtask.kt").readText()
+ assertTrue("val rank: Priority" in subtask, "Subtask.rank must be typed Priority; saw:\n$subtask")
+ for (name in listOf("TicketSummary.kt", "TicketBadge.kt")) {
+ val f = shop.resolve(name)
+ if (Files.exists(f)) assertTrue("Priority" in f.readText(), "$name must reference Priority; saw:\n${f.readText()}")
+ }
+ } finally {
+ out.toFile().deleteRecursively()
+ }
+ }
+
+ @Test
+ fun `refund fields resolve to distinct shared declarations on the same object`() {
+ val out = generate("com.acme.ext")
+ try {
+ val refund = out.resolve("acme/shop/Refund.kt").readText()
+ assertTrue("ticketTier" in refund && "invoiceTier" in refund && "unit" in refund
+ && "tender" in refund && "orderStatus" in refund,
+ "Refund.kt must declare all five fields; saw:\n$refund")
+ assertTrue("com.acme.ext.Currency" in refund,
+ "Refund.tender must reference the configured external namespace; saw:\n$refund")
+ } finally {
+ out.toFile().deleteRecursively()
+ }
+ }
+
+ @Test
+ fun `own values or intValueMap stops the walk and keeps an inline per-object enum`() {
+ val out = generate("com.acme.ext")
+ try {
+ // An inline enum is its own per-object file (.kt) the data class types its
+ // property by, and no shared type stands in for it.
+ fun assertInline(owner: String, field: String, property: String) {
+ val type = owner + field.replaceFirstChar { it.uppercase() }
+ val file = out.resolve("acme/shop/$type.kt")
+ assertTrue(Files.exists(file), "$owner.$field must keep its inline enum file $type.kt")
+ assertTrue("enum class $type" in file.readText(), "$type.kt must declare enum class $type")
+ val src = out.resolve("acme/shop/$owner.kt").readText()
+ assertTrue("val $property: $type" in src, "$owner.$field must be typed $type; saw:\n$src")
+ }
+ assertInline("Task", "phase", "phase")
+ assertInline("Task", "weight", "weight")
+ assertInline("Subtask", "phase", "phase")
+ assertInline("Subtask", "weight", "weight")
+ assertInline("Refund", "orderStatus", "orderStatus")
+ } finally {
+ out.toFile().deleteRecursively()
+ }
+ }
}
diff --git a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinProjectionTwoHopEnumTest.kt b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinProjectionTwoHopEnumTest.kt
index 8a42d6e6e..be1411d84 100644
--- a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinProjectionTwoHopEnumTest.kt
+++ b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinProjectionTwoHopEnumTest.kt
@@ -13,24 +13,18 @@ import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
- * #259 — a `field.enum` that inherits its `@values` through TWO `extends` hops
- * (projection.status → entity.status → shared-abstract enum) must still generate the
- * projection's OWN per-projection enum, populated with the transitively-inherited members.
+ * #259 / FR-019 — a `field.enum` that inherits its `@values` through TWO `extends` hops
+ * (projection.status → entity.status → shared-abstract enum) resolves to the shared
+ * declaration, the SAME cross-generator authority every other hop count resolves through
+ * ([EnumField.resolveSharedEnumDeclaration], metadata module).
*
- * Sibling of #246 (same shared-enum feature area). The one-hop case
- * ([KotlinProjectionExtendsInheritanceTest]) already worked because the entity field carried
- * its own inline `@values`. The two-hop case regressed: [KotlinTypeMapper.enumTypeName]
- * decided "collapse onto the shared enum" from the TOP-MOST super (its private
- * `resolveSuperRoot`), so a projection field walked PAST its concrete entity super to the root
- * abstract enum (no declaring object) and wrongly collapsed to the shared type — the
- * per-projection enum was never emitted, and every consumer of that column failed to resolve
- * the (now-absent) per-projection type. Values themselves were never the problem:
- * `getMetaAttr(@values)` is inheritance-aware across multiple hops.
- *
- * The fix keys the collapse decision on the IMMEDIATE super: only a field whose direct
- * `extends` target is a package-level abstract enum (no declaring object) collapses onto the
- * shared type. A field whose direct super is a CONCRETE entity/projection field gets its own
- * `` enum, populated with the depth-2-inherited members.
+ * This SUPERSEDES the original #259 remedy, which gave the projection its OWN per-projection
+ * `ProgramViewStatus` enum so the two-hop case would not silently emit nothing. That worked
+ * around the real defect instead of fixing it: a projection reaching a shared declaration is
+ * exactly like an entity reaching one, and must reference the SAME materialized type — not a
+ * second, differently-populated copy. The one-hop case ([KotlinProjectionExtendsInheritanceTest])
+ * already demonstrated this is unproblematic when the chain is shallow; the two-hop case must
+ * behave identically, just with one more hop walked.
*/
@OptIn(org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi::class)
class KotlinProjectionTwoHopEnumTest {
@@ -56,6 +50,20 @@ class KotlinProjectionTwoHopEnumTest {
] }
}""".trimIndent()
+ // The reduced model for the "projection-only" must-generate check: NO entity at all — the
+ // projection's field.enum extends the shared declaration directly (one hop), so the shared
+ // type's ONLY possible generated consumer in the whole loaded model is the projection.
+ private val projectionOnlyFixture = """{
+ "metadata.root": { "package": "acme::commerce", "children": [
+ { "field.enum": { "name": "SharedStatus", "abstract": true, "@required": true,
+ "@values": ["DRAFT", "LIVE", "ARCHIVED"] } },
+ { "object.projection": { "name": "ProgramView", "children": [
+ { "source.rdb": { "@kind": "view", "@view": "v_program" } },
+ { "field.enum": { "name": "status", "extends": "acme::commerce::SharedStatus", "@required": true } }
+ ] } }
+ ] }
+ }""".trimIndent()
+
private fun compile(outDir: Path): KotlinCompilation.Result {
val sources = Files.walk(outDir).filter { it.isRegularFile() }.sorted().toList()
.map { path -> SourceFile.kotlin(path.parent.relativize(path).toString().replace('/', '_'), path.readText()) }
@@ -66,7 +74,7 @@ class KotlinProjectionTwoHopEnumTest {
}.compile()
}
- @Test fun `two-hop projection enum materializes its own per-projection type`() {
+ @Test fun `two-hop projection enum references the shared type, not a per-projection copy`() {
val outDir = Files.createTempDirectory("k2hop-enum-")
try {
val loader = loadString("two-hop-enum", fixture)
@@ -75,25 +83,25 @@ class KotlinProjectionTwoHopEnumTest {
gen.execute(loader)
}
- // The shared enum is emitted once (the entity field DOES collapse onto it).
- assertTrue(Files.exists(outDir.resolve("acme/commerce/SharedStatus.kt")),
+ // The shared enum is emitted exactly once, carrying every member.
+ val sharedEnum = outDir.resolve("acme/commerce/SharedStatus.kt")
+ assertTrue(Files.exists(sharedEnum),
"shared SharedStatus.kt must be emitted; files=${Files.walk(outDir).toList()}")
-
- // The projection gets its OWN enum, populated with the DEPTH-2-inherited values.
- val viewEnum = outDir.resolve("acme/commerce/ProgramViewStatus.kt")
- assertTrue(Files.exists(viewEnum),
- "#259: projection enum ProgramViewStatus.kt must be materialized (values inherited " +
- "through TWO extends hops); files=${Files.walk(outDir).toList()}")
- val viewEnumSrc = viewEnum.readText()
+ val sharedSrc = sharedEnum.readText()
for (m in listOf("DRAFT", "LIVE", "ARCHIVED"))
- assertTrue(m in viewEnumSrc, "inherited member $m must be present; saw:\n$viewEnumSrc")
+ assertTrue(m in sharedSrc, "member $m must be present; saw:\n$sharedSrc")
- // The projection table references its OWN enum, NOT the collapsed shared one.
+ // NO per-projection enum is written — the #259 remedy is superseded.
+ assertFalse(Files.exists(outDir.resolve("acme/commerce/ProgramViewStatus.kt")),
+ "a per-projection ProgramViewStatus.kt must NOT be materialized now that the " +
+ "projection references the shared type; files=${Files.walk(outDir).toList()}")
+
+ // The projection table references the SHARED enum.
val viewTable = outDir.resolve("acme/commerce/ProgramViewTable.kt").readText()
- assertTrue("ProgramViewStatus::class" in viewTable,
- "projection table must reference its own ProgramViewStatus enum; saw:\n$viewTable")
- assertFalse("SharedStatus::class" in viewTable,
- "#259: projection table must NOT collapse to the shared SharedStatus enum; saw:\n$viewTable")
+ assertTrue("SharedStatus::class" in viewTable,
+ "projection table must reference the shared SharedStatus enum; saw:\n$viewTable")
+ assertFalse("ProgramViewStatus::class" in viewTable,
+ "projection table must NOT reference a per-projection enum; saw:\n$viewTable")
// The ENTITY table still collapses onto the shared enum (byte-identity guardrail).
val programTable = outDir.resolve("acme/commerce/ProgramTable.kt").readText()
@@ -107,4 +115,29 @@ class KotlinProjectionTwoHopEnumTest {
outDir.toFile().deleteRecursively()
}
}
+
+ @Test fun `shared enum materializes when a projection is the only generated consumer`() {
+ val outDir = Files.createTempDirectory("k-proj-only-enum-")
+ try {
+ val loader = loadString("projection-only-enum", projectionOnlyFixture)
+ for (gen in listOf(KotlinEntityGenerator(), KotlinExposedTableGenerator())) {
+ gen.setArgs(mapOf("outputDir" to outDir.toString()))
+ gen.execute(loader)
+ }
+
+ val sharedEnum = outDir.resolve("acme/commerce/SharedStatus.kt")
+ assertTrue(Files.exists(sharedEnum),
+ "shared SharedStatus.kt must be materialized when a projection is the only " +
+ "consumer; files=${Files.walk(outDir).toList()}")
+
+ val viewTable = outDir.resolve("acme/commerce/ProgramViewTable.kt").readText()
+ assertTrue("SharedStatus::class" in viewTable,
+ "projection table must reference the shared SharedStatus enum; saw:\n$viewTable")
+
+ val result = compile(outDir)
+ assertEquals(KotlinCompilation.ExitCode.OK, result.exitCode, result.messages)
+ } finally {
+ outDir.toFile().deleteRecursively()
+ }
+ }
}
diff --git a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinTypedPayloadRenderTest.kt b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinTypedPayloadRenderTest.kt
new file mode 100644
index 000000000..2ad908367
--- /dev/null
+++ b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinTypedPayloadRenderTest.kt
@@ -0,0 +1,68 @@
+package com.metaobjects.generator.kotlin
+
+import com.metaobjects.render.InMemoryProvider
+import com.metaobjects.render.RenderRequest
+import com.metaobjects.render.Renderer
+import org.junit.jupiter.api.Test
+import java.nio.file.Files
+import java.nio.file.Path
+import kotlin.test.assertEquals
+
+/**
+ * A Kotlin data class payload — the shape a generated value object takes — goes through the JVM
+ * renderer and must render byte-identically to the equal map payload: `-last` and `has`
+ * come from one mechanism whatever the payload's shape. The oracle is the shared
+ * render-list-separator fixture, whose expected.txt every port renders identically.
+ */
+class KotlinTypedPayloadRenderTest {
+
+ data class Item(val name: String)
+ data class Tag(val label: String)
+ data class Group(val tags: List)
+ data class Owner(val nick: String)
+ data class Entry(val name: String, val note: String?, val owner: Owner)
+ data class ListSeparator(
+ val items: List- ,
+ val solo: List
- ,
+ val none: List
- ,
+ val groups: List
,
+ val entries: List,
+ val authored: List>,
+ )
+
+ private val fixture: Path = Path.of(System.getProperty("user.dir"))
+ .resolve("../../..").normalize()
+ .resolve("fixtures/render-conformance/render-list-separator")
+
+ private fun render(template: String, payload: Any): String =
+ Renderer().render(RenderRequest(template, null, payload, InMemoryProvider(emptyMap()), "text", null, null))
+
+ @Test
+ fun `a data class payload renders the shared fixture byte-identically`() {
+ val payload = ListSeparator(
+ items = listOf(Item("a"), Item("b"), Item("c")),
+ solo = listOf(Item("only")),
+ none = emptyList(),
+ groups = listOf(Group(listOf(Tag("x"), Tag("y"))), Group(listOf(Tag("z")))),
+ entries = listOf(
+ Entry("a", "x", Owner("ann")),
+ Entry("b", null, Owner("bob")),
+ Entry("c", "z", Owner("cy")),
+ ),
+ authored = listOf(mapOf("name" to "p", "-last" to true), mapOf("name" to "q", "-last" to true)),
+ )
+ assertEquals(
+ Files.readString(fixture.resolve("expected.txt")),
+ render(Files.readString(fixture.resolve("template.mustache")), payload),
+ )
+ }
+
+ @Test
+ fun `has gate on a data class element matches the equal map payload`() {
+ val template = "{{#items}}{{name}}:{{#hasNote}}y{{/hasNote}}{{^hasNote}}n{{/hasNote}} {{/items}}"
+ val viaMaps = mapOf("items" to listOf(mapOf("name" to "a", "note" to "x"), mapOf("name" to "b", "note" to " ")))
+ val viaData = mapOf("items" to listOf(Entry("a", "x", Owner("o")), Entry("b", " ", Owner("o"))))
+ assertEquals("a:y b:n ", render(template, viaMaps))
+ assertEquals(render(template, viaMaps), render(template, viaData))
+ }
+}
diff --git a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/Fr019SharedEnum.java b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/Fr019SharedEnum.java
index 333d81444..4bd76c38e 100644
--- a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/Fr019SharedEnum.java
+++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/Fr019SharedEnum.java
@@ -1,13 +1,15 @@
package com.metaobjects.generator.spring;
-import com.metaobjects.MetaRoot;
import com.metaobjects.field.EnumField;
import com.metaobjects.field.MetaField;
import com.metaobjects.generator.GeneratorException;
+import com.metaobjects.generator.util.GeneratedFileWriter;
import com.metaobjects.generator.util.GeneratorUtil;
import com.metaobjects.loader.MetaDataLoader;
import com.metaobjects.object.MetaObject;
+import java.io.IOException;
+import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
@@ -21,10 +23,13 @@
* C# {@code Fr019SharedEnum}).
*
* A reusable enum is a ROOT/package-level abstract {@code field.enum} (a sibling of
- * {@code object.entity}) that concrete entity fields {@code extends}. Per ADR-0026 such a
- * declaration is materialized ONCE per port as a standalone Java {@code enum} (one file,
- * {@code Priority.java}) and referenced, instead of being redeclared inline (a nested
- * {@code public enum E}) in every consuming DTO record.
+ * {@code object.entity}) that a concrete field anywhere — on an {@code object.entity},
+ * {@code object.projection}, or {@code object.value} — reaches via (possibly multi-hop)
+ * {@code extends}, resolved by the single cross-generator authority
+ * {@link EnumField#resolveSharedEnumDeclaration}. Per ADR-0026 such a declaration is
+ * materialized ONCE per port as a standalone Java {@code enum} (one file, {@code Priority.java})
+ * and referenced, instead of being redeclared inline (a nested {@code public enum E}) in every
+ * consuming record.
*
* Two cases on the abstract declaration:
*
@@ -95,25 +100,16 @@ public static Map parsePackageMap(String arg) {
}
/**
- * The root-level abstract {@code field.enum} a concrete enum field resolves to via
- * {@code extends}, or {@code null} when the field is an inline enum (no root-abstract super).
- * The {@code extends} chain is walked and the NEAREST super that is (a) an {@link EnumField},
- * (b) abstract, AND (c) a direct child of the metadata root ({@link MetaRoot}) — NOT an
- * abstract field nested inside an object — wins, so a multi-hop chain through concrete enum
- * fields still reaches the shared declaration.
+ * The shared-enum declaration {@code field} resolves to via {@code extends}, or {@code null}
+ * when the field is an inline enum. Delegates entirely to the single cross-generator
+ * authority, {@link EnumField#resolveSharedEnumDeclaration} (metadata module) — the SAME
+ * function the Kotlin port and the #246 loader validator call, so the two JVM generators and
+ * the loader can never disagree about what "shared" means. See that method's Javadoc for the
+ * full rule (nearest-declaration-wins, an own-{@code @values}/{@code @intValueMap} node stops
+ * the walk, no hop limit, no hosting-object-subtype condition).
*/
public static MetaField> resolveSharedEnumDecl(MetaField> field) {
- if (!(field instanceof EnumField)) return null;
- java.util.Set> seen = java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>());
- seen.add(field);
- MetaField> sup = field.getSuperField();
- while (sup != null && seen.add(sup)) {
- if (!(sup instanceof EnumField)) return null;
- // The declaration must sit at the package/root level (a sibling of object.entity).
- if (GeneratorUtil.isAbstract(sup) && sup.getParent() instanceof MetaRoot) return sup;
- sup = sup.getSuperField();
- }
- return null;
+ return EnumField.resolveSharedEnumDeclaration(field);
}
/** The shared-enum descriptor a field resolves to, or {@code null} for inline enums. */
@@ -151,16 +147,25 @@ private static boolean isProvided(EnumField decl) {
/**
* All shared (root-level abstract) enum declarations in the loaded model that are actually
- * CONSUMED by at least one concrete entity field — keyed by materialized type name, sorted by
+ * CONSUMED by at least one concrete object field — keyed by materialized type name, sorted by
* name (deterministic). A declaration nobody extends is not materialized (no dangling type).
- * Only {@code object.entity} fields are considered (the DTO scope); payload value-objects keep
- * their own inline nesting.
+ * Scans every concrete {@code object.entity}, {@code object.projection} AND
+ * {@code object.value} (there is no condition on the hosting object's SUBTYPE — a projection
+ * or value-object field reaching a shared declaration must-generate it too); abstract objects
+ * are skipped (nothing is generated for them directly — a concrete subtype's resolving
+ * {@link MetaObject#getMetaFields()} already sees their inherited fields). Scans the WHOLE
+ * loader regardless of which objects a particular generator invocation is narrowed to, so a
+ * run filtered down to a single consuming object still materializes the type it reaches.
*/
public static List collectSharedEnums(MetaDataLoader loader) {
Map out = new LinkedHashMap<>();
- for (MetaObject entity : loader.getMetaObjects()) {
- if (!MetaObject.SUBTYPE_ENTITY.equals(entity.getSubType())) continue;
- for (MetaField> field : entity.getMetaFields()) {
+ for (MetaObject owner : loader.getMetaObjects()) {
+ if (GeneratorUtil.isAbstract(owner)) continue;
+ String subType = owner.getSubType();
+ if (!MetaObject.SUBTYPE_ENTITY.equals(subType)
+ && !MetaObject.SUBTYPE_PROJECTION.equals(subType)
+ && !MetaObject.SUBTYPE_VALUE.equals(subType)) continue;
+ for (MetaField> field : owner.getMetaFields()) {
SharedEnum shared = sharedEnumForField(field);
if (shared == null) continue;
out.putIfAbsent(shared.name(), shared);
@@ -178,6 +183,35 @@ public static List materializedSharedEnums(MetaDataLoader loader) {
return out;
}
+ /**
+ * Emit each materialized shared enum ONCE as a standalone top-level Java {@code enum} in its
+ * declaring package — {@code /.java} containing {@code public enum { A, B, C }}.
+ * Every generator whose output REFERENCES a shared type (the DTO, value-object and output-parser
+ * generators) calls this, so the type exists whichever of them a run includes; the content is a
+ * pure function of the declaration, so the repeated write is byte-identical. Provided enums
+ * emit nothing (referenced externally).
+ */
+ public static void emitMaterialized(MetaDataLoader loader, Path outRoot) {
+ for (SharedEnum shared : materializedSharedEnums(loader)) {
+ StringBuilder src = new StringBuilder();
+ if (!shared.javaPackage().isEmpty()) {
+ src.append("package ").append(shared.javaPackage()).append(";\n\n");
+ }
+ src.append("/** GENERATED — shared enum ").append(shared.name())
+ .append(". Do not hand-edit; regenerated from metadata. */\n");
+ src.append(SpringTypeMapper.enumDeclaration(shared.name(), shared.values(), shared.intValueMap()))
+ .append("\n");
+ try {
+ Path outFile = outRoot.resolve(shared.javaPackage().replace('.', '/'))
+ .resolve(shared.name() + ".java");
+ GeneratedFileWriter.write(outFile, src.toString());
+ } catch (IOException e) {
+ throw new GeneratorException(
+ "failed writing shared enum " + shared.name() + ".java: " + e, e);
+ }
+ }
+ }
+
/**
* The Java type reference a consuming DTO component emits for a shared/provided enum:
*
@@ -194,7 +228,18 @@ public static String typeReference(SharedEnum shared, MetaObject owner, Provided
if (shared.javaPackage().isEmpty() || shared.javaPackage().equals(ownerJavaPkg)) {
return shared.name();
}
- return shared.javaPackage() + "." + shared.name();
+ }
+ return qualifiedTypeReference(shared, cfg);
+ }
+
+ /**
+ * The always-qualified reference ({@code .E} / {@code .E}) for generated code that
+ * lives in a package of its own — the output parser sits in {@code .prompts}, never in
+ * the enum's package, so it cannot use the bare same-package form.
+ */
+ public static String qualifiedTypeReference(SharedEnum shared, ProvidedEnumConfig cfg) {
+ if (!shared.provided()) {
+ return shared.javaPackage().isEmpty() ? shared.name() : shared.javaPackage() + "." + shared.name();
}
String ns = cfg.packageNamespaces().get(shared.metaPackage());
if (ns == null || ns.isBlank()) ns = cfg.fallbackNamespace();
diff --git a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringDtoGenerator.java b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringDtoGenerator.java
index 1e49cc613..b05228447 100644
--- a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringDtoGenerator.java
+++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringDtoGenerator.java
@@ -114,7 +114,7 @@ public void execute(MetaDataLoader loader) {
getArg("providedEnumNamespace"), getArg("providedEnumPackages"));
// FR-019: materialize each shared (package-level abstract, non-@provided) enum ONCE as a
// standalone Java enum, so consuming DTOs reference it instead of redeclaring it inline.
- emitSharedEnums(loader, outRoot);
+ Fr019SharedEnum.emitMaterialized(loader, outRoot);
boolean emitAbstractShapes = Boolean.parseBoolean(getArg("emitAbstractShapes", "false"));
for (MetaObject declared : loader.getMetaObjects()) {
// FR-044: a served report's row DTO is emitted from its read model (one
@@ -905,26 +905,6 @@ private String componentTypeFr019(MetaField> field, MetaObject owner) {
return componentType(field, owner);
}
- /**
- * FR-019: emit each materialized shared enum (package-level abstract, non-{@code @provided},
- * consumed by ≥1 entity field) ONCE as a standalone top-level Java {@code enum} in its declaring
- * package — {@code /.java} containing {@code public enum { A, B, C }}. Consuming
- * DTOs reference this type. Provided enums emit nothing here (referenced externally).
- */
- private void emitSharedEnums(MetaDataLoader loader, Path outRoot) {
- for (Fr019SharedEnum.SharedEnum shared : Fr019SharedEnum.materializedSharedEnums(loader)) {
- StringBuilder src = new StringBuilder();
- if (!shared.javaPackage().isEmpty()) {
- src.append("package ").append(shared.javaPackage()).append(";\n\n");
- }
- src.append("/** GENERATED — shared enum ").append(shared.name())
- .append(". Do not hand-edit; regenerated from metadata. */\n");
- src.append(SpringTypeMapper.enumDeclaration(shared.name(), shared.values(), shared.intValueMap()))
- .append("\n");
- writeSharedEnumFile(shared, outRoot, src.toString());
- }
- }
-
// === #234 strict field.uri / field.inet bind-layer enforcement =============
/**
@@ -1075,18 +1055,6 @@ private void writeNetBindingsFile(String pkg, Path outRoot) {
}
}
- /** Write {@code //.java} for a materialized shared enum. */
- private void writeSharedEnumFile(Fr019SharedEnum.SharedEnum shared, Path outRoot, String body) {
- try {
- Path outFile = outRoot.resolve(shared.javaPackage().replace('.', '/'))
- .resolve(shared.name() + ".java");
- GeneratedFileWriter.write(outFile, body);
- } catch (IOException e) {
- throw new GeneratorException(
- "failed writing shared enum " + shared.name() + ".java: " + e, e);
- }
- }
-
/**
* Collect the nested {@code public enum { }} declarations for the enum
* fields in {@code fields} (the record's components), deduped by enum-type name (two fields
diff --git a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringOutputParserGenerator.java b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringOutputParserGenerator.java
index eb5102566..62f1ab75b 100644
--- a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringOutputParserGenerator.java
+++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringOutputParserGenerator.java
@@ -35,6 +35,7 @@
import java.util.List;
import java.util.Map;
import java.util.Set;
+import com.metaobjects.generator.spring.Fr019SharedEnum;
import com.metaobjects.generator.util.GeneratedFileWriter;
/**
@@ -118,6 +119,16 @@
*/
public class SpringOutputParserGenerator extends MultiFileDirectGeneratorBase {
+ /**
+ * FR-019 per-port config for resolving {@code @provided} enum namespaces, parsed from the
+ * same {@code providedEnumNamespace} / {@code providedEnumPackages} generator args
+ * {@link SpringDtoGenerator} and {@link SpringValueObjectGenerator} read — a parsed enum
+ * field here may equally reach a shared/{@code @provided} declaration, and all three
+ * generators must resolve it to the SAME reference.
+ */
+ private Fr019SharedEnum.ProvidedEnumConfig fr019Config =
+ new Fr019SharedEnum.ProvidedEnumConfig(null, java.util.Map.of());
+
@Override
protected Class getFilterClass() {
return MetaObject.class;
@@ -127,6 +138,9 @@ protected Class getFilterClass() {
public void execute(MetaDataLoader loader) {
parseArgs();
Path outRoot = Paths.get(outDir.getAbsolutePath());
+ fr019Config = Fr019SharedEnum.ProvidedEnumConfig.of(
+ getArg("providedEnumNamespace"), getArg("providedEnumPackages"));
+ Fr019SharedEnum.emitMaterialized(loader, outRoot);
// ADR-0052: the direction rule lives in FindInbound, never re-derived here.
for (MetaTemplate tmpl : FindInbound.inboundTemplates(loader)) {
@@ -371,12 +385,17 @@ protected String mapperArgForField(MetaField> field, MetaObject owner, String
}
// Enum fields: the assembled value is a validated member string; the record component
- // is the enum nested in the value object's record. Bridge string → enum via
- // `..valueOf(s)` (per-element for arrays, dropping nulls). Checked BEFORE
+ // is either the enum nested in the value object's record (inline) or a shared/
+ // @provided top-level type (FR-019 — resolved by the SAME Fr019SharedEnum authority
+ // SpringDtoGenerator and SpringValueObjectGenerator use). Bridge string → enum via
+ // `.valueOf(s)` (per-element for arrays, dropping nulls). Checked BEFORE
// the generic scalar-array branch (enum-before-isArray). Safe: @values == the generated
// constants and the engine validated the member.
if (field instanceof EnumField ef) {
- String enumType = payloadClass + "." + SpringTypeMapper.enumTypeName(owner, ef);
+ Fr019SharedEnum.SharedEnum shared = Fr019SharedEnum.sharedEnumForField(ef);
+ String enumType = shared != null
+ ? Fr019SharedEnum.qualifiedTypeReference(shared, fr019Config)
+ : payloadClass + "." + SpringTypeMapper.enumTypeName(owner, ef);
if (field.isArrayType()) {
// asStringList returns null when the array is absent/malformed — guard with an
// empty list so the stream never NPEs.
diff --git a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringRenderHelperGenerator.java b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringRenderHelperGenerator.java
index 095ed5ca2..056aa6fda 100644
--- a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringRenderHelperGenerator.java
+++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringRenderHelperGenerator.java
@@ -326,6 +326,9 @@ private static List derivePayloadFieldTree(
seen.add(vo.getName());
List fields = new ArrayList<>();
for (MetaField> field : vo.getMetaFields()) {
+ // ADR-0039: isArrayType() is the EFFECTIVE (inherited-via-extends) array-ness; the
+ // own-only isArray() flag would read false for a field that inherits @isArray.
+ boolean isArray = field.isArrayType();
if (field instanceof ObjectField of) {
// Resolve the nested @objectRef by BARE short-name — the cross-port
// render-helper consensus (TS findObject: c.type === OBJECT &&
@@ -339,11 +342,11 @@ private static List derivePayloadFieldTree(
if (target != null && MetaObject.SUBTYPE_VALUE.equals(target.getSubType())) {
List children =
derivePayloadFieldTree(loader, target, new LinkedHashSet<>(seen));
- fields.add(PayloadField.object(field.getName(), children));
+ fields.add(PayloadField.object(field.getName(), children, isArray));
continue;
}
}
- fields.add(PayloadField.scalar(field.getName()));
+ fields.add(PayloadField.scalar(field.getName(), isArray));
}
return fields;
}
@@ -390,7 +393,7 @@ protected static MetaObject resolveNestedObjectRef(MetaDataLoader loader, Object
/**
* Emit a {@code List} as deterministic Java construction code
* ({@code java.util.List.of(...)}): {@code PayloadField.scalar("name")} and
- * {@code PayloadField.object("nested", List.of(...))}. An empty tree emits
+ * {@code PayloadField.object("nested", List.of(...))}, each with a trailing {@code , true} when the field is an array. An empty tree emits
* {@code java.util.List.of()}.
*/
protected static String fieldTreeLiteral(List fields) {
@@ -402,10 +405,10 @@ protected static String fieldTreeLiteral(List fields) {
if (f.fields() != null) {
sb.append("com.metaobjects.render.PayloadField.object(")
.append(quote(f.name())).append(", ")
- .append(fieldTreeLiteral(f.fields())).append(")");
+ .append(fieldTreeLiteral(f.fields())).append(f.isArray() ? ", true)" : ")");
} else {
sb.append("com.metaobjects.render.PayloadField.scalar(")
- .append(quote(f.name())).append(")");
+ .append(quote(f.name())).append(f.isArray() ? ", true)" : ")");
}
}
sb.append(")");
diff --git a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringTypeMapper.java b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringTypeMapper.java
index a5fc7b92f..ad42f5155 100644
--- a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringTypeMapper.java
+++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringTypeMapper.java
@@ -22,9 +22,7 @@
import com.metaobjects.identity.MetaIdentity;
import com.metaobjects.object.MetaObject;
-import java.util.HashSet;
import java.util.List;
-import java.util.Set;
/**
* Centralised mapping from {@link MetaField} subtype to the Java type used in
@@ -319,37 +317,25 @@ public static String payloadJavaTypeName(MetaField> field, MetaObject owner, S
/**
* The generated Java {@code enum} type name for an enum-subtype payload field — the SAME
* shared scheme the other ports use ({@code KotlinTypeMapper.enumTypeName} /
- * C# {@code CSharpNaming.EnumTypeName}): when the field {@code extends} an abstract enum
- * super, all extenders collapse onto ONE enum named for the top-most super
- * ({@code Pascal(super.shortName)}); otherwise {@code Pascal(owner.shortName) +
- * Pascal(field.name)}. Members verbatim.
+ * C# {@code CSharpNaming.EnumTypeName}): when {@code field} reaches a package-level shared
+ * enum DECLARATION via {@link EnumField#resolveSharedEnumDeclaration} (the single
+ * cross-generator authority — possibly multi-hop, short-circuited by an own
+ * {@code @values}/{@code @intValueMap} along the way), the name is
+ * {@code Pascal(declaration.shortName)}; otherwise (an inline field — no reachable
+ * declaration) it is {@code Pascal(owner.shortName) + Pascal(field.name)}. Members verbatim.
+ *
+ * This is a BARE (unqualified) name — a shared declaration materialized in a different
+ * Java package, or marked {@code @provided}, is qualified by
+ * {@link Fr019SharedEnum#typeReference}; this method answers only "what is the type called."
*/
public static String enumTypeName(MetaObject owner, MetaField> field) {
- MetaField> superRoot = resolveSuperRoot(field);
- if (superRoot != null) {
- return pascal(SpringNaming.splitFqn(superRoot.getName())[1]);
+ MetaField> declaration = EnumField.resolveSharedEnumDeclaration(field);
+ if (declaration != null) {
+ return pascal(SpringNaming.splitFqn(declaration.getName())[1]);
}
return pascal(SpringNaming.splitFqn(owner.getName())[1]) + pascal(field.getName());
}
- /**
- * Walk a field's {@code extends} (super-field) chain to the top-most ancestor, returning it,
- * or {@code null} when the field has no super. Naming the generated enum after the top-most
- * super makes every extending field share one type. Cycle-guarded via a visited set.
- */
- static MetaField> resolveSuperRoot(MetaField> field) {
- MetaField> current = field.getSuperField();
- if (current == null) return null;
- Set seen = new HashSet<>();
- while (true) {
- MetaField> next = current.getSuperField();
- if (next == null) break;
- if (!seen.add(current.getName())) break; // cycle guard
- current = next;
- }
- return current;
- }
-
/**
* Read the EFFECTIVE {@code @values} of an {@link EnumField} (inheriting from an
* {@code extends}-super when the field carries no own values), as the verbatim member list.
diff --git a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringValueObjectGenerator.java b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringValueObjectGenerator.java
index d31750401..2b19a8c64 100644
--- a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringValueObjectGenerator.java
+++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringValueObjectGenerator.java
@@ -28,6 +28,7 @@
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
+import com.metaobjects.generator.spring.Fr019SharedEnum;
import com.metaobjects.generator.util.GeneratedFileWriter;
/**
@@ -62,6 +63,17 @@
*/
public class SpringValueObjectGenerator extends MultiFileDirectGeneratorBase {
+ /**
+ * FR-019 per-port config for resolving {@code @provided} enum namespaces, parsed from the
+ * same {@code providedEnumNamespace} / {@code providedEnumPackages} generator args
+ * {@link SpringDtoGenerator} reads — a value-object field may equally reach a shared
+ * declaration (a projection/value-object field extending an entity field that in turn
+ * extends a root-level abstract enum), and the two generators must resolve it to the SAME
+ * reference.
+ */
+ private Fr019SharedEnum.ProvidedEnumConfig fr019Config =
+ new Fr019SharedEnum.ProvidedEnumConfig(null, java.util.Map.of());
+
@Override
protected Class getFilterClass() {
return MetaObject.class;
@@ -71,6 +83,9 @@ protected Class getFilterClass() {
public void execute(MetaDataLoader loader) {
parseArgs();
Path outRoot = Paths.get(outDir.getAbsolutePath());
+ fr019Config = Fr019SharedEnum.ProvidedEnumConfig.of(
+ getArg("providedEnumNamespace"), getArg("providedEnumPackages"));
+ Fr019SharedEnum.emitMaterialized(loader, outRoot);
for (MetaObject vo : valueShapes(loader)) {
emit(vo, outRoot);
}
@@ -138,7 +153,7 @@ private void emit(MetaObject vo, Path outRoot) {
for (int i = 0; i < fields.size(); i++) {
MetaField field = fields.get(i);
String a = annotations.get(i);
- String type = SpringDtoGenerator.componentType(field, vo);
+ String type = componentTypeFr019(field, vo);
// A field named notify/wait/toString/… cannot BE a record component (JLS 8.10.3),
// so it is escaped here and pinned back to its declared name on the wire.
String component = SpringNaming.recordComponentName(field.getName());
@@ -173,15 +188,40 @@ private void emit(MetaObject vo, Path outRoot) {
}
/**
- * Nested {@code public enum { }} declarations for the value object's inline
+ * FR-019-aware component type for a value-object field. A {@code field.enum} resolving to a
+ * package-level shared enum is typed by the materialized standalone type (a bare {@code E}
+ * in the same package, else its FQN) or — when {@code @provided} — the configured external
+ * {@code .E}; an enum array wraps it in {@code List<…>}. Every other field (including
+ * inline enums) delegates verbatim to {@link SpringDtoGenerator#componentType}, so the inline
+ * default stays byte-identical. Mirrors {@link SpringDtoGenerator}'s own
+ * {@code componentTypeFr019} — the two generators resolve a shared field to the SAME
+ * reference because both call {@link Fr019SharedEnum#sharedEnumForField}.
+ */
+ private String componentTypeFr019(MetaField> field, MetaObject owner) {
+ if (field instanceof EnumField) {
+ Fr019SharedEnum.SharedEnum shared = Fr019SharedEnum.sharedEnumForField(field);
+ if (shared != null) {
+ String ref = Fr019SharedEnum.typeReference(shared, owner, fr019Config);
+ return field.isArrayType() ? "java.util.List<" + ref + ">" : ref;
+ }
+ }
+ return SpringDtoGenerator.componentType(field, owner);
+ }
+
+ /**
+ * Nested {@code public enum { }} declarations for the value object's INLINE
* enum members, deduped by enum-type name — mirrors {@link SpringDtoGenerator}'s record body.
- * A VO with no enum members yields an empty list (the common case).
+ * A field resolving to a package-level shared enum (materialized standalone or
+ * {@code @provided} externally) is referenced, never nested — skipped here, the same guard
+ * {@code SpringDtoGenerator.collectEnumDecls} applies. A VO with no own enum members yields
+ * an empty list (the common case).
*/
private static List collectEnumDecls(MetaObject owner, List fields) {
Set seen = new LinkedHashSet<>();
List decls = new ArrayList<>();
for (MetaField> field : fields) {
if (!(field instanceof EnumField ef)) continue;
+ if (Fr019SharedEnum.sharedEnumForField(field) != null) continue;
List values = SpringTypeMapper.effectiveEnumValues(ef);
if (values.isEmpty()) continue;
String typeName = SpringTypeMapper.enumTypeName(owner, ef);
diff --git a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/Fr019SharedProvidedConformanceTest.java b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/Fr019SharedProvidedConformanceTest.java
index 02509f553..c5b70eea7 100644
--- a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/Fr019SharedProvidedConformanceTest.java
+++ b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/Fr019SharedProvidedConformanceTest.java
@@ -1,6 +1,11 @@
package com.metaobjects.generator.spring;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.metaobjects.field.EnumField;
+import com.metaobjects.field.MetaField;
import com.metaobjects.loader.MetaDataLoader;
+import com.metaobjects.object.MetaObject;
import com.metaobjects.registry.SharedRegistryTestBase;
import org.junit.Rule;
import org.junit.Test;
@@ -9,9 +14,14 @@
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
+import java.util.ArrayList;
import java.util.HashMap;
+import java.util.Iterator;
+import java.util.LinkedHashMap;
+import java.util.List;
import java.util.Map;
+import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
@@ -84,11 +94,15 @@ public void sharedEnumIsMaterializedOnceAndReferencedByBothEntities() throws Exc
@Test
public void multiHopEnumExtendsStillResolvesToTheSharedEnum() throws Exception {
// Task.urgency extends Ticket.priority, which extends the shared abstract Priority.
+ // Task ALSO carries phase/weight (own @values / @intValueMap, resp. — inline per-object
+ // enums per rule (b)), so this asserts only that Priority specifically is referenced, not
+ // redeclared — "no `public enum` at all" would wrongly fail on Task's legitimate inline
+ // fields.
Path out = run(loadShared(), "com.acme.ext");
String task = Files.readString(out.resolve("acme/shop/TaskDto.java"));
assertTrue("urgency must be typed as the shared enum; saw:\n" + task, task.contains("Priority urgency"));
- assertFalse("shared enum must NOT be redeclared in TaskDto; saw:\n" + task,
- task.contains("public enum"));
+ assertFalse("shared Priority must NOT be redeclared in TaskDto; saw:\n" + task,
+ task.contains("public enum Priority"));
}
@Test
@@ -117,4 +131,217 @@ public void providedEnumWithNoNamespaceConfigIsACodegenErrorNamingTheEnum() thro
e.getMessage() != null && e.getMessage().contains("Currency"));
}
}
+
+ // =========================================================================
+ // Decision table — one shared-enum rule for every object-hosted field.enum
+ // =========================================================================
+
+ /**
+ * For EVERY object-hosted {@code field.enum} in the shared fixture (entities, the projection,
+ * the value object), compute {@code {"shared": ""|null, "provided": bool}}
+ * from the SAME function the Java generators call ({@link Fr019SharedEnum#sharedEnumForField})
+ * and assert exact equality with {@code expected-enum-types.json}'s {@code fields} map in both
+ * directions (no missing key, no extra key). Also asserts {@code materialized}.
+ */
+ @Test
+ public void decisionTableMatchesSharedFixtureExpectation() throws Exception {
+ MetaDataLoader loader = loadShared();
+ Map> actual = computeDecisionTable(loader);
+ JsonNode expected = readExpectedEnumTypes();
+
+ JsonNode expectedFields = expected.get("fields");
+ Iterator expectedKeys = expectedFields.fieldNames();
+ int expectedCount = 0;
+ while (expectedKeys.hasNext()) {
+ String key = expectedKeys.next();
+ expectedCount++;
+ assertTrue("decision table missing key " + key + "; actual keys: " + actual.keySet(),
+ actual.containsKey(key));
+ JsonNode expectedEntry = expectedFields.get(key);
+ Map actualEntry = actual.get(key);
+ Object expectedShared = expectedEntry.get("shared").isNull()
+ ? null : expectedEntry.get("shared").asText();
+ assertEquals("mismatched 'shared' for " + key, expectedShared, actualEntry.get("shared"));
+ assertEquals("mismatched 'provided' for " + key,
+ expectedEntry.get("provided").asBoolean(), actualEntry.get("provided"));
+ }
+ assertEquals("decision table has extra keys beyond the fixture; actual: " + actual.keySet(),
+ expectedCount, actual.size());
+
+ List expectedMaterialized = new ArrayList<>();
+ for (JsonNode n : expected.get("materialized")) expectedMaterialized.add(n.asText());
+ List actualMaterialized = new ArrayList<>();
+ for (Fr019SharedEnum.SharedEnum e : Fr019SharedEnum.materializedSharedEnums(loader)) {
+ actualMaterialized.add(e.name());
+ }
+ assertEquals("materialized shared enums must match the fixture", expectedMaterialized, actualMaterialized);
+ }
+
+ /** {@code ::.} -> {@code {"shared": |null, "provided": bool}},
+ * computed via {@link Fr019SharedEnum#sharedEnumForField} for every object-hosted
+ * {@code field.enum} on every concrete entity/projection/value object — no condition on the
+ * hosting object's subtype. */
+ private static Map> computeDecisionTable(MetaDataLoader loader) {
+ Map> out = new LinkedHashMap<>();
+ for (MetaObject owner : loader.getMetaObjects()) {
+ String subType = owner.getSubType();
+ if (!MetaObject.SUBTYPE_ENTITY.equals(subType)
+ && !MetaObject.SUBTYPE_PROJECTION.equals(subType)
+ && !MetaObject.SUBTYPE_VALUE.equals(subType)) continue;
+ for (MetaField> field : owner.getMetaFields()) {
+ if (!(field instanceof EnumField)) continue;
+ String key = owner.getName() + "." + field.getName();
+ Fr019SharedEnum.SharedEnum shared = Fr019SharedEnum.sharedEnumForField(field);
+ Map entry = new LinkedHashMap<>();
+ if (shared == null) {
+ entry.put("shared", null);
+ entry.put("provided", false);
+ } else {
+ String fqn = shared.metaPackage().isEmpty()
+ ? shared.name() : shared.metaPackage() + "::" + shared.name();
+ entry.put("shared", fqn);
+ entry.put("provided", shared.provided());
+ }
+ out.put(key, entry);
+ }
+ }
+ return out;
+ }
+
+ private JsonNode readExpectedEnumTypes() throws IOException {
+ Path repoRoot = Path.of(System.getProperty("user.dir")).resolve("../../..").normalize();
+ Path path = repoRoot.resolve("fixtures/codegen-conformance/shared-provided-enum/expected-enum-types.json");
+ assertTrue("expected-enum-types.json must exist at " + path, Files.exists(path));
+ return new ObjectMapper().readTree(Files.readString(path));
+ }
+
+ // =========================================================================
+ // Generated-output assertions — the multi-hop / provided / negative cases
+ // =========================================================================
+
+ @Test
+ public void multiHopProjectionAndValueObjectFieldsReferenceTheSharedTypeAndItEmitsOnce() throws Exception {
+ MetaDataLoader loader = loadShared();
+ Path dtoOut = run(loader, "com.acme.ext");
+
+ // Priority is materialized exactly once, regardless of how many objects/hops reach it.
+ try (java.util.stream.Stream walk = Files.walk(dtoOut)) {
+ long priorityFiles = walk.filter(p -> p.getFileName().toString().equals("Priority.java")).count();
+ assertEquals("Priority.java must be emitted exactly once", 1, priorityFiles);
+ }
+
+ // Task.importance (Ticket.severity -> Priority, two hops) and Subtask.rank
+ // (Task.importance -> Ticket.severity -> Priority, three hops).
+ String taskDto = Files.readString(dtoOut.resolve("acme/shop/TaskDto.java"));
+ assertTrue("Task.importance must be typed as the shared enum; saw:\n" + taskDto,
+ taskDto.contains("Priority importance"));
+ String subtaskDto = Files.readString(dtoOut.resolve("acme/shop/SubtaskDto.java"));
+ assertTrue("Subtask.rank must be typed as the shared enum; saw:\n" + subtaskDto,
+ subtaskDto.contains("Priority rank"));
+ assertFalse("shared Priority must not be redeclared in SubtaskDto; saw:\n" + subtaskDto,
+ subtaskDto.contains("public enum Priority"));
+
+ // TicketSummary (a view-backed projection) reaches the shared Priority too.
+ String ticketSummaryDto = Files.readString(dtoOut.resolve("acme/shop/TicketSummaryDto.java"));
+ assertTrue("TicketSummary.level must be typed as the shared enum; saw:\n" + ticketSummaryDto,
+ ticketSummaryDto.contains("Priority level"));
+ assertFalse("shared Priority must not be redeclared in TicketSummaryDto; saw:\n" + ticketSummaryDto,
+ ticketSummaryDto.contains("public enum Priority"));
+
+ // TicketBadge (an object.value) reaches the shared Priority too — via SpringValueObjectGenerator.
+ Path voOut = tmp.newFolder().toPath();
+ Map args = new HashMap<>();
+ args.put("outputDir", voOut.toString());
+ args.put("providedEnumNamespace", "com.acme.ext");
+ SpringValueObjectGenerator voGen = new SpringValueObjectGenerator();
+ voGen.setArgs(args);
+ voGen.execute(loader);
+ String ticketBadge = Files.readString(voOut.resolve("acme/shop/TicketBadge.java"));
+ assertTrue("TicketBadge.badge must be typed as the shared enum; saw:\n" + ticketBadge,
+ ticketBadge.contains("Priority badge"));
+ assertFalse("shared Priority must not be redeclared in TicketBadge; saw:\n" + ticketBadge,
+ ticketBadge.contains("public enum Priority"));
+ }
+
+ @Test
+ public void refundFieldsResolveToDistinctSharedDeclarationsOnTheSameObject() throws Exception {
+ // Refund.ticketTier (via Ticket.tier) -> Priority; Refund.invoiceTier (via Invoice.tier)
+ // -> Money — two fields on ONE object whose immediate supers are same-named fields
+ // ("tier") of DIFFERENT objects, resolving to DIFFERENT shared declarations.
+ MetaDataLoader loader = loadShared();
+ Path out = run(loader, "com.acme.ext");
+ String refundDto = Files.readString(out.resolve("acme/shop/RefundDto.java"));
+
+ assertTrue("Refund.ticketTier must be typed Priority; saw:\n" + refundDto,
+ refundDto.contains("Priority ticketTier"));
+ assertTrue("Refund.invoiceTier must be typed Money; saw:\n" + refundDto,
+ refundDto.contains("Money invoiceTier"));
+ // Refund.unit -> Invoice.money -> the materialized Money.
+ assertTrue("Refund.unit must be typed as the materialized Money; saw:\n" + refundDto,
+ refundDto.contains("Money unit"));
+ // Refund.tender -> Ticket.currency -> the @provided Currency (never materialized).
+ assertTrue("Refund.tender must reference the configured external namespace; saw:\n" + refundDto,
+ refundDto.contains("com.acme.ext.Currency tender"));
+ assertFalse("provided Currency must never be nested in RefundDto; saw:\n" + refundDto,
+ refundDto.contains("public enum Currency"));
+
+ // Negative: Refund.orderStatus -> Order.status, which owns its own @values (inline).
+ assertFalse("Refund.orderStatus must NOT be typed as a shared enum; saw:\n" + refundDto,
+ refundDto.contains("Priority orderStatus") || refundDto.contains("Money orderStatus"));
+ }
+
+ @Test
+ public void ownValuesOrIntValueMapStopsTheWalkAndKeepsAnInlinePerObjectEnum() throws Exception {
+ // Task.phase (own @values) and Task.weight (own @intValueMap) each extend a field whose
+ // OWN chain reaches the shared Priority — but their own redeclaration stops the walk
+ // before it gets there, so they keep their per-object inline enum. Same for the
+ // Subtask fields that extend them.
+ MetaDataLoader loader = loadShared();
+ Path out = run(loader, "com.acme.ext");
+
+ String taskDto = Files.readString(out.resolve("acme/shop/TaskDto.java"));
+ assertTrue("Task.phase must keep its inline enum; saw:\n" + taskDto,
+ taskDto.contains("public enum TaskPhase"));
+ assertTrue("Task.weight must keep its inline enum; saw:\n" + taskDto,
+ taskDto.contains("public enum TaskWeight"));
+
+ String subtaskDto = Files.readString(out.resolve("acme/shop/SubtaskDto.java"));
+ assertTrue("Subtask.phase must keep its inline enum; saw:\n" + subtaskDto,
+ subtaskDto.contains("public enum SubtaskPhase"));
+ assertTrue("Subtask.weight must keep its inline enum; saw:\n" + subtaskDto,
+ subtaskDto.contains("public enum SubtaskWeight"));
+
+ String refundDto = Files.readString(out.resolve("acme/shop/RefundDto.java"));
+ assertTrue("Refund.orderStatus must keep its inline enum; saw:\n" + refundDto,
+ refundDto.contains("public enum RefundOrderStatus"));
+ }
+
+ @Test
+ public void sharedEnumMaterializesWhenTheOnlyGeneratedConsumerIsAProjection() throws Exception {
+ // No condition on the hosting object's subtype: a model whose ONLY object at all is a
+ // view-backed projection whose enum field extends a shared root-level abstract
+ // declaration still materializes that declaration — as narrow a "run" as there is.
+ String fixtureJson = """
+ {
+ "metadata.root": { "package": "acme::only", "children": [
+ { "field.enum": { "name": "Status", "abstract": true, "@values": ["DRAFT","PUBLISHED"] } },
+ { "object.projection": { "name": "StatusView", "children": [
+ { "source.rdb": { "@kind": "view", "@view": "v_status" } },
+ { "field.enum": { "name": "status", "extends": "acme::only::Status" } }
+ ] } }
+ ] }
+ }
+ """;
+ MetaDataLoader loader = SpringTestFixtures.loadFixture(
+ tmp.newFolder().toPath(), "projectionOnly", fixtureJson);
+ Path out = run(loader, "com.acme.ext");
+
+ assertTrue("Status.java must be materialized when a projection is the only consumer",
+ Files.exists(out.resolve("acme/only/Status.java")));
+ String statusViewDto = Files.readString(out.resolve("acme/only/StatusViewDto.java"));
+ assertTrue("StatusView.status must reference the shared Status; saw:\n" + statusViewDto,
+ statusViewDto.contains("Status status"));
+ assertFalse("shared Status must not be redeclared in StatusViewDto; saw:\n" + statusViewDto,
+ statusViewDto.contains("public enum Status"));
+ }
}
diff --git a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/GeneratedTypedEnumCompileRunTest.java b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/GeneratedTypedEnumCompileRunTest.java
index 260d319dd..7f6b35e18 100644
--- a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/GeneratedTypedEnumCompileRunTest.java
+++ b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/GeneratedTypedEnumCompileRunTest.java
@@ -127,16 +127,17 @@ public void sharedAbstractEnumEmitsOneTypeNamedForSuperUsedByBothFields() throws
generate(loader, gen);
- // The source must declare EXACTLY ONE nested enum named for the super (Priority), not
- // (CurrentPriority / PreviousPriority).
+ // The value object REFERENCES the shared declaration (ADR-0026 amendment: no condition on
+ // the hosting object's subtype), so its record declares no enum of its own and the
+ // shared type is emitted exactly once, as its own file, by the value-object run itself.
String src = Files.readString(gen.resolve("acme/orders/TicketPayload.java"));
- int firstDecl = src.indexOf("public enum Priority {");
- assertTrue("must emit a nested `public enum Priority`; saw:\n" + src, firstDecl >= 0);
- assertEquals("must emit EXACTLY ONE `enum Priority` (deduped); saw:\n" + src,
- firstDecl, src.lastIndexOf("public enum Priority {"));
- assertFalse("must NOT name the enum ; saw:\n" + src,
- src.contains("public enum CurrentPriority")
- || src.contains("public enum PreviousPriority"));
+ assertFalse("the record must reference the shared enum, not nest one; saw:\n" + src,
+ src.contains("enum "));
+ String shared = Files.readString(gen.resolve("acme/orders/Priority.java"));
+ int firstDecl = shared.indexOf("public enum Priority {");
+ assertTrue("must emit a standalone `public enum Priority`; saw:\n" + shared, firstDecl >= 0);
+ assertEquals("must emit EXACTLY ONE `enum Priority`; saw:\n" + shared,
+ firstDecl, shared.lastIndexOf("public enum Priority {"));
Path classes = compile(gen);
try (URLClassLoader cl = new URLClassLoader(
diff --git a/server/java/metadata/src/main/java/com/metaobjects/field/EnumField.java b/server/java/metadata/src/main/java/com/metaobjects/field/EnumField.java
index 6a0374521..f8d86f6fe 100644
--- a/server/java/metadata/src/main/java/com/metaobjects/field/EnumField.java
+++ b/server/java/metadata/src/main/java/com/metaobjects/field/EnumField.java
@@ -16,6 +16,8 @@
package com.metaobjects.field;
import com.metaobjects.DataTypes;
+import com.metaobjects.MetaData;
+import com.metaobjects.MetaRoot;
import com.metaobjects.attr.BooleanAttribute;
import com.metaobjects.attr.StringAttribute;
import com.metaobjects.registry.MetaDataRegistry;
@@ -23,7 +25,9 @@
import org.slf4j.LoggerFactory;
import java.util.ArrayList;
+import java.util.Collections;
import java.util.HashSet;
+import java.util.IdentityHashMap;
import java.util.List;
import java.util.Set;
import java.util.regex.Pattern;
@@ -309,4 +313,64 @@ public static boolean validateEnumValues(Object value) {
}
return true;
}
+
+ // -----------------------------------------------------------------------
+ // FR-019 — single shared-enum-declaration authority (ADR-0026)
+ // -----------------------------------------------------------------------
+ //
+ // ONE predicate decides "what is this enum field's type" for every JVM codegen site
+ // (Java/Spring and Kotlin) AND the #246 loader validator (ValidationPhase.sharedEnumSuper,
+ // immediate-super-only): a shared enum DECLARATION is an abstract field.enum that is a
+ // direct child of the metadata root. Resolving a field walks its extends chain and, at
+ // each node, in order: (a) the node IS a declaration -> that is the answer (nearest wins);
+ // (b) the node declares its OWN @values or OWN @intValueMap -> NONE (it starts a new,
+ // unshared vocabulary); (c) otherwise continue to its super. A non-enum node, the end of
+ // the chain, or a cycle answers NONE. There is no condition on the hosting object's
+ // subtype (entity / projection / value all qualify) and no hop limit.
+
+ /**
+ * True iff {@code node} is itself a shared-enum DECLARATION: an abstract {@code field.enum}
+ * that is a direct child of the metadata root ({@link MetaRoot}) — a sibling of
+ * {@code object.entity}, not a field nested inside an object. ADR-0039: both facts
+ * (own {@code @isAbstract}, and parentage) are declaration-layer and read own-only — neither
+ * is ever inherited through {@code extends}.
+ */
+ public static boolean isSharedEnumDeclaration(MetaField> node) {
+ if (!(node instanceof EnumField)) return false;
+ return isOwnAbstract(node) && node.getParent() instanceof MetaRoot;
+ }
+
+ private static boolean isOwnAbstract(MetaData node) {
+ return node.hasMetaAttr(MetaData.ATTR_IS_ABSTRACT, false)
+ && Boolean.TRUE.equals(node.getMetaAttr(MetaData.ATTR_IS_ABSTRACT, false).getValue());
+ }
+
+ /** True iff {@code node} declares its OWN {@code @values} or OWN {@code @intValueMap} —
+ * the rule-(b) "starts a new vocabulary" stop condition. Own-only (ADR-0039): an inherited
+ * {@code @values}/{@code @intValueMap} does not stop the walk at this node. */
+ private static boolean declaresOwnValuesOrIntMap(MetaField> node) {
+ return node.hasMetaAttr(ATTR_VALUES, false) || node.hasMetaAttr(ATTR_INT_VALUE_MAP, false);
+ }
+
+ /**
+ * Resolve the shared-enum declaration {@code field}'s {@code extends} chain reaches, per the
+ * FR-019 rule above, or {@code null} for an inline field (the field keeps its per-object
+ * generated type exactly as before this rule existed). Walks {@code field} itself first (a
+ * field extending a chain that, two hops up, redeclares its own {@code @values} answers NONE
+ * even when a further hop would reach a declaration), then each super in turn. Cycle-guarded
+ * by identity.
+ */
+ public static MetaField> resolveSharedEnumDeclaration(MetaField> field) {
+ if (!(field instanceof EnumField)) return null;
+ Set> seen = Collections.newSetFromMap(new IdentityHashMap<>());
+ MetaField> node = field;
+ while (node != null) {
+ if (!seen.add(node)) return null; // cycle
+ if (!(node instanceof EnumField)) return null; // non-enum node ends the chain
+ if (isSharedEnumDeclaration(node)) return node; // (a) nearest declaration wins
+ if (declaresOwnValuesOrIntMap(node)) return null; // (b) starts its own vocabulary
+ node = node.getSuperField(); // (c) continue
+ }
+ return null; // end of chain
+ }
}
diff --git a/server/java/metadata/src/main/java/com/metaobjects/loader/ValidationPhase.java b/server/java/metadata/src/main/java/com/metaobjects/loader/ValidationPhase.java
index 56a036fc4..ede515449 100644
--- a/server/java/metadata/src/main/java/com/metaobjects/loader/ValidationPhase.java
+++ b/server/java/metadata/src/main/java/com/metaobjects/loader/ValidationPhase.java
@@ -823,18 +823,26 @@ private static void validateEnumNode(MetaData node) {
/**
* The shared-enum super of {@code node}, or null when it has none.
*
- * "Shared" (FR-019 / #246) means the immediate super is abstract AND declared at
- * metadata-root — its parent is the {@link MetaRoot}, not an object. Such a declaration
+ *
"Shared" (FR-019 / #246) means the immediate super is itself a shared-enum
+ * DECLARATION — {@link EnumField#isSharedEnumDeclaration}: abstract AND declared at
+ * metadata-root (its parent is the {@link MetaRoot}, not an object). Such a declaration
* is materialized ONCE per port as a single named type, so anything a consuming field
* re-declares that belongs to the shared TYPE's contract (its {@code @values} member
* set, or the {@code @intValueMap} integer backing of that set) is a conflict.
*
- *
Immediate-super-only, matching codegen's {@code Fr019SharedEnum.resolveSharedEnumDecl}
- * so the validator and the shared-enum collapse agree on what "shared" means.
+ *
Immediate-super-only — this check does NOT walk multiple hops the way codegen's
+ * {@code EnumField.resolveSharedEnumDeclaration} does when resolving a field's generated
+ * type. The relationship is real, not coincidental: both call the SAME
+ * {@link EnumField#isSharedEnumDeclaration} predicate to decide "is this node a shared
+ * declaration" — this method applies it once, at the immediate super, because #246 is only
+ * a conflict when THIS node's own {@code @values}/{@code @intValueMap} collide with a
+ * declaration one hop away; a redeclaration two-or-more hops down the chain starts its own
+ * (non-shared) vocabulary instead of conflicting with anything, which is exactly what
+ * {@code resolveSharedEnumDeclaration}'s rule (b) also answers NONE for.
*/
private static MetaData sharedEnumSuper(MetaData node) {
MetaData sup = node.getSuperData();
- return (sup != null && isAbstract(sup) && sup.getParent() instanceof MetaRoot) ? sup : null;
+ return (sup instanceof MetaField> supField && EnumField.isSharedEnumDeclaration(supField)) ? sup : null;
}
/**
diff --git a/server/java/render/src/main/java/com/metaobjects/render/PayloadAccessors.java b/server/java/render/src/main/java/com/metaobjects/render/PayloadAccessors.java
index 2183d11bb..3eac8ff0c 100644
--- a/server/java/render/src/main/java/com/metaobjects/render/PayloadAccessors.java
+++ b/server/java/render/src/main/java/com/metaobjects/render/PayloadAccessors.java
@@ -23,35 +23,33 @@ public final class PayloadAccessors {
public static final String HAS_PREFIX = "has";
/**
- * Positional accessors injected into every map-shaped ELEMENT of a collection, so a list
+ * The positional accessor injected into every map-shaped ELEMENT of a collection, so a list
* separator needs no hand-computed {@code last} flag:
* {@code {{#items}}{{name}}{{^-last}}, {{/-last}}{{/items}}}. The leading {@code -} cannot
* collide with a field name and is still an ordinary Mustache tag name, so the template stays
* valid Mustache. A scalar element has nowhere to hold a key and carries none. An authored key
* of the same name wins, as for {@code has}.
*/
- public static final String FIRST_ACCESSOR = "-first";
public static final String LAST_ACCESSOR = "-last";
private PayloadAccessors() { /* no instances */ }
- /** Adds {@code -first}/{@code -last} to the map-shaped elements of an already-derived list. */
+ /** Adds {@code -last} to the map-shaped elements of an already-derived list. */
@SuppressWarnings("unchecked")
private static java.util.List withPositionalAccessors(java.util.List elements) {
int last = elements.size() - 1;
for (int i = 0; i < elements.size(); i++) {
if (elements.get(i) instanceof java.util.Map, ?> m) {
java.util.Map el = (java.util.Map) m;
- el.putIfAbsent(FIRST_ACCESSOR, i == 0);
el.putIfAbsent(LAST_ACCESSOR, i == last);
}
}
return elements;
}
- /** True when {@code name} is {@code -first}/{@code -last} and the scope is a collection element. */
+ /** True when {@code name} is {@code -last} and the scope is a collection element. */
public static boolean isPositionalAccessor(boolean inElement, String name) {
- return inElement && (FIRST_ACCESSOR.equals(name) || LAST_ACCESSOR.equals(name));
+ return inElement && LAST_ACCESSOR.equals(name);
}
/**
@@ -101,8 +99,9 @@ public static Boolean accessorValue(Object value) {
/**
* A view over {@code payload} carrying its derived {@code has} accessors,
- * recursively — for map-shaped payloads and Java records (viewed as the map of their
- * components).
+ * recursively — for map-shaped payloads, Java records (viewed as the map of their
+ * components) and getter-based beans, which covers Kotlin data classes (viewed as the map of
+ * their getters and public fields).
*
* Without it, the SAME payload data renders differently depending on the shape it
* arrived in, which is the divergence the shared {@code render-derived-has-accessor} fixture
@@ -187,6 +186,77 @@ private static Object withDerivedAccessors(Object payload, int depth) {
for (int i = 0; i < n; i++) out.add(withDerivedAccessors(java.lang.reflect.Array.get(payload, i), depth + 1));
return withPositionalAccessors(out);
}
+ if (isViewableBean(payload)) {
+ java.util.Map members = new java.util.LinkedHashMap<>();
+ for (MemberReader r : readersFor(payload.getClass())) {
+ try {
+ members.putIfAbsent(r.name(), r.read(payload));
+ } catch (ReflectiveOperationException | RuntimeException e) {
+ // Left out rather than failing the render, as for a record component.
+ }
+ }
+ return withDerivedAccessors(members, depth);
+ }
return payload;
}
+
+ // A getter-based bean's readable members, resolved ONCE per class like the record accessors.
+ // Names are the ones the Mustache engine already resolved on such an object: the getter's own
+ // name (getName, isActive), the property name it implies (name,
+ // active) and public fields. Only getters are read — a fluent method is never invoked
+ // as a side effect of viewing — and nothing declared by Object (so no getClass) is exposed.
+ private static final java.util.concurrent.ConcurrentHashMap, MemberReader[]> MEMBERS =
+ new java.util.concurrent.ConcurrentHashMap<>();
+
+ private record MemberReader(String name, java.lang.reflect.Method getter, java.lang.reflect.Field field) {
+ Object read(Object target) throws ReflectiveOperationException {
+ return getter != null ? getter.invoke(target) : field.get(target);
+ }
+ }
+
+ private static MemberReader[] readersFor(Class> type) {
+ return MEMBERS.computeIfAbsent(type, c -> {
+ java.util.List readers = new java.util.ArrayList<>();
+ for (java.lang.reflect.Method m : c.getMethods()) {
+ if (m.getDeclaringClass() == Object.class || m.getParameterCount() != 0
+ || java.lang.reflect.Modifier.isStatic(m.getModifiers())
+ || m.getReturnType() == void.class) continue;
+ String n = m.getName();
+ String property = n.startsWith("get") ? n.substring(3) : n.startsWith("is") ? n.substring(2) : null;
+ if (property == null || property.isEmpty() || !Character.isUpperCase(property.charAt(0))) continue;
+ try {
+ m.setAccessible(true);
+ } catch (RuntimeException e) {
+ continue;
+ }
+ readers.add(new MemberReader(n, m, null));
+ readers.add(new MemberReader(decapitalize(property), m, null));
+ }
+ for (java.lang.reflect.Field f : c.getFields()) {
+ if (java.lang.reflect.Modifier.isStatic(f.getModifiers())) continue;
+ try {
+ f.setAccessible(true);
+ } catch (RuntimeException e) {
+ continue;
+ }
+ readers.add(new MemberReader(f.getName(), null, f));
+ }
+ return readers.toArray(new MemberReader[0]);
+ });
+ }
+
+ /** The Java Beans rule: lowercase the first character unless the first two are both upper case. */
+ private static String decapitalize(String s) {
+ if (s.length() > 1 && Character.isUpperCase(s.charAt(0)) && Character.isUpperCase(s.charAt(1))) return s;
+ return Character.toLowerCase(s.charAt(0)) + s.substring(1);
+ }
+
+ /** A scalar-like or platform value keeps its own string form; only an application bean is viewed. */
+ private static boolean isViewableBean(Object payload) {
+ if (payload instanceof Enum> || payload instanceof CharSequence || payload instanceof Number
+ || payload instanceof Boolean || payload instanceof Character || payload instanceof Iterable>) return false;
+ String n = payload.getClass().getName();
+ return !(n.startsWith("java.") || n.startsWith("javax.") || n.startsWith("jdk.")
+ || n.startsWith("kotlin.") || n.startsWith("sun.") || n.startsWith("com.sun."));
+ }
}
diff --git a/server/java/render/src/main/java/com/metaobjects/render/PayloadField.java b/server/java/render/src/main/java/com/metaobjects/render/PayloadField.java
index 45dd0eba9..24c556d9c 100644
--- a/server/java/render/src/main/java/com/metaobjects/render/PayloadField.java
+++ b/server/java/render/src/main/java/com/metaobjects/render/PayloadField.java
@@ -12,22 +12,38 @@
* array-of-object). {@code fields} null → a scalar field (string / number /
* boolean / scalar array).
*
+ * {@code isArray} is the field's EFFECTIVE array-ness (a resolving read when built from real
+ * metadata). It is distinct from {@code fields != null}: an array of scalars is
+ * {@code isArray} with no {@code fields}; a single nested object has {@code fields} and is not
+ * {@code isArray}. Together they decide whether {@code -last} is available inside a section over
+ * this field (see {@link Verify}).
+ *
*
Mirrors {@code server/csharp/MetaObjects.Render/Verify.cs}
* {@code PayloadField} record.
*/
-public record PayloadField(String name, List fields) {
+public record PayloadField(String name, List fields, boolean isArray) {
public PayloadField {
Objects.requireNonNull(name, "name");
}
- /** Convenience factory: a scalar field with no nested children. */
+ /** Convenience factory: a single (non-array) scalar field. */
public static PayloadField scalar(String name) {
- return new PayloadField(name, null);
+ return new PayloadField(name, null, false);
+ }
+
+ /** Convenience factory: a scalar field, an array of scalars when {@code isArray}. */
+ public static PayloadField scalar(String name, boolean isArray) {
+ return new PayloadField(name, null, isArray);
}
- /** Convenience factory: a context-pushing object/array-of-object field. */
+ /** Convenience factory: a single (non-array) nested-object field. */
public static PayloadField object(String name, List children) {
- return new PayloadField(name, List.copyOf(children));
+ return new PayloadField(name, List.copyOf(children), false);
+ }
+
+ /** Convenience factory: a nested-object field, an array of objects when {@code isArray}. */
+ public static PayloadField object(String name, List children, boolean isArray) {
+ return new PayloadField(name, List.copyOf(children), isArray);
}
}
diff --git a/server/java/render/src/main/java/com/metaobjects/render/Verify.java b/server/java/render/src/main/java/com/metaobjects/render/Verify.java
index 333d6ae97..1e403fcd5 100644
--- a/server/java/render/src/main/java/com/metaobjects/render/Verify.java
+++ b/server/java/render/src/main/java/com/metaobjects/render/Verify.java
@@ -161,7 +161,7 @@ private static void walk(
if (atRoot) ctx.referencedAtRoot().add(s.value().split("\\.")[0]);
PayloadField field = resolve(stack, s.value());
if (field == null) {
- // A positional {{#-first}} / {{^-last}} gate is a BOOLEAN on the current
+ // A positional {{^-last}} gate is a BOOLEAN on the current
// collection element: it pushes nothing, so the body walks in the SAME scope.
if (PayloadAccessors.isPositionalAccessor(inElement, s.value())) {
walk(s.children(), stack, seen, ctx, inElement);
@@ -184,12 +184,21 @@ private static void walk(
// `#` over a container pushes its element fields; `^` (and `#`
// over a scalar, used as a conditional) keep the current context.
boolean push = !s.inverted() && field.fields() != null;
+ // `-last` is available in the body only when the INNERMOST enclosing ARRAY
+ // section iterates OBJECT elements: a `#` over an array field sets the flag
+ // fresh (true for array-of-objects, false for array-of-scalars, which carry
+ // no element key even inside an outer object array). Any other section
+ // (scalar conditional, single nested object, `^`) is not an iteration, so it
+ // inherits the enclosing scope's flag unchanged.
+ boolean childInElement = !s.inverted() && field.isArray()
+ ? field.fields() != null
+ : inElement;
if (push) {
List> next = new ArrayList<>(stack);
next.add(field.fields());
- walk(s.children(), Collections.unmodifiableList(next), seen, ctx, true);
+ walk(s.children(), Collections.unmodifiableList(next), seen, ctx, childInElement);
} else {
- walk(s.children(), stack, seen, ctx, inElement);
+ walk(s.children(), stack, seen, ctx, childInElement);
}
}
case PartialTok p -> {
diff --git a/server/java/render/src/test/java/com/metaobjects/render/TypedPayloadRenderTest.java b/server/java/render/src/test/java/com/metaobjects/render/TypedPayloadRenderTest.java
new file mode 100644
index 000000000..c1e6a7237
--- /dev/null
+++ b/server/java/render/src/test/java/com/metaobjects/render/TypedPayloadRenderTest.java
@@ -0,0 +1,135 @@
+package com.metaobjects.render;
+
+import org.junit.Test;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.Assert.assertEquals;
+
+/**
+ * A typed payload must render byte-identically to the equal map payload — {@code -last} and
+ * {@code has} come from one mechanism whatever shape the payload arrives in. The oracle is
+ * the shared render-list-separator fixture, whose expected.txt every port renders identically.
+ */
+public class TypedPayloadRenderTest {
+
+ // ---- Records -------------------------------------------------------------------------
+ public record RItem(String name) {}
+ public record RTag(String label) {}
+ public record RGroup(List tags) {}
+ public record ROwner(String nick) {}
+ public record REntry(String name, String note, ROwner owner) {}
+ public record RPayload(List items, List solo, List none, List groups,
+ List entries, List> authored) {}
+
+ // ---- Getter-based beans (also the shape of a Kotlin data class: getX() per property) -----
+ public static class BItem {
+ private final String name;
+ public BItem(String name) { this.name = name; }
+ public String getName() { return name; }
+ }
+ public static class BTag {
+ private final String label;
+ public BTag(String label) { this.label = label; }
+ public String getLabel() { return label; }
+ }
+ public static class BGroup {
+ private final List tags;
+ public BGroup(List tags) { this.tags = tags; }
+ public List getTags() { return tags; }
+ }
+ public static class BOwner {
+ private final String nick;
+ public BOwner(String nick) { this.nick = nick; }
+ public String getNick() { return nick; }
+ }
+ public static class BEntry {
+ private final String name;
+ private final String note;
+ private final BOwner owner;
+ public BEntry(String name, String note, BOwner owner) { this.name = name; this.note = note; this.owner = owner; }
+ public String getName() { return name; }
+ public String getNote() { return note; }
+ public BOwner getOwner() { return owner; }
+ }
+ public static class BPayload {
+ public List getItems() { return List.of(new BItem("a"), new BItem("b"), new BItem("c")); }
+ public List getSolo() { return List.of(new BItem("only")); }
+ public List getNone() { return List.of(); }
+ public List getGroups() {
+ return List.of(new BGroup(List.of(new BTag("x"), new BTag("y"))), new BGroup(List.of(new BTag("z"))));
+ }
+ public List getEntries() {
+ return List.of(new BEntry("a", "x", new BOwner("ann")), new BEntry("b", null, new BOwner("bob")),
+ new BEntry("c", "z", new BOwner("cy")));
+ }
+ public List> getAuthored() { return authored(); }
+ }
+
+ private static List> authored() {
+ List> out = new ArrayList<>();
+ for (String n : List.of("p", "q")) {
+ Map m = new LinkedHashMap<>();
+ m.put("name", n);
+ m.put("-last", true);
+ out.add(m);
+ }
+ return out;
+ }
+
+ private static Path fixtureDir() {
+ Path p = Paths.get(System.getProperty("user.dir")).toAbsolutePath();
+ while (p != null && !Files.exists(p.resolve("fixtures/render-conformance"))) p = p.getParent();
+ return p.resolve("fixtures/render-conformance/render-list-separator");
+ }
+
+ private static String read(String file) throws IOException {
+ return Files.readString(fixtureDir().resolve(file), StandardCharsets.UTF_8);
+ }
+
+ private static String render(String template, Object payload) {
+ return new Renderer().render(new RenderRequest(
+ template, null, payload, new InMemoryProvider(Map.of()), "text", null, null));
+ }
+
+ @Test
+ public void recordPayloadRendersTheSharedFixtureByteIdentically() throws IOException {
+ RPayload payload = new RPayload(
+ List.of(new RItem("a"), new RItem("b"), new RItem("c")),
+ List.of(new RItem("only")), List.of(),
+ List.of(new RGroup(List.of(new RTag("x"), new RTag("y"))), new RGroup(List.of(new RTag("z")))),
+ List.of(new REntry("a", "x", new ROwner("ann")), new REntry("b", null, new ROwner("bob")),
+ new REntry("c", "z", new ROwner("cy"))),
+ authored());
+ assertEquals(read("expected.txt"), render(read("template.mustache"), payload));
+ }
+
+ @Test
+ public void beanPayloadRendersTheSharedFixtureByteIdentically() throws IOException {
+ assertEquals(read("expected.txt"), render(read("template.mustache"), new BPayload()));
+ }
+
+ @Test
+ public void hasFieldGateWorksOnBeanElementsLikeOnMaps() {
+ String tpl = "{{#items}}{{name}}:{{#hasNote}}y{{/hasNote}}{{^hasNote}}n{{/hasNote}} {{/items}}";
+ Map viaMaps = Map.of("items", List.of(
+ Map.of("name", "a", "note", "x"), Map.of("name", "b", "note", " ")));
+ Map viaBeans = Map.of("items", List.of(
+ new BEntry("a", "x", null), new BEntry("b", " ", null)));
+ assertEquals("a:y b:n ", render(tpl, viaMaps));
+ assertEquals(render(tpl, viaMaps), render(tpl, viaBeans));
+ }
+
+ @Test
+ public void getClassIsNotExposedOnABean() {
+ assertEquals("[]", "[" + render("{{class}}{{getClass}}", new BItem("a")) + "]");
+ }
+}
diff --git a/server/java/render/src/test/java/com/metaobjects/render/VerifyConformanceTest.java b/server/java/render/src/test/java/com/metaobjects/render/VerifyConformanceTest.java
index 739e33f0a..09e59143a 100644
--- a/server/java/render/src/test/java/com/metaobjects/render/VerifyConformanceTest.java
+++ b/server/java/render/src/test/java/com/metaobjects/render/VerifyConformanceTest.java
@@ -154,10 +154,11 @@ private static List parseFields(JsonNode array) {
private static PayloadField parseField(JsonNode el) {
String name = el.get("name").asText();
+ boolean isArray = el.path("isArray").asBoolean(false);
if (el.has("fields") && el.get("fields").isArray()) {
- return new PayloadField(name, parseFields(el.get("fields")));
+ return new PayloadField(name, parseFields(el.get("fields")), isArray);
}
- return PayloadField.scalar(name);
+ return PayloadField.scalar(name, isArray);
}
private static List stringArray(JsonNode array) {
diff --git a/server/java/render/src/test/java/com/metaobjects/render/VerifyTest.java b/server/java/render/src/test/java/com/metaobjects/render/VerifyTest.java
index 6b3883b3b..133501132 100644
--- a/server/java/render/src/test/java/com/metaobjects/render/VerifyTest.java
+++ b/server/java/render/src/test/java/com/metaobjects/render/VerifyTest.java
@@ -234,4 +234,35 @@ public void acceptedAccessorNameMatchesSharedRule() {
assertEquals(1, drift.size());
assertEquals("ERR_VAR_NOT_ON_PAYLOAD", drift.get(0).code());
}
+
+ // ---- -last availability: the innermost enclosing ARRAY section must iterate objects ----
+
+ private static final List POSITIONAL_PAYLOAD = List.of(
+ PayloadField.object("posts", List.of(
+ PayloadField.scalar("title"),
+ PayloadField.scalar("note"),
+ PayloadField.object("author", List.of(PayloadField.scalar("name"))),
+ PayloadField.scalar("labels", true)), true));
+
+ @Test
+ public void lastIsAcceptedInsideAnObjectListSection() {
+ assertTrue(Verify.check("{{#posts}}{{title}}{{^-last}}, {{/-last}}{{/posts}}",
+ POSITIONAL_PAYLOAD, VerifyOptions.empty()).isEmpty());
+ }
+
+ @Test
+ public void lastIsRejectedInsideAScalarListNestedInAnObjectList() {
+ var errors = Verify.check("{{#posts}}{{#labels}}{{.}}{{^-last}}, {{/-last}}{{/labels}}{{/posts}}",
+ POSITIONAL_PAYLOAD, VerifyOptions.empty());
+ assertEquals(1, errors.size());
+ assertEquals("ERR_VAR_NOT_ON_PAYLOAD", errors.get(0).code());
+ assertEquals("-last", errors.get(0).path());
+ }
+
+ @Test
+ public void scalarConditionalAndSingleObjectSectionsAreTransparentToLast() {
+ assertTrue(Verify.check(
+ "{{#posts}}{{#note}}{{note}}{{^-last}}; {{/-last}}{{/note}}{{#author}}{{name}}{{^-last}}, {{/-last}}{{/author}}{{/posts}}",
+ POSITIONAL_PAYLOAD, VerifyOptions.empty()).isEmpty());
+ }
}
diff --git a/server/java/render/src/test/resources/snapshots/render-list-separator.txt b/server/java/render/src/test/resources/snapshots/render-list-separator.txt
index 4072c5c26..c786b4029 100644
--- a/server/java/render/src/test/resources/snapshots/render-list-separator.txt
+++ b/server/java/render/src/test/resources/snapshots/render-list-separator.txt
@@ -1,6 +1,7 @@
items: a, b, c
-lead: (first) a b c
solo: only
none: []
nested: [x|y];[z]
+conditional: a(x); bc(z)
+object: ann, bob, cy
authored: pq
diff --git a/server/python/src/metaobjects/codegen/generators/fr019_shared_enum.py b/server/python/src/metaobjects/codegen/generators/fr019_shared_enum.py
index 0e7c85493..f37ec0ad4 100644
--- a/server/python/src/metaobjects/codegen/generators/fr019_shared_enum.py
+++ b/server/python/src/metaobjects/codegen/generators/fr019_shared_enum.py
@@ -23,10 +23,12 @@
from dataclasses import dataclass
-from metaobjects.meta.core.field.meta_field import MetaField
+from metaobjects.meta.core.field.meta_field import (
+ MetaField,
+ resolve_shared_enum_declaration,
+)
from metaobjects.meta.core.field import field_constants as fc
from metaobjects.meta.meta_data import MetaData
-from metaobjects.shared.base_types import TYPE_METADATA
from metaobjects.shared.separators import PACKAGE_SEP
from metaobjects.codegen import type_map
from metaobjects.codegen.config import GenConfig
@@ -47,27 +49,6 @@ def _pascal(name: str) -> str:
return name[:1].upper() + name[1:] if name else name
-def resolve_shared_enum_decl(field: MetaField) -> MetaData | None:
- """The package-level abstract ``field.enum`` *field* resolves to via ``extends``, or ``None``
- for an inline enum. The ``extends`` chain is walked and the NEAREST super that is (a) a
- ``field.enum``, (b) abstract, AND (c) a direct child of the metadata root — a sibling of
- ``object.entity``, not an abstract field nested inside an object — wins, so a multi-hop
- chain through concrete enum fields still reaches the shared declaration."""
- if field.sub_type != fc.FIELD_SUBTYPE_ENUM:
- return None
- seen = {id(field)}
- sup = field.super_data
- while sup is not None and id(sup) not in seen:
- if sup.sub_type != fc.FIELD_SUBTYPE_ENUM:
- return None
- parent = sup.parent
- if getattr(sup, "is_abstract", False) and parent is not None and parent.type == TYPE_METADATA:
- return sup
- seen.add(id(sup))
- sup = sup.super_data
- return None
-
-
def is_provided(decl: MetaData) -> bool:
"""``@provided`` truth of an enum DECLARATION.
@@ -94,11 +75,16 @@ def _meta_package_of(decl: MetaData) -> str:
def shared_enum_for_field(field: MetaField) -> SharedEnum | None:
- """The shared-enum descriptor *field* resolves to, or ``None`` for inline enums."""
- decl = resolve_shared_enum_decl(field)
+ """The shared-enum descriptor *field* resolves to, or ``None`` for inline enums.
+
+ The member set is read off the DECLARATION *decl* (a resolving ``attrs()``
+ read, never off *field*) so materialization cannot depend on which consuming
+ field a generator happens to visit first — every field resolving to the same
+ shared declaration must describe that declaration identically."""
+ decl = resolve_shared_enum_declaration(field)
if decl is None:
return None
- values = type_map.effective_enum_values(field)
+ values = type_map.effective_enum_values(decl)
if not values:
return None
return SharedEnum(
diff --git a/server/python/src/metaobjects/codegen/generators/render_helper_generator.py b/server/python/src/metaobjects/codegen/generators/render_helper_generator.py
index bcd3d5013..0dbbb5afb 100644
--- a/server/python/src/metaobjects/codegen/generators/render_helper_generator.py
+++ b/server/python/src/metaobjects/codegen/generators/render_helper_generator.py
@@ -136,21 +136,31 @@ def _derive_payload_field_tree(
target = _resolve_nested_object_ref(root, ref, field_pkg)
if target is not None and target.sub_type == OBJECT_SUBTYPE_VALUE:
children = _derive_payload_field_tree(root, target, next_seen)
- fields.append(PayloadField(f.name, children))
+ # ADR-0039 resolving read: an array of this VO (vs a single
+ # nested instance) gates `-last` availability in `verify`.
+ fields.append(
+ PayloadField(f.name, children, is_array=f.resolved_is_array())
+ )
continue
- fields.append(PayloadField(f.name))
+ fields.append(PayloadField(f.name, is_array=f.resolved_is_array()))
return fields
def _field_tree_literal(fields: list[PayloadField]) -> str:
"""Emit a ``list[PayloadField]`` as a deterministic Python literal:
- ``[PayloadField("name"), PayloadField("nested", [PayloadField("x")])]``."""
+ ``[PayloadField("name"), PayloadField("nested", [PayloadField("x")])]``.
+
+ The array flag is emitted only when true, so a helper with no array field
+ stays byte-identical to before ``is_array`` existed."""
parts: list[str] = []
for f in fields:
+ args = [_py_str(f.name)]
if f.fields is not None:
- parts.append(f"PayloadField({_py_str(f.name)}, {_field_tree_literal(f.fields)})")
- else:
- parts.append(f"PayloadField({_py_str(f.name)})")
+ args.append(_field_tree_literal(f.fields))
+ elif f.is_array:
+ args.append("None") # fill the positional `fields` slot before the kwarg
+ kw = ", is_array=True" if f.is_array else ""
+ parts.append(f"PayloadField({', '.join(args)}{kw})")
return "[" + ", ".join(parts) + "]"
diff --git a/server/python/src/metaobjects/loader/validation_passes.py b/server/python/src/metaobjects/loader/validation_passes.py
index 00ed8d161..6869e78e4 100644
--- a/server/python/src/metaobjects/loader/validation_passes.py
+++ b/server/python/src/metaobjects/loader/validation_passes.py
@@ -49,6 +49,7 @@
FIELD_SUBTYPE_URI,
FIELD_SUBTYPE_UUID,
)
+from ..meta.core.field.meta_field import is_shared_enum_declaration
from ..meta.persistence.db.db_constants import (
DB_COLUMN_TYPE_JSONB,
DB_COLUMN_TYPE_UUID,
@@ -74,7 +75,6 @@
TYPE_IDENTITY,
TYPE_INDEX,
TYPE_LAYOUT,
- TYPE_METADATA,
TYPE_OBJECT,
TYPE_ORIGIN,
TYPE_RELATIONSHIP,
@@ -604,32 +604,6 @@ def _validate_attr_schema(
_ENUM_MEMBER_RE = re.compile(ENUM_MEMBER_PATTERN)
-def _shared_enum_super(node: MetaData) -> MetaData | None:
- """The shared-enum super of ``node``, or ``None`` when it has none.
-
- "Shared" (FR-019 / #246) means the immediate super is abstract AND declared at
- metadata-root — its parent is the ``metadata.root`` node, not an object. Such a
- declaration is materialized ONCE per port as a single named type, so anything a
- consuming field re-declares that belongs to the shared TYPE's contract (its
- ``@values`` member set, or the ``@intValueMap`` integer backing of that set) is a
- conflict. A concrete super, or a non-root abstract super (e.g. one nested inside
- an object), is legal and not flagged.
-
- Immediate-super-only, matching codegen's ``resolve_shared_enum_decl``
- (``codegen/generators/fr019_shared_enum.py``) so the validator and the
- shared-enum collapse agree on what "shared" means.
- """
- sup = node.super_data
- if (
- sup is not None
- and sup.is_abstract
- and sup.parent is not None
- and sup.parent.type == TYPE_METADATA
- ):
- return sup
- return None
-
-
def _validate_enum_values(
root: MetaData,
errors: list[MetaError],
@@ -709,8 +683,13 @@ def _validate_enum_values(
# #246: own @values AND extends a shared package-level abstract enum —
# one shared enum type has one member set, so the own @values would be
# silently dropped by the shared-enum codegen collapse. Mirrors the TS
- # reference (attr-schema-validate.ts).
- if _shared_enum_super(node) is not None:
+ # reference (attr-schema-validate.ts). IMMEDIATE super only — the single
+ # authority is ``is_shared_enum_declaration``
+ # (``meta/core/field/meta_field.py``), the same predicate
+ # ``resolve_shared_enum_declaration`` uses at every step of its walk, so a
+ # mid-chain redeclaration further up still loads (only a value/map owned
+ # directly under a shared declaration conflicts).
+ if node.super_data is not None and is_shared_enum_declaration(node.super_data):
errors.append(
MetaError(
f"{label} declares its own '@{FIELD_ATTR_VALUES}' but extends "
@@ -779,9 +758,10 @@ def _validate_enum_int_value_map(node: MetaData, errors: list[MetaError]) -> Non
# shared enum is materialized once as a single type, so a per-field map would
# give one logical type N storage encodings (and, where a port emits per-TYPE
# codec artifacts, two same-named declarations). Same remedy as the @values
- # half: declare it on the shared declaration and inherit it.
- shared_super = _shared_enum_super(node)
- if shared_super is not None:
+ # half: declare it on the shared declaration and inherit it. IMMEDIATE super
+ # only — same ``is_shared_enum_declaration`` authority as the @values check
+ # above.
+ if node.super_data is not None and is_shared_enum_declaration(node.super_data):
errors.append(
MetaError(
f"{label} declares its own '@{FIELD_ATTR_INT_VALUE_MAP}' but extends "
diff --git a/server/python/src/metaobjects/meta/core/field/meta_field.py b/server/python/src/metaobjects/meta/core/field/meta_field.py
index 0b00c3b3f..8f210c23e 100644
--- a/server/python/src/metaobjects/meta/core/field/meta_field.py
+++ b/server/python/src/metaobjects/meta/core/field/meta_field.py
@@ -2,7 +2,7 @@
from __future__ import annotations
from ....datatype import DataType
-from ....shared.base_types import TYPE_ORIGIN
+from ....shared.base_types import TYPE_FIELD, TYPE_METADATA, TYPE_ORIGIN
from ...meta_data import MetaData
from . import field_constants as fc
@@ -102,3 +102,69 @@ def set_value(
obj.set(key, value)
return
setattr(obj, key, value)
+
+
+def is_shared_enum_declaration(node: MetaData) -> bool:
+ """A "shared enum declaration" (FR-019 / #246): a ``field.enum`` that is
+ abstract AND a direct child of the metadata root — a sibling of
+ ``object.entity``, not a field nested inside an object. This is the ONE
+ predicate the loader validator and every codegen enum-naming/materializing
+ site call to decide what "shared" means (single authority in the meta layer,
+ not duplicated in codegen or the loader). See
+ :func:`resolve_shared_enum_declaration` for the full resolution rule."""
+ if node.type != TYPE_FIELD or node.sub_type != fc.FIELD_SUBTYPE_ENUM:
+ return False
+ parent = node.parent
+ return bool(node.is_abstract and parent is not None and parent.type == TYPE_METADATA)
+
+
+def _declares_own_enum_vocabulary(node: MetaData) -> bool:
+ """ADR-0039 sanctioned own read: whether *node* itself declares an OWN
+ ``@values`` or OWN ``@intValueMap`` — i.e. starts a new enum vocabulary rather
+ than inheriting one. This is the deliberate own/resolving split in
+ :func:`resolve_shared_enum_declaration`'s rule (b): the walk must see whether
+ THIS node introduces its own member set, which an effective/resolving read
+ would mask by reporting the inherited value instead."""
+ return (
+ node.attr(fc.FIELD_ATTR_VALUES) is not None
+ or node.attr(fc.FIELD_ATTR_INT_VALUE_MAP) is not None
+ )
+
+
+def resolve_shared_enum_declaration(field: MetaData) -> MetaData | None:
+ """The shared enum DECLARATION *field* resolves to via its ``extends`` chain,
+ or ``None`` when it resolves to none (the common inline case — a per-object
+ ``field.enum`` with its own ``@values``, or a chain that never reaches a
+ shared declaration).
+
+ Walks from *field* itself up through ``extends``. At each node, in order:
+
+ (a) the node IS a shared declaration (:func:`is_shared_enum_declaration`) —
+ that node wins. Nearest wins: a chained root abstract (``Money extends
+ Currency``) answers ``Money``, never continuing on to ``Currency``.
+ (b) the node declares its OWN ``@values``/``@intValueMap``
+ (:func:`_declares_own_enum_vocabulary`) — the answer is ``None``. The node
+ starts a new vocabulary, so nothing further up the chain counts, even a
+ mid-chain redeclaration the loader's own #246 check still allows to load.
+ (c) otherwise, continue to the node's super.
+
+ A non-enum node, the end of the chain, or a cycle answers ``None``. There is
+ no condition on the hosting object's subtype (entity, projection, value
+ object all qualify) and no hop limit.
+
+ THE single authority both the loader's #246 validator (which calls
+ :func:`is_shared_enum_declaration` on the field's IMMEDIATE super only) and
+ every codegen enum-naming/materializing site call — so they cannot drift
+ (ADR-0039)."""
+ if field.sub_type != fc.FIELD_SUBTYPE_ENUM:
+ return None
+ seen: set[int] = set()
+ node: MetaData | None = field
+ while node is not None and node.sub_type == fc.FIELD_SUBTYPE_ENUM and id(node) not in seen:
+ seen.add(id(node))
+ if is_shared_enum_declaration(node):
+ return node
+ if _declares_own_enum_vocabulary(node):
+ return None
+ node = node.super_data
+ return None
diff --git a/server/python/src/metaobjects/render/payload_accessors.py b/server/python/src/metaobjects/render/payload_accessors.py
index c97ac0611..64d3ea79e 100644
--- a/server/python/src/metaobjects/render/payload_accessors.py
+++ b/server/python/src/metaobjects/render/payload_accessors.py
@@ -27,7 +27,6 @@
"accessor_value",
"with_derived_accessors",
"is_boolean_accessor",
- "FIRST_ACCESSOR",
"LAST_ACCESSOR",
"is_positional_accessor",
]
@@ -35,12 +34,11 @@
#: The ``has`` prefix every derived boolean accessor carries.
HAS_PREFIX = "has"
-#: Positional accessors injected into every mapping ELEMENT of a collection, so a list
+#: Positional accessor injected into every mapping ELEMENT of a collection, so a list
#: separator needs no hand-computed ``last`` flag: ``{{#items}}{{name}}{{^-last}}, {{/-last}}{{/items}}``.
#: The leading ``-`` cannot collide with a field name and is still an ordinary Mustache tag
#: name, so the template stays valid Mustache. Scalar elements carry none (nowhere to hold a
#: key). An authored key of the same name wins, as for ``has``.
-FIRST_ACCESSOR = "-first"
LAST_ACCESSOR = "-last"
_MAX_DEPTH = 32
@@ -137,7 +135,6 @@ def with_derived_accessors(payload: Any, depth: int = 0) -> Any:
for i, v in enumerate(payload):
el = with_derived_accessors(v, depth + 1)
if isinstance(el, dict):
- el.setdefault(FIRST_ACCESSOR, i == 0)
el.setdefault(LAST_ACCESSOR, i == last)
items.append(el)
return items
@@ -164,7 +161,10 @@ def is_boolean_accessor(stack: list[list[Any]], name: str) -> bool:
def is_positional_accessor(in_element: bool, name: str) -> bool:
- """True when ``name`` is ``-first`` / ``-last`` and the current scope is an element of a
- collection section. Permissive about shape, like ``is_boolean_accessor``: ``in_element`` is
- true whenever the innermost context was pushed by a ``{{#x}}`` over a container field."""
- return in_element and name in (FIRST_ACCESSOR, LAST_ACCESSOR)
+ """True when ``name`` is ``-last`` and *in_element* says it is available here.
+
+ ``in_element`` is the caller's own precomputed flag (see ``render/verify.py``'s
+ walk): true only when the nearest enclosing ARRAY section iterates object
+ elements — an array of scalars carries no element to hold the key on, so it
+ is unavailable there even nested inside an outer object array."""
+ return in_element and name == LAST_ACCESSOR
diff --git a/server/python/src/metaobjects/render/verify.py b/server/python/src/metaobjects/render/verify.py
index 2443c064c..bb3264640 100644
--- a/server/python/src/metaobjects/render/verify.py
+++ b/server/python/src/metaobjects/render/verify.py
@@ -47,10 +47,18 @@ class PayloadField:
``fields`` present → a context-pushing field (object / array-of-object);
``fields is None`` → a scalar (string/number/boolean/scalar-array).
+
+ ``is_array`` is the field's EFFECTIVE ``isArray`` (resolving read, when built
+ from real metadata) — distinct from ``fields is not None``: an array of
+ SCALARS has ``is_array=True`` and ``fields=None``; a single nested object has
+ ``is_array=False`` and ``fields`` present. The combination of the two decides
+ whether ``-last`` is available inside a ``{{#x}}`` section over this field
+ (see the walk in :func:`verify`).
"""
name: str
fields: list[PayloadField] | None = None
+ is_array: bool = False
@dataclass(frozen=True)
@@ -234,7 +242,7 @@ def walk(
referenced_at_root.add(tok.value.split(".")[0])
field = _resolve(stack, tok.value)
if field is None:
- # A positional `-first` / `-last` gate is a BOOLEAN on the current element:
+ # A positional `-last` gate is a BOOLEAN on the current element:
# it pushes nothing, so the body walks in the SAME scope.
if is_positional_accessor(in_element, tok.value):
walk(tok.children, stack, seen, in_element)
@@ -249,12 +257,22 @@ def walk(
# context is unknowable, walking it would cascade false errors).
errors.append(VerifyError(ERR_VAR_NOT_ON_PAYLOAD, tok.value))
continue
- # `#` over a container pushes its element fields; `^` (and `#` over a
- # scalar, used as a conditional) keep the current context.
- if not tok.inverted and field.fields is not None:
- walk(tok.children, [*stack, field.fields], seen, True)
- else:
+ # `^` (inverted) never pushes and never changes whether `-last` is
+ # available — it always inherits the enclosing flag unchanged.
+ if tok.inverted:
walk(tok.children, stack, seen, in_element)
+ continue
+ # `#`: an ARRAY section sets the flag explicitly — true only when
+ # its elements are objects (there is an element shape to carry
+ # `-last` on); an array of scalars rules `-last` OUT here, even
+ # when an outer object array enclosed this section. A non-array
+ # section (object or scalar conditional) changes nothing of its
+ # own — it inherits whatever the enclosing context already had.
+ next_in_element = field.fields is not None if field.is_array else in_element
+ if field.fields is not None:
+ walk(tok.children, [*stack, field.fields], seen, next_in_element)
+ else:
+ walk(tok.children, stack, seen, next_in_element)
else: # _Partial
if provider is None:
continue # can't resolve without a provider
diff --git a/server/python/tests/codegen/test_fr019_shared_provided_conformance.py b/server/python/tests/codegen/test_fr019_shared_provided_conformance.py
index 1bfb2b7b8..c4e11480f 100644
--- a/server/python/tests/codegen/test_fr019_shared_provided_conformance.py
+++ b/server/python/tests/codegen/test_fr019_shared_provided_conformance.py
@@ -10,6 +10,8 @@
materialized; consuming models import it from the configured module. A referenced ``@provided``
enum with no configured module is a codegen-time error naming the enum.
"""
+import importlib
+import sys
from pathlib import Path
import pytest
@@ -19,16 +21,27 @@
import json
from metaobjects import InMemoryStringSource, MetaDataFormat, MetaDataLoader
+from metaobjects.meta.core.field import field_constants as fc
+from metaobjects.meta.core.field.meta_field import resolve_shared_enum_declaration
from metaobjects.meta.core.object.meta_object import MetaObject
from metaobjects.codegen.config import GenConfig
from metaobjects.codegen.generator import GenContext
-from metaobjects.codegen.generators.entity_model import EntityModelGenerator
+from metaobjects.codegen.generators.entity_model import EntityModelGenerator, entity_model
+from metaobjects.codegen.generators.fr019_shared_enum import (
+ is_provided,
+ materialized_shared_enums,
+)
+from metaobjects.codegen.runner import run_gen
_FIXTURE_DIR = (
Path(__file__).resolve().parents[4]
/ "fixtures/codegen-conformance/shared-provided-enum/input"
)
+_EXPECTED_ENUM_TYPES = (
+ Path(__file__).resolve().parents[4]
+ / "fixtures/codegen-conformance/shared-provided-enum/expected-enum-types.json"
+)
def _emit(config: GenConfig) -> dict[str, str]:
@@ -56,7 +69,10 @@ def test_shared_enum_is_materialized_once_and_referenced_by_both_entities() -> N
src = files[name]
assert "priority: Priority" in src, f"{name} must type priority as the shared enum"
assert "from .enums import Priority" in src, f"{name} must import the materialized enum"
- assert "Literal" not in src, f"{name} must not inline the shared enum as a Literal"
+ # Scoped to the `priority` field itself — the extended fixture also gives
+ # Order its own inline enum (`status`), so the file as a whole legitimately
+ # contains a Literal for that unrelated field.
+ assert "priority: Literal" not in src, f"{name} must not inline priority as a Literal"
def test_provided_enum_is_not_materialized_and_imported_from_the_configured_module() -> None:
@@ -106,4 +122,148 @@ def test_multi_hop_enum_extends_still_resolves_to_the_shared_enum() -> None:
task = files["Task.py"]
assert "urgency: Priority" in task
assert "from .enums import Priority" in task
- assert "Literal" not in task
+ # Scoped to `urgency` — the extended fixture also gives Task its own
+ # inline/mid-chain-broken enums (`phase`, `weight`), which legitimately
+ # inline as Literal elsewhere in the same file.
+ assert "urgency: Literal" not in task
+
+
+def test_decision_table_matches_fixture_exactly() -> None:
+ """The decision-table fixture (``expected-enum-types.json``) pins, for EVERY
+ object-hosted ``field.enum`` in every object — entities, the ``TicketSummary``
+ projection, the ``TicketBadge`` value object — exactly what
+ ``resolve_shared_enum_declaration`` (the one function every generator calls)
+ answers. Exact equality both ways: no missing key, no extra key."""
+ root = MetaDataLoader.from_directory(_FIXTURE_DIR).root
+ objects = [o for o in root.own_children() if isinstance(o, MetaObject)]
+
+ actual: dict[str, dict[str, object]] = {}
+ for obj in objects:
+ for f in obj.fields():
+ if f.sub_type != fc.FIELD_SUBTYPE_ENUM:
+ continue
+ decl = resolve_shared_enum_declaration(f)
+ actual[f"{obj.resolution_key()}.{f.name}"] = {
+ "shared": decl.resolution_key() if decl is not None else None,
+ "provided": is_provided(decl) if decl is not None else False,
+ }
+
+ expected = json.loads(_EXPECTED_ENUM_TYPES.read_text())
+ assert actual == expected["fields"]
+
+ materialized = sorted(e.name for e in materialized_shared_enums(objects))
+ assert materialized == expected["materialized"]
+
+
+def test_generated_output_for_multi_hop_provided_and_negative_cases(
+ tmp_path: Path,
+) -> None:
+ """Executes the real generated output (via ``run_gen`` + import, not string
+ guessing) for every scenario the decision table above names explicitly."""
+ root = MetaDataLoader.from_directory(_FIXTURE_DIR).root
+ out_dir = tmp_path / "generated"
+ config = GenConfig(out_dir=str(out_dir), provided_enum_namespace="acme_ext.enums")
+ run_gen(config, root, generators=[entity_model()])
+
+ # A stub for the @provided module (ADR-0001: it never lives in metadata, and
+ # genuinely does not exist here) so Refund.py's cross-module import resolves
+ # for real instead of being skipped.
+ stub_pkg = tmp_path / "acme_ext"
+ stub_pkg.mkdir()
+ (stub_pkg / "__init__.py").write_text("")
+ (stub_pkg / "enums.py").write_text(
+ 'from enum import Enum\n\n\nclass Currency(str, Enum):\n'
+ ' USD = "USD"\n EUR = "EUR"\n GBP = "GBP"\n'
+ )
+
+ sys.path.insert(0, str(tmp_path))
+ try:
+ enums_mod = importlib.import_module("generated.enums")
+ assert not hasattr(enums_mod, "Currency"), "a @provided enum must never be materialized"
+ assert set(enums_mod.Priority.__members__) == {"LOW", "MEDIUM", "HIGH"}
+ assert set(enums_mod.Money.__members__) == {"USD", "EUR", "GBP"}
+
+ # Importing executes the real generated modules — proves the shared-enum
+ # cross-module references actually resolve, not merely render as
+ # plausible text.
+ task = importlib.import_module("generated.Task")
+ subtask = importlib.import_module("generated.Subtask")
+ ticket_summary = importlib.import_module("generated.TicketSummary")
+ ticket_badge = importlib.import_module("generated.TicketBadge")
+ refund = importlib.import_module("generated.Refund")
+ assert task.Task is not None
+ assert subtask.Subtask is not None
+ assert ticket_summary.TicketSummary is not None
+ assert ticket_badge.TicketBadge is not None
+ assert refund.Refund is not None
+
+ task_src = (out_dir / "Task.py").read_text()
+ subtask_src = (out_dir / "Subtask.py").read_text()
+ ticket_summary_src = (out_dir / "TicketSummary.py").read_text()
+ ticket_badge_src = (out_dir / "TicketBadge.py").read_text()
+ refund_src = (out_dir / "Refund.py").read_text()
+ enums_src = (out_dir / "enums.py").read_text()
+
+ # Multi-hop consumers (Task.importance -> Ticket.severity -> Priority;
+ # Subtask.rank three hops; a projection; a value object) reference the
+ # shared type, with no per-object Literal.
+ assert "importance: Priority" in task_src and "importance: Literal" not in task_src
+ assert "rank: Priority" in subtask_src and "rank: Literal" not in subtask_src
+ assert "level: Priority" in ticket_summary_src and "level: Literal" not in ticket_summary_src
+ assert "badge: Priority" in ticket_badge_src and "badge: Literal" not in ticket_badge_src
+ for src in (task_src, subtask_src, ticket_summary_src, ticket_badge_src):
+ assert "from .enums import Priority" in src
+
+ # Priority emitted exactly once across the whole model.
+ assert enums_src.count("class Priority(str, Enum):") == 1
+
+ # Refund: two fields whose immediate supers are same-named fields of
+ # different objects (ticketTier -> Ticket.tier -> Priority; invoiceTier ->
+ # Invoice.tier -> Money), plus the materialized Money the unit field
+ # resolves to, plus the @provided Currency that is never materialized.
+ assert "ticketTier: Priority" in refund_src and "ticketTier: Literal" not in refund_src
+ assert "invoiceTier: Money" in refund_src and "invoiceTier: Literal" not in refund_src
+ assert "unit: Money" in refund_src and "unit: Literal" not in refund_src
+ assert "from .enums import Money" in refund_src
+ assert "tender: Currency" in refund_src
+ assert "from acme_ext.enums import Currency" in refund_src
+ assert "class Currency" not in enums_src
+
+ # Negatives: an own @values/@intValueMap breaks the chain, so these keep
+ # an inline per-object Literal enum instead of referencing Priority.
+ for src, field_name in (
+ (task_src, "phase"),
+ (task_src, "weight"),
+ (subtask_src, "phase"),
+ (subtask_src, "weight"),
+ (refund_src, "orderStatus"),
+ ):
+ assert f"{field_name}: Literal[" in src, f"{field_name} must keep its inline enum"
+ finally:
+ sys.path.remove(str(tmp_path))
+ for name in [
+ m for m in sys.modules if m.split(".")[0] in ("generated", "acme_ext")
+ ]:
+ del sys.modules[name]
+
+
+def test_narrowed_run_on_only_the_projection_still_materializes_the_shared_enum(
+ tmp_path: Path,
+) -> None:
+ """A run narrowed to ONLY ``TicketSummary`` (a projection, via
+ ``entity_filter``) must still emit ``Priority`` — must-generate turns on
+ whether any SURVIVING consumer references the shared type, never on the
+ hosting object's subtype."""
+ root = MetaDataLoader.from_directory(_FIXTURE_DIR).root
+ out_dir = tmp_path / "generated_narrow"
+ config = GenConfig(out_dir=str(out_dir), provided_enum_namespace="acme_ext.enums")
+ run_gen(config, root, generators=[entity_model()], entity_filter=["TicketSummary"])
+
+ assert (out_dir / "enums.py").exists(), "narrowing to TicketSummary must still emit enums.py"
+ enums_src = (out_dir / "enums.py").read_text()
+ assert enums_src.count("class Priority(str, Enum):") == 1
+ assert "class Money" not in enums_src, "Money has no consumer in the narrowed run"
+
+ ticket_summary_src = (out_dir / "TicketSummary.py").read_text()
+ assert "level: Priority" in ticket_summary_src
+ assert "from .enums import Priority" in ticket_summary_src
diff --git a/server/python/tests/render/test_render_model_payload.py b/server/python/tests/render/test_render_model_payload.py
index 5ea769996..770effb7f 100644
--- a/server/python/tests/render/test_render_model_payload.py
+++ b/server/python/tests/render/test_render_model_payload.py
@@ -6,12 +6,19 @@
"""
from __future__ import annotations
+import json
from enum import Enum
+from pathlib import Path
from pydantic import BaseModel
from metaobjects.render.renderer import InMemoryProvider, RenderRequest, render
+_LIST_SEPARATOR_FIXTURE = (
+ Path(__file__).resolve().parents[4]
+ / "fixtures/render-conformance/render-list-separator"
+)
+
class _Tier(str, Enum):
GOLD = "gold"
@@ -51,3 +58,72 @@ def test_a_model_and_its_dump_render_identically() -> None:
npc = _Npc(name="Igor", tier=_Tier.GOLD, bio="old", abilities=[_Ability(name="lurk")])
template = "{{name}} {{tier}} {{bio}} {{#abilities}}{{name}}{{/abilities}}"
assert _render(template, npc) == _render(template, npc.model_dump(mode="json"))
+
+
+# --- Typed payload elements (shared fixture parity) ---------------------------
+
+
+class _ListItem(BaseModel):
+ name: str
+
+
+class _Tag(BaseModel):
+ label: str
+
+
+class _Group(BaseModel):
+ tags: list[_Tag]
+
+
+class _Owner(BaseModel):
+ nick: str
+
+
+class _Entry(BaseModel):
+ name: str
+ note: str | None = None
+ owner: _Owner
+
+
+class _ListSeparatorPayload(BaseModel):
+ """Mirrors ``fixtures/render-conformance/render-list-separator/payload.json``.
+
+ ``authored``'s elements carry a literal ``-last`` key, which is not a valid
+ Python identifier — item 5's documented escape hatch keeps those as plain
+ dict elements; every other list is modeled as the Pydantic equivalent of the
+ DOCUMENTED Python payload shape (a generated value-object model)."""
+
+ items: list[_ListItem]
+ solo: list[_ListItem]
+ none: list[_ListItem]
+ groups: list[_Group]
+ entries: list[_Entry]
+ authored: list[dict]
+
+
+def test_typed_payload_renders_the_shared_fixture_byte_identically_to_its_dict_twin() -> None:
+ """``-last`` (and, transitively, ``has``) must work for BOTH documented
+ Python payload shapes — a generated Pydantic ``BaseModel`` and a plain
+ ``dict`` — and a typed payload equal to a dict payload must render
+ byte-identically. Proven against the shared render-conformance fixture
+ itself, not a one-off template, so this pins the real cross-port contract."""
+ raw = json.loads((_LIST_SEPARATOR_FIXTURE / "payload.json").read_text())
+ expected = (_LIST_SEPARATOR_FIXTURE / "expected.txt").read_text()
+ template = (_LIST_SEPARATOR_FIXTURE / "template.mustache").read_text()
+
+ typed_payload = _ListSeparatorPayload.model_validate(raw)
+
+ rendered_typed = _render(template, typed_payload)
+ rendered_dict = _render(template, raw)
+ assert rendered_typed == expected
+ assert rendered_dict == expected
+ assert rendered_typed == rendered_dict
+
+
+def test_has_field_gate_on_a_typed_element_inside_a_model_list() -> None:
+ """``has`` through the same ``with_derived_accessors`` mechanism as
+ ``-last``, exercised on an ELEMENT of a model-typed list (``_Npc.abilities``),
+ not just the top-level model the existing has test above covers."""
+ template = "{{#abilities}}{{#hasName}}yes{{/hasName}}{{^hasName}}no{{/hasName}}{{/abilities}}"
+ npc = _Npc(name="Igor", tier=_Tier.GOLD, abilities=[_Ability(name="lurk")])
+ assert _render(template, npc) == "yes"
diff --git a/server/python/tests/render/test_verify_conformance.py b/server/python/tests/render/test_verify_conformance.py
index 922024c89..9bd350633 100644
--- a/server/python/tests/render/test_verify_conformance.py
+++ b/server/python/tests/render/test_verify_conformance.py
@@ -53,12 +53,20 @@ def _corpus_root() -> Path:
def _parse_fields(raw: Any) -> list[PayloadField]:
- """Parse the payload.json field-tree (recursive) into PayloadField[]."""
+ """Parse the payload.json field-tree (recursive) into PayloadField[].
+
+ ``isArray`` (absent = false) carries the field's array-ness, which gates
+ ``-last`` availability (an array of scalars never carries it, even nested
+ inside an outer object array)."""
fields: list[PayloadField] = []
for node in raw:
sub = node.get("fields")
fields.append(
- PayloadField(node["name"], _parse_fields(sub) if sub is not None else None)
+ PayloadField(
+ node["name"],
+ _parse_fields(sub) if sub is not None else None,
+ is_array=bool(node.get("isArray", False)),
+ )
)
return fields
diff --git a/server/typescript/packages/cli/src/lib/payload-field-tree.ts b/server/typescript/packages/cli/src/lib/payload-field-tree.ts
index 2c0d8d1a3..44495372a 100644
--- a/server/typescript/packages/cli/src/lib/payload-field-tree.ts
+++ b/server/typescript/packages/cli/src/lib/payload-field-tree.ts
@@ -7,9 +7,9 @@
import {
type MetaData,
- TYPE_FIELD,
FIELD_SUBTYPE_OBJECT,
FIELD_ATTR_OBJECT_REF,
+ isMetaField,
resolveObjectRef,
} from "@metaobjectsdev/metadata";
import type { PayloadField } from "@metaobjectsdev/render";
@@ -44,17 +44,21 @@ export function derivePayloadFieldTree(
const voPkg = vo.package ?? vo.fileDefaultPackage ?? "";
const nextSeen = new Set(seen).add(voName);
const fields: PayloadField[] = [];
- for (const f of vo.children().filter((c) => c.type === TYPE_FIELD)) {
+ for (const f of vo.children().filter(isMetaField)) {
+ // ADR-0039: effective array-ness — `isArray` may be inherited via extends. Carried only
+ // when true, so verify can tell a list of objects from a single object and a list of
+ // scalars from a scalar.
+ const arrayness = f.resolvedIsArray() ? { isArray: true } : {};
if (f.subType === FIELD_SUBTYPE_OBJECT) {
// ADR-0039: effective attr — @objectRef may be inherited via extends.
const ref = f.attr(FIELD_ATTR_OBJECT_REF);
if (typeof ref === "string") {
const fieldPkg = f.parent?.package ?? f.parent?.fileDefaultPackage ?? voPkg;
- fields.push({ name: f.name, fields: derivePayloadFieldTree(root, ref, fieldPkg, nextSeen) });
+ fields.push({ name: f.name, fields: derivePayloadFieldTree(root, ref, fieldPkg, nextSeen), ...arrayness });
continue;
}
}
- fields.push({ name: f.name });
+ fields.push({ name: f.name, ...arrayness });
}
return fields;
}
diff --git a/server/typescript/packages/cli/test/unit/payload-field-tree.test.ts b/server/typescript/packages/cli/test/unit/payload-field-tree.test.ts
index 2414de528..071da6f84 100644
--- a/server/typescript/packages/cli/test/unit/payload-field-tree.test.ts
+++ b/server/typescript/packages/cli/test/unit/payload-field-tree.test.ts
@@ -51,7 +51,36 @@ describe("derivePayloadFieldTree — mirrors the payload-codegen VO walk", () =>
expect(tree).toEqual([
{ name: "displayName" },
{ name: "postCount" },
- { name: "posts", fields: [{ name: "title" }] },
+ { name: "posts", fields: [{ name: "title" }], isArray: true },
+ ]);
+ });
+
+ // verify tells a list of objects from a single object, and a list of scalars from a scalar,
+ // by the node's array-ness — the field's EFFECTIVE isArray, so one inherited through
+ // `extends` counts exactly like one declared on the field.
+ test("array-ness is carried for object lists and scalar lists, including when inherited", async () => {
+ const root = await loadRoot([
+ { "field.string": { name: "Labels", abstract: true, isArray: true } },
+ { "object.value": { name: "Tag", children: [{ "field.string": { name: "name" } }] } },
+ {
+ "object.value": {
+ name: "Card",
+ children: [
+ { "field.string": { name: "title" } },
+ { "field.string": { name: "aliases", isArray: true } },
+ { "field.string": { name: "labels", extends: "Labels" } },
+ { "field.object": { name: "owner", "@objectRef": "Tag" } },
+ { "field.object": { name: "tags", isArray: true, "@objectRef": "Tag" } },
+ ],
+ },
+ },
+ ]);
+ expect(derivePayloadFieldTree(root, "Card", "acme::ai")).toEqual([
+ { name: "title" },
+ { name: "aliases", isArray: true },
+ { name: "labels", isArray: true },
+ { name: "owner", fields: [{ name: "name" }] },
+ { name: "tags", fields: [{ name: "name" }], isArray: true },
]);
});
diff --git a/server/typescript/packages/codegen-ts/src/enum-shared.ts b/server/typescript/packages/codegen-ts/src/enum-shared.ts
index 0a1694f97..0908bd8f2 100644
--- a/server/typescript/packages/codegen-ts/src/enum-shared.ts
+++ b/server/typescript/packages/codegen-ts/src/enum-shared.ts
@@ -1,7 +1,7 @@
// FR-019 — shared + externally-provided enum resolution.
//
// A reusable enum is a ROOT/package-level abstract `field.enum` (a sibling of
-// object.entity) that concrete entity fields `extends`. Per ADR-0026 such a
+// object.entity) that object fields reach through `extends`. Per ADR-0026 such a
// declaration is materialized ONCE per port and referenced, instead of being
// redeclared inline in every consuming entity file.
//
@@ -12,15 +12,17 @@
// reference an existing hand-written/third-party type, resolved via per-port
// codegen config (the namespace/module never lives in metadata — ADR-0001).
//
+// WHICH declaration a field resolves to is decided in the metadata layer
+// (`resolveSharedEnumDeclaration`, ADR-0026 Amendment 1) — the same function the
+// loader's #246 check uses. This module adds only the TypeScript naming and the
+// `@provided` read.
+//
// The common inline case (a concrete `field.enum` with @values declared directly,
-// no root-extends) is UNCHANGED — it stays a per-entity `` union.
+// no shared declaration on its extends chain) is UNCHANGED — it stays a per-entity
+// `` union.
import type { MetaField, MetaRoot } from "@metaobjectsdev/metadata";
-import {
- FIELD_SUBTYPE_ENUM,
- FIELD_ATTR_PROVIDED,
- TYPE_METADATA,
-} from "@metaobjectsdev/metadata";
+import { FIELD_ATTR_PROVIDED, resolveSharedEnumDeclaration } from "@metaobjectsdev/metadata";
import { toPascalCase } from "./naming.js";
import { enumValues } from "./enum-meta.js";
@@ -28,45 +30,26 @@ import { enumValues } from "./enum-meta.js";
export interface SharedEnum {
/** The materialized type name — the abstract declaration's PascalCase name (cross-port identity). */
readonly name: string;
- /** Member symbols (the @values SSOT). */
+ /** The declaration's package-qualified key (`::`). */
+ readonly declaration: string;
+ /** Member symbols (the @values SSOT), read from the declaration. */
readonly values: string[];
/** True when the declaration carries `@provided: true` (reference, don't materialize). */
readonly provided: boolean;
}
-/**
- * The root-level abstract `field.enum` a concrete enum field resolves to via
- * `extends`, or undefined when the field is an inline enum (no root-abstract
- * super). A super qualifies only when it is (a) an abstract field, AND (b) a
- * direct child of the metadata root (a package-level declaration) — NOT an
- * abstract field nested inside another object.
- */
-export function resolveSharedEnumDecl(field: MetaField): MetaField | undefined {
- if (field.subType !== FIELD_SUBTYPE_ENUM) return undefined;
- // Walk the extends chain: the NEAREST root-level abstract enum wins, so a field
- // that extends a concrete (or nested) enum field which itself extends the shared
- // declaration still resolves to it. The seen-set guards a malformed cyclic chain.
- const seen = new Set([field]);
- let sup = field.resolveSuper();
- while (sup !== undefined && !seen.has(sup)) {
- if (sup.subType !== FIELD_SUBTYPE_ENUM) return undefined;
- // The declaration must sit at the package/root level (sibling of object.entity),
- // i.e. its parent node is the metadata root.
- if (sup.isAbstract && sup.parent !== undefined && sup.parent.type === TYPE_METADATA) return sup;
- seen.add(sup);
- sup = sup.resolveSuper();
- }
- return undefined;
-}
-
/** The shared-enum descriptor a field resolves to, or undefined for inline enums. */
export function sharedEnumForField(field: MetaField): SharedEnum | undefined {
- const decl = resolveSharedEnumDecl(field);
+ const decl = resolveSharedEnumDeclaration(field);
if (decl === undefined) return undefined;
- const values = enumValues(field);
+ // The member set is the DECLARATION's (resolving, so `Money extends Currency` reads
+ // the members it inherits) — never a consuming field's, so what is materialized
+ // cannot depend on which consumer is visited first.
+ const values = enumValues(decl);
if (values === undefined) return undefined;
return {
name: toPascalCase(decl.name),
+ declaration: decl.resolutionKey(),
values,
// ADR-0039 sanctioned own: @provided is a declaration-layer provenance marker
// ("THIS type is supplied by hand-written/third-party code"), like `abstract` —
@@ -80,9 +63,10 @@ export function sharedEnumForField(field: MetaField): SharedEnum | undefined {
/**
* All shared (root-level abstract) enum declarations in the loaded root that are
- * actually CONSUMED by at least one concrete entity field — keyed by the
- * materialized type name. A declaration nobody extends is not materialized (no
- * dangling type). Deterministic order: first-consumption order across entities.
+ * actually CONSUMED by at least one object field (entity, projection or value
+ * object, through any number of `extends` hops) — keyed by the materialized type
+ * name. A declaration nobody extends is not materialized (no dangling type).
+ * Deterministic order: first-consumption order across objects.
*
* `select` (FR-023 §11.1 item 2), when given, narrows which CONSUMING entities
* count toward "is this enum used" — an entity for which `select(entity.
diff --git a/server/typescript/packages/codegen-ts/src/generators/template-payload-tree.ts b/server/typescript/packages/codegen-ts/src/generators/template-payload-tree.ts
index 846520ac1..519f1ad4c 100644
--- a/server/typescript/packages/codegen-ts/src/generators/template-payload-tree.ts
+++ b/server/typescript/packages/codegen-ts/src/generators/template-payload-tree.ts
@@ -71,6 +71,7 @@ export function buildEnrichedPayloadTree(
owner,
type: neutralTypeStr(f),
required: isFieldRequired(f),
+ ...(f.resolvedIsArray() ? { isArray: true } : {}),
};
if (f.subType === FIELD_SUBTYPE_OBJECT) {
const ref = f.attr(FIELD_ATTR_OBJECT_REF);
diff --git a/server/typescript/packages/codegen-ts/src/templates/entity-constants.ts b/server/typescript/packages/codegen-ts/src/templates/entity-constants.ts
index e77b9748a..2574532d5 100644
--- a/server/typescript/packages/codegen-ts/src/templates/entity-constants.ts
+++ b/server/typescript/packages/codegen-ts/src/templates/entity-constants.ts
@@ -93,10 +93,11 @@ function renderFieldEntry(f: UiFieldDescriptor): string {
}
/**
- * A value object's field-rules constant: `export const = { $entity, : { name, label,
- * view, rules, ... } } as const`. The same per-field shape an entity's constant carries, minus
- * `$table` and `$path` — a value object has no source and no route — so a caller reads a limit as
- * `..rules.maxLength.value` instead of reaching through the Zod schema's getters.
+ * The field-rules constant of an `object.value`: `export const = { $entity, : { name,
+ * label, view, rules, ... } } as const`. The same per-field shape an entity's constant carries,
+ * minus `$table` and `$path` — a value object has no source and no route — so a caller reads a
+ * limit as `..rules.maxLength.value` instead of reaching through the Zod schema's
+ * getters. Emitted for the value subtype only (see `renderValueObjectFile`).
*/
export function renderValueObjectConstants(obj: MetaObject, emittedName?: string): Code {
const descriptor = buildEntityUiDescriptor(obj);
diff --git a/server/typescript/packages/codegen-ts/src/templates/inferred-types.ts b/server/typescript/packages/codegen-ts/src/templates/inferred-types.ts
index 66218ef4b..c2e8cfb2f 100644
--- a/server/typescript/packages/codegen-ts/src/templates/inferred-types.ts
+++ b/server/typescript/packages/codegen-ts/src/templates/inferred-types.ts
@@ -94,15 +94,18 @@ export type ${entity.name}Update = Partial<${entity.name}Insert>;
/**
* The string-literal-union type-alias name for a `field.enum` field — the SINGLE
* source of truth for enum-union naming (reused by the entity inferred-types
- * emitter AND the payload-VO emitter so both agree byte-for-byte).
+ * emitter, the payload-VO emitter and the extractor so all agree byte-for-byte).
*
- * - If the field extends an abstract field.enum (super), use the super field's
- * PascalCase name (so multiple fields sharing one abstract enum collapse to a
- * single alias).
- * - Otherwise use `` for inline enums, where `` is the
- * owning object's name (entity OR payload value-object).
+ * - A field that resolves to a shared enum declaration (through any number of
+ * `extends` hops) takes that declaration's PascalCase name — the one type every
+ * consumer of the declaration references.
+ * - Otherwise the enum is inline. One that extends another field keeps that
+ * immediate super's PascalCase name; one with no super is ``,
+ * where `` is the owning object's name (entity OR payload value-object).
*/
export function enumUnionAliasName(ownerName: string, field: MetaField): string {
+ const shared = sharedEnumForField(field);
+ if (shared !== undefined) return shared.name;
const superField = field.resolveSuper();
return superField !== undefined
? toPascalCase(superField.name)
diff --git a/server/typescript/packages/codegen-ts/src/templates/render-helper.ts b/server/typescript/packages/codegen-ts/src/templates/render-helper.ts
index 78976671d..f4cd62a0e 100644
--- a/server/typescript/packages/codegen-ts/src/templates/render-helper.ts
+++ b/server/typescript/packages/codegen-ts/src/templates/render-helper.ts
@@ -27,7 +27,6 @@
import {
type MetaData,
- TYPE_FIELD,
TYPE_TEMPLATE,
FIELD_SUBTYPE_OBJECT,
FIELD_ATTR_OBJECT_REF,
@@ -42,6 +41,7 @@ import {
TEMPLATE_ATTR_SUBJECT_REF,
TEMPLATE_ATTR_HTML_BODY_REF,
TEMPLATE_ATTR_TEXT_BODY_REF,
+ isMetaField,
resolveObjectRef,
} from "@metaobjectsdev/metadata";
import {
@@ -108,16 +108,19 @@ function derivePayloadFieldTree(
const voPkg = vo.package ?? vo.fileDefaultPackage ?? "";
const nextSeen = new Set(seen).add(voName);
const fields: PayloadField[] = [];
- for (const f of vo.children().filter((c) => c.type === TYPE_FIELD)) {
+ for (const f of vo.children().filter(isMetaField)) {
+ // ADR-0039: effective array-ness — `isArray` may be inherited via extends. Carried only
+ // when true, so a helper for a payload with no list field is byte-identical.
+ const arrayness = f.resolvedIsArray() ? { isArray: true } : {};
if (f.subType === FIELD_SUBTYPE_OBJECT) {
const ref = f.attr(FIELD_ATTR_OBJECT_REF);
if (typeof ref === "string") {
const fieldPkg = f.parent?.package ?? f.parent?.fileDefaultPackage ?? voPkg;
- fields.push({ name: f.name, fields: derivePayloadFieldTree(root, ref, fieldPkg, nextSeen) });
+ fields.push({ name: f.name, fields: derivePayloadFieldTree(root, ref, fieldPkg, nextSeen), ...arrayness });
continue;
}
}
- fields.push({ name: f.name });
+ fields.push({ name: f.name, ...arrayness });
}
return fields;
}
diff --git a/server/typescript/packages/codegen-ts/src/templates/value-object-file.ts b/server/typescript/packages/codegen-ts/src/templates/value-object-file.ts
index a566ab23b..3f1a1be86 100644
--- a/server/typescript/packages/codegen-ts/src/templates/value-object-file.ts
+++ b/server/typescript/packages/codegen-ts/src/templates/value-object-file.ts
@@ -1,8 +1,8 @@
// Value-object file composer — emits a streamlined "value-only" module for
-// metaobjects with no writable source.rdb child. Output is just the TS
-// interface + the Zod schema (and enum type aliases when applicable); no
-// Drizzle table, no Infer*Model aliases, no filter allowlists, no constants
-// object.
+// metaobjects with no writable source.rdb child. Output is the TS interface +
+// the Zod schema (and enum type aliases when applicable); no Drizzle table, no
+// Infer*Model aliases, no filter allowlists. An `object.value` also gets a
+// field-rules constant (`..rules`), without `$table` or `$path`.
//
// Two objects land here that are more than a value: a TPH subtype (it shares the
// base's table) and a SOURCELESS ENTITY (identity, no source: a record in a store
@@ -15,6 +15,7 @@
import { joinCode, type Code } from "ts-poet";
import type { MetaObject } from "@metaobjectsdev/metadata";
+import { OBJECT_SUBTYPE_VALUE } from "@metaobjectsdev/metadata";
import type { RenderContext } from "../render-context.js";
import { renderValueObjectInterface, renderEnumTypeAliases } from "./inferred-types.js";
import {
@@ -83,10 +84,14 @@ export function renderValueObjectFile(obj: MetaObject, apiPrefix = "", ctx?: Ren
// object stays insert-only (no PATCH semantics — an UpdateSchema would mislead).
tphSubtype || sourceless ? renderZodValidators(obj, ctx) : renderInsertSchemaOnly(obj, ctx),
...(tphConstants !== null ? [tphConstants] : []),
- // A pure value object (no TPH, no sourceless identity) gets a field-rules constant, so a caller
- // reads `..rules` rather than the Zod schema's getters. It has no table or route, so
- // it takes the constant without `$table`/`$path`.
- ...(!tphSubtype && !sourceless ? [renderValueObjectConstants(obj, ctx ? ctx.valueObjectEmittedName(obj) : undefined)] : []),
+ // An `object.value` gets a field-rules constant, so a caller reads `..rules`
+ // rather than the Zod schema's getters. It has no table or route, so the constant carries
+ // no `$table`/`$path`. Only the value subtype: an abstract object, a TPH subtype, a
+ // sourceless entity and an entity emitted to a contract-only target also land in this
+ // composer, and none of them takes it.
+ ...(obj.subType === OBJECT_SUBTYPE_VALUE
+ ? [renderValueObjectConstants(obj, ctx ? ctx.valueObjectEmittedName(obj) : undefined)]
+ : []),
...(tphFilterAllowlist !== null ? [tphFilterAllowlist] : []),
...(tphSortAllowlist !== null ? [tphSortAllowlist] : []),
...(tphFilterType !== null ? [tphFilterType] : []),
diff --git a/server/typescript/packages/codegen-ts/test/generators/entity-file.test.ts b/server/typescript/packages/codegen-ts/test/generators/entity-file.test.ts
index bc1f7390d..70cb32e64 100644
--- a/server/typescript/packages/codegen-ts/test/generators/entity-file.test.ts
+++ b/server/typescript/packages/codegen-ts/test/generators/entity-file.test.ts
@@ -149,17 +149,27 @@ describe("entityFile() — emitAbstractShapes knob", () => {
renderContext,
warn: () => {},
};
- return (await entityFile().generate(ctx)).map((f) => f.path);
+ return entityFile().generate(ctx);
}
test("default (on): abstract entity emits its shape file", async () => {
- const paths = await runWith(true);
+ const paths = (await runWith(true)).map((f) => f.path);
expect(paths).toContain("Base.ts");
expect(paths).toContain("Widget.ts");
});
+ // Generated-output contract: the field-rules constant belongs to `object.value` only. An
+ // abstract object's shape file goes through the same composer and must not carry one.
+ test("abstract entity's shape file carries no value-object field-rules constant", async () => {
+ const base = (await runWith(true)).find((f) => f.path === "Base.ts");
+ expect(base).toBeDefined();
+ expect(base!.content).toContain("export interface Base");
+ expect(base!.content).not.toContain("export const Base =");
+ expect(base!.content).not.toContain("$entity:");
+ });
+
test("off: abstract entity emits no file; concrete still emits", async () => {
- const paths = await runWith(false);
+ const paths = (await runWith(false)).map((f) => f.path);
expect(paths).not.toContain("Base.ts");
expect(paths).toContain("Widget.ts");
});
diff --git a/server/typescript/packages/codegen-ts/test/golden/fr019-shared-provided-conformance.test.ts b/server/typescript/packages/codegen-ts/test/golden/fr019-shared-provided-conformance.test.ts
index 2623aafa2..3c4a2f990 100644
--- a/server/typescript/packages/codegen-ts/test/golden/fr019-shared-provided-conformance.test.ts
+++ b/server/typescript/packages/codegen-ts/test/golden/fr019-shared-provided-conformance.test.ts
@@ -1,6 +1,7 @@
// Cross-port FR-019 conformance (TS — the reference). Loads the shared fixture
-// `fixtures/codegen-conformance/shared-provided-enum/input/meta.json` and asserts the two
-// FR-019 behaviors (ADR-0026):
+// `fixtures/codegen-conformance/shared-provided-enum/input/meta.json`, checks every enum
+// field's resolution against the port-neutral decision table beside it
+// (`expected-enum-types.json`), and asserts the FR-019 behaviors on generated output (ADR-0026):
// • Shared materialization — a package-level abstract `field.enum` (`Priority`) extended by
// TWO entities is materialized ONCE in `enums.ts`; both entities reference it.
// • `@provided` — a package-level abstract `field.enum` (`Currency`, `@provided: true`) is
@@ -16,7 +17,9 @@ import { runGen, defineConfig } from "../../src/index.js";
import { barrel } from "../../src/generators/barrel.js";
import { entityFile } from "../../src/generators/entity-file.js";
import { queriesFile } from "../../src/generators/queries-file.js";
-import { MetaDataLoader, InMemoryStringSource, type MetaRoot } from "@metaobjectsdev/metadata";
+import { MetaDataLoader, InMemoryStringSource, FIELD_SUBTYPE_ENUM, type MetaObject, type MetaRoot } from "@metaobjectsdev/metadata";
+import { sharedEnumForField, materializedSharedEnums } from "../../src/enum-shared.js";
+import { renderSharedEnumsFile } from "../../src/templates/enums-file.js";
function findRepoRoot(start: string): string {
let dir = start;
@@ -38,7 +41,20 @@ async function loadSharedFixture(): Promise {
return res.root;
}
-async function gen(root: MetaRoot, providedEnumModule?: string): Promise> {
+function loadDecisionTable(): {
+ fields: Record;
+ materialized: string[];
+} {
+ const file = join(findRepoRoot(import.meta.dir), "fixtures", "codegen-conformance", "shared-provided-enum", "expected-enum-types.json");
+ return JSON.parse(readFileSync(file, "utf-8"));
+}
+
+/** `only`, when given, narrows the run to the objects it accepts (a generator `filter`). */
+async function gen(
+ root: MetaRoot,
+ providedEnumModule?: string,
+ only?: (entity: MetaObject) => boolean,
+): Promise> {
const tmp = mkdtempSync(join(tmpdir(), "fr019-conf-"));
try {
await runGen({
@@ -47,7 +63,8 @@ async function gen(root: MetaRoot, providedEnumModule?: string): Promise {
expect(inv).not.toContain('from "@acme/ext-enums"');
});
- // MULTI-HOP: `Task.urgency extends Ticket.priority`, and `Ticket.priority` itself extends the
- // root abstract `Priority`. The shared declaration must be found through the intermediate
- // concrete field, so `Task` references `enums.ts` instead of inlining its own `TaskUrgency`.
- test("an enum field extending another enum field that extends the shared abstract still dedups", async () => {
+ // The DECISION TABLE (`expected-enum-types.json`) is the port-neutral oracle: for every
+ // object-hosted enum field in the shared input, which shared declaration its type resolves
+ // to (or none), and whether that declaration is `@provided`. Every port computes the table
+ // from the function its generators call and must match it exactly, in both directions, so a
+ // case cannot be added to the input without all five ports answering it.
+ test("every enum field resolves to the shared declaration the decision table names", async () => {
+ const root = await loadSharedFixture();
+ const expected = loadDecisionTable();
+ const actual: Record = {};
+ for (const obj of root.objects()) {
+ for (const field of obj.fields()) {
+ if (field.subType !== FIELD_SUBTYPE_ENUM) continue;
+ const shared = sharedEnumForField(field);
+ actual[`${obj.resolutionKey()}.${field.name}`] = {
+ shared: shared?.declaration ?? null,
+ provided: shared?.provided ?? false,
+ };
+ }
+ }
+ expect(actual).toEqual(expected.fields);
+ expect(materializedSharedEnums(root).map((e) => e.name).sort()).toEqual(expected.materialized);
+ });
+
+ // MULTI-HOP, on generated output. Each consumer below reaches the shared `Priority` through
+ // one or more intermediate fields whose NAME differs from the declaration's (`severity`,
+ // `importance`), so naming the type after the immediate super would emit `Severity` /
+ // `Importance` instead. The hosts are two entities and a value object.
+ test("multi-hop consumers reference the shared type; it is materialized exactly once", async () => {
const files = await gen(await loadSharedFixture(), "@acme/ext-enums");
const enums = files["enums.ts"]!;
expect(enums.split("export type Priority =").length - 1).toBe(1);
+ expect(enums.split("export type Money =").length - 1).toBe(1);
+
const task = files["Task.ts"]!;
- expect(task).toBeDefined();
- expect(task).toContain('from "./enums"');
- expect(task).toContain("PriorityEnum");
+ expect(task).toContain('export { type Priority, PriorityEnum } from "./enums";');
+ expect(task).toContain("urgency: PriorityEnum.optional()");
+ expect(task).toContain("importance: PriorityEnum.optional()");
+ expect(task).not.toContain("export type Priority =");
expect(task).not.toContain("TaskUrgency");
+ expect(task).not.toContain("export type Importance");
+ expect(task).not.toContain("ImportanceEnum");
+
+ const subtask = files["Subtask.ts"]!;
+ expect(subtask).toContain('export { type Priority, PriorityEnum } from "./enums";');
+ expect(subtask).toContain("rank: PriorityEnum.optional()");
+ expect(subtask).not.toContain("export type Priority =");
+ expect(subtask).not.toContain("export type Importance");
+ expect(subtask).not.toContain("export type Rank");
+ expect(subtask).not.toContain("RankEnum");
+
+ const badge = files["TicketBadge.ts"]!;
+ expect(badge).toContain('import { type Priority, PriorityEnum } from "./enums";');
+ expect(badge).toContain("badge?: Priority | undefined;");
+ expect(badge).toContain("badge: PriorityEnum.optional()");
+ expect(badge).not.toContain("export type Severity");
+ expect(badge).not.toContain("SeverityEnum");
+ expect(badge).not.toContain("TicketBadgeBadge");
+ });
+
+ // The projection over a view. Its read schema validates with the declaration's members and
+ // declares no named enum of its own, so there is no per-projection enum type to drift from
+ // the shared one (in TypeScript the row's field type is the same literal union structurally).
+ test("a multi-hop projection field takes the shared declaration's members and no enum of its own", async () => {
+ const files = await gen(await loadSharedFixture(), "@acme/ext-enums");
+ const summary = files["TicketSummary.ts"]!;
+ expect(summary).toBeDefined();
+ expect(summary).toContain('level: z.enum(["LOW", "MEDIUM", "HIGH"])');
+ expect(summary).not.toContain("TicketSummaryLevel");
+ expect(summary).not.toContain("export type Level");
+ expect(summary).not.toContain("export type Severity");
+ });
+
+ // Two fields of ONE object whose immediate supers are same-named fields (`tier`) of different
+ // objects, resolving to DIFFERENT shared declarations. Naming by the immediate super gives both
+ // the name `Tier`, and the per-file dedupe then drops one of them.
+ test("same-named immediate supers resolving to different shared types stay distinct", async () => {
+ const files = await gen(await loadSharedFixture(), "@acme/ext-enums");
+ const refund = files["Refund.ts"]!;
+ expect(refund).toBeDefined();
+ expect(refund).not.toContain("export type Tier");
+ expect(refund).not.toContain("TierEnum");
+ expect(refund).toContain("ticketTier: PriorityEnum.optional()");
+ expect(refund).toContain("invoiceTier: MoneyEnum.optional()");
+ // `unit` reaches the materialized `Money`; `tender` reaches the @provided `Currency`,
+ // which is referenced from the configured module and never materialized.
+ expect(refund).toContain("unit: MoneyEnum.optional()");
+ expect(refund).toContain('export { type Currency } from "@acme/ext-enums";');
+ expect(refund).toContain('tender: z.enum(["USD", "EUR", "GBP"]).optional()');
+ expect(files["enums.ts"]!).not.toContain("Currency");
+ });
+
+ // NEGATIVES. A field that redeclares `@values` or `@intValueMap` part-way down the chain
+ // starts a new vocabulary, and so does everything extending it; an enum that never reaches a
+ // shared declaration stays inline. Each keeps a locally declared union and takes nothing
+ // from the shared module for that field.
+ test("a mid-chain redeclaration and a chain with no shared declaration stay inline", async () => {
+ const files = await gen(await loadSharedFixture(), "@acme/ext-enums");
+ const task = files["Task.ts"]!;
+ // `phase` redeclares @values: its own members, not the shared Priority's.
+ expect(task).toContain('export type Tier = "OPEN" | "DONE";');
+ // `weight` redeclares @intValueMap over the inherited members: its own inline union.
+ expect(task).toContain('export type Severity = "LOW" | "MEDIUM" | "HIGH";');
+ const subtask = files["Subtask.ts"]!;
+ expect(subtask).toContain('export type Phase = "OPEN" | "DONE";');
+ expect(subtask).toContain('export type Weight = "LOW" | "MEDIUM" | "HIGH";');
+ const refund = files["Refund.ts"]!;
+ expect(refund).toContain('export type Status = "NEW" | "PAID";');
+ });
+
+ // MUST-GENERATE. When the run is scoped to one object whose only enum reaches `Priority`
+ // through `Ticket.severity`, the shared module must still carry `Priority` — otherwise a
+ // consumer references a type nothing generated (the failure #259 reported). Scoping to the
+ // projection, and to the value object, each leaves `Priority` as the one materialized enum.
+ for (const only of ["TicketSummary", "TicketBadge"]) {
+ test(`a run scoped to ${only} alone still materializes the shared enum`, async () => {
+ const root = await loadSharedFixture();
+ const enums = renderSharedEnumsFile(root, { select: (fqn) => fqn === `acme::shop::${only}` });
+ expect(enums).not.toBeNull();
+ expect(enums!).toContain('export type Priority = "LOW" | "MEDIUM" | "HIGH";');
+ expect(enums!).not.toContain("Money");
+ });
+ }
+
+ // A generator `filter` narrows which entity files are written, not which enums exist.
+ test("an entity-file run filtered to the projection still writes the shared enums module", async () => {
+ const files = await gen(await loadSharedFixture(), "@acme/ext-enums", (e) => e.name === "TicketSummary");
+ expect(Object.keys(files).sort()).toEqual(["TicketSummary.ts", "enums.ts"]);
+ expect(files["enums.ts"]!).toContain('export type Priority = "LOW" | "MEDIUM" | "HIGH";');
});
test("a @provided enum with no providedEnumModule config is a codegen-time error naming the enum", async () => {
diff --git a/server/typescript/packages/codegen-ts/test/projection/entity-file.test.ts b/server/typescript/packages/codegen-ts/test/projection/entity-file.test.ts
index 88a3f65d9..9fc6489b4 100644
--- a/server/typescript/packages/codegen-ts/test/projection/entity-file.test.ts
+++ b/server/typescript/packages/codegen-ts/test/projection/entity-file.test.ts
@@ -296,6 +296,18 @@ describe("renderEntityFile — source-aware dispatch", () => {
expect(out).not.toContain("InferSelectModel");
});
+ // Generated-output contract: the field-rules constant (`export const = { $entity, … }`)
+ // belongs to `object.value` only. A table-backed entity reaches the same plain-shape composer
+ // on a contract-only target, and a constant there would name the entity "the value object"
+ // while carrying no `$table`/`$path`.
+ test("write-through entity: emits no value-object field-rules constant", async () => {
+ const { root, entity } = await loadVanillaFixture();
+ const out = renderEntityFile(entity, contractCtx(root));
+ expect(out).toContain(`export interface ${entity.name}`);
+ expect(out).not.toContain(`export const ${entity.name} =`);
+ expect(out).not.toContain("$entity:");
+ });
+
test("no runtime-ts allowlists in a contract target", async () => {
const { root, projection } = await loadProjectionFixture();
const out = renderEntityFile(projection, contractCtx(root));
diff --git a/server/typescript/packages/metadata/src/attr-schema-validate.ts b/server/typescript/packages/metadata/src/attr-schema-validate.ts
index 23f06f02a..051277ee9 100644
--- a/server/typescript/packages/metadata/src/attr-schema-validate.ts
+++ b/server/typescript/packages/metadata/src/attr-schema-validate.ts
@@ -45,7 +45,7 @@ import type { MetaData } from "./shared/meta-data.js";
import { ParseError } from "./errors.js";
import type { AttrSchema, TypeRegistry } from "./registry.js";
import { childRuleMatches } from "./registry.js";
-import { TYPE_FIELD, TYPE_METADATA } from "./shared/base-types.js";
+import { TYPE_FIELD } from "./shared/base-types.js";
import { ATTR_SUBTYPE_PROPERTIES } from "./core/attr/attr-constants.js";
// #337: turns "unknown X" into "retired in V — here is the migration". Changes no load
// outcome; retired vocabulary still fails with the same code.
@@ -58,6 +58,7 @@ import {
FIELD_ATTR_INT_VALUE_MAP,
ENUM_MEMBER_PATTERN,
} from "./core/field/field-constants.js";
+import { isSharedEnumDeclaration } from "./core/field/shared-enum.js";
import {
FIELD_ATTR_DB_COLUMN_TYPE,
DB_COLUMN_TYPE_VALUES,
@@ -337,18 +338,13 @@ function validateNode(
// only own @values need checking here (mirrors the own-attrs-only policy of
// Checks 2+3 above — inherited attrs were validated on the declaring node).
if (node.type === TYPE_FIELD && node.subType === FIELD_SUBTYPE_ENUM) {
- // #246: the shared-enum super, if any — a root-level abstract field.enum (one
- // whose parent is the metadata root, not an object). FR-019 materializes such a
- // declaration ONCE per port as a single named type, so anything a consuming
- // field re-declares that is part of the shared TYPE's contract is a conflict.
- // Immediate-super-only, matching codegen's resolveSharedEnumDecl (enum-shared.ts)
- // so the validator and the collapse agree on what "shared" means.
- const sharedSuper =
- node.superData !== undefined &&
- node.superData.isAbstract &&
- node.superData.parent?.type === TYPE_METADATA
- ? node.superData
- : undefined;
+ // #246: the shared-enum super, if any. FR-019 materializes a shared declaration
+ // ONCE per port as a single named type, so anything a field directly under it
+ // re-declares that is part of the shared TYPE's contract is a conflict. The
+ // predicate is the one codegen's resolver walks with (core/field/shared-enum.ts).
+ // Only the IMMEDIATE super is checked: a redeclaration further down the chain is
+ // legal, and the resolver answers it with a per-object inline type.
+ const sharedSuper = isSharedEnumDeclaration(node.superData) ? node.superData : undefined;
// ADR-0039: own — validates the @values DECLARED on this node; a concrete enum
// extending an abstract one inherits already-validated @values (own-attrs-only).
diff --git a/server/typescript/packages/metadata/src/core/field/shared-enum.ts b/server/typescript/packages/metadata/src/core/field/shared-enum.ts
new file mode 100644
index 000000000..b1226550c
--- /dev/null
+++ b/server/typescript/packages/metadata/src/core/field/shared-enum.ts
@@ -0,0 +1,53 @@
+// Shared enum declarations — the single authority for "which type does this enum
+// field have" (FR-019, ADR-0026 Amendment 1). The loader's #246 check and every
+// codegen site that names, references, imports or materializes an enum type call
+// these two functions; neither keeps a predicate of its own.
+
+import { TYPE_FIELD, TYPE_METADATA } from "../../shared/base-types.js";
+import type { MetaData } from "../../shared/meta-data.js";
+import { FIELD_ATTR_INT_VALUE_MAP, FIELD_ATTR_VALUES, FIELD_SUBTYPE_ENUM } from "./field-constants.js";
+import type { MetaField } from "./meta-field.js";
+
+function isEnumField(node: MetaData | undefined): node is MetaField {
+ return node !== undefined && node.type === TYPE_FIELD && node.subType === FIELD_SUBTYPE_ENUM;
+}
+
+/**
+ * True when `node` is a shared enum declaration: an abstract `field.enum` that is a
+ * direct child of the metadata root (a package-level sibling of `object.entity`). An
+ * abstract enum nested inside an object is not one.
+ */
+export function isSharedEnumDeclaration(node: MetaData | undefined): node is MetaField {
+ return isEnumField(node) && isRootAbstract(node);
+}
+
+function isRootAbstract(field: MetaField): boolean {
+ return field.isAbstract && field.parent?.type === TYPE_METADATA;
+}
+
+/**
+ * The shared enum declaration an enum field's type resolves to, or undefined when the
+ * field keeps a per-object inline type.
+ *
+ * Walks from the field itself up its `extends` chain. At each node, in order: a shared
+ * declaration answers (the nearest one wins, so `Money extends Currency` answers
+ * `Money`); a node that declares its own `@values` or `@intValueMap` starts a new
+ * vocabulary and answers undefined; otherwise the walk continues at the super. A
+ * non-enum node, the end of the chain or a cycle answers undefined. The hosting
+ * object's subtype and the number of hops play no part.
+ */
+export function resolveSharedEnumDeclaration(field: MetaData | undefined): MetaField | undefined {
+ const seen = new Set();
+ let node = field;
+ while (isEnumField(node) && !seen.has(node)) {
+ if (isRootAbstract(node)) return node;
+ // ADR-0039 own (declared-here layer): the question is whether THIS node redeclares
+ // the vocabulary. A resolving read would see the inherited @values on every hop and
+ // stop the walk at the first one.
+ const own = node.ownAttrs();
+ if (own.has(FIELD_ATTR_VALUES) || own.has(FIELD_ATTR_INT_VALUE_MAP)) return undefined;
+ seen.add(node);
+ node = node.superData;
+ }
+ return undefined;
+}
diff --git a/server/typescript/packages/metadata/src/index.ts b/server/typescript/packages/metadata/src/index.ts
index afcf69d0d..952a2e885 100644
--- a/server/typescript/packages/metadata/src/index.ts
+++ b/server/typescript/packages/metadata/src/index.ts
@@ -122,6 +122,9 @@ export {
// Core node classes
export { MetaObject } from "./core/object/meta-object.js";
export { MetaField } from "./core/field/meta-field.js";
+// The one authority for "which type does this enum field have" — the loader's #246
+// check and every codegen site share it (ADR-0026 Amendment 1).
+export { isSharedEnumDeclaration, resolveSharedEnumDeclaration } from "./core/field/shared-enum.js";
export { MetaAttr } from "./core/attr/meta-attr.js";
// Runtime object model — backing objects + FQN→factory binding (Phase A).
diff --git a/server/typescript/packages/render/src/payload-accessors.ts b/server/typescript/packages/render/src/payload-accessors.ts
index 7b03ab385..3a6e3e5c2 100644
--- a/server/typescript/packages/render/src/payload-accessors.ts
+++ b/server/typescript/packages/render/src/payload-accessors.ts
@@ -28,14 +28,13 @@ export function hasAccessorName(fieldName: string): string {
}
/**
- * Positional accessors injected into every ELEMENT of a collection, so a list separator needs
- * no hand-computed `last` flag in the payload: `{{#items}}{{name}}{{^-last}}, {{/-last}}{{/items}}`.
- * `-first` / `-last` are booleans on the element; the leading `-` cannot collide with a field
- * name, and it is still an ordinary Mustache tag name, so the template stays valid Mustache.
- * Only MAP-shaped elements carry them (a scalar element has nowhere to hold a key). An authored
- * key of the same name wins, as it does for `has`.
+ * The positional accessor every OBJECT element of a collection carries, so a list separator
+ * needs no hand-computed `last` flag in the payload:
+ * `{{#items}}{{name}}{{^-last}}, {{/-last}}{{/items}}`. A boolean on the element; the leading
+ * `-` cannot collide with a field name, and it is still an ordinary Mustache tag name, so the
+ * template stays valid Mustache. A scalar element has nowhere to hold a key and carries none.
+ * An authored member of the same name wins, as it does for `has`.
*/
-export const FIRST_ACCESSOR = "-first";
export const LAST_ACCESSOR = "-last";
/** Capitalize the first character, leaving an already-uppercase one untouched. */
@@ -71,49 +70,97 @@ export function accessorValue(value: unknown): boolean | undefined {
}
/**
- * A view over `payload` carrying its derived `has` accessors, recursively.
+ * A view over `payload` carrying its derived accessors, recursively: `has` on every
+ * object, and `-last` on every object that is an element of a collection.
*
* NON-MUTATING — the caller's payload is never touched, because a render must not
- * be able to change the object it was handed. An AUTHORED key always wins: if a
+ * be able to change the object it was handed. An AUTHORED member always wins: if a
* payload genuinely carries `hasFoo`, that value is kept rather than shadowed by a
* derived one.
*
* Recursion follows Mustache's own scoping: every nested object and every array
* ELEMENT becomes a context in its own right, so a section over `abilities` sees
* the accessors of the ability it is currently iterating.
+ *
+ * The payload's shape does not change the result. A plain object is copied with its
+ * accessors added; anything with its own prototype (a class instance, a Date) is read
+ * THROUGH a view instead, so its getters, methods and string form stay exactly its own.
*/
export function withDerivedAccessors(payload: T, depth = 0): T {
+ return derive(payload, depth, undefined) as T;
+}
+
+/** `last` is set only for an element of a collection: its position in that collection. */
+function derive(payload: unknown, depth: number, last: boolean | undefined): unknown {
if (depth > 32) return payload; // pathological graph; render is not a validator
if (Array.isArray(payload)) {
- const last = payload.length - 1;
- return payload.map((v, i) => {
- const el = withDerivedAccessors(v, depth + 1);
- if (el === null || typeof el !== "object" || Array.isArray(el)) return el;
- const proto = Object.getPrototypeOf(el);
- if (proto !== Object.prototype && proto !== null) return el;
- const rec = el as Record;
- const out: Record = { ...rec };
- if (!Object.prototype.hasOwnProperty.call(rec, FIRST_ACCESSOR)) out[FIRST_ACCESSOR] = i === 0;
- if (!Object.prototype.hasOwnProperty.call(rec, LAST_ACCESSOR)) out[LAST_ACCESSOR] = i === last;
- return out;
- }) as unknown as T;
+ const end = payload.length - 1;
+ return payload.map((v, i) => derive(v, depth + 1, i === end));
}
if (payload === null || typeof payload !== "object") return payload;
- // PLAIN objects only. Rebuilding from Object.entries() would flatten anything with
- // its own prototype — a Date stringifies to "[object Object]" and a class instance
- // loses its getters — and the other ports only rebuild map-shaped values, so
- // rebuilding more here would be a divergence as well as a regression.
+ // Only a PLAIN object is rebuilt. Rebuilding from Object.entries() would flatten
+ // anything with its own prototype — a Date stringifies to "[object Object]" and a
+ // class instance loses its getters — so those are viewed, never copied.
const proto = Object.getPrototypeOf(payload);
- if (proto !== Object.prototype && proto !== null) return payload;
+ if (proto !== Object.prototype && proto !== null) return typedView(payload, depth, last);
const src = payload as Record;
const out: Record = {};
- for (const [k, v] of Object.entries(src)) out[k] = withDerivedAccessors(v, depth + 1);
+ for (const [k, v] of Object.entries(src)) out[k] = derive(v, depth + 1, undefined);
for (const [k, v] of Object.entries(src)) {
const name = hasAccessorName(k);
if (Object.prototype.hasOwnProperty.call(src, name)) continue; // authored wins
const derived = accessorValue(v);
if (derived !== undefined) out[name] = derived;
}
- return out as unknown as T;
+ if (last !== undefined && !Object.prototype.hasOwnProperty.call(src, LAST_ACCESSOR)) {
+ out[LAST_ACCESSOR] = last;
+ }
+ return out;
+}
+
+/** The first character lowercased — the inverse of `capitalize` for a camelCase field name. */
+function decapitalize(s: string): string {
+ return s.length === 0 ? s : s.charAt(0).toLowerCase() + s.slice(1);
+}
+
+/**
+ * The derived-accessor view of an object that has its own prototype. Every member the
+ * object has resolves to the object's own — a getter runs against the object itself, a
+ * method stays bound to it — so the Mustache engine sees the names and values it always
+ * did. Only a name the object does NOT have can resolve to a derived accessor.
+ *
+ * The proxy's target is an empty stand-in, not the object: a frozen payload would
+ * otherwise forbid returning the derived view of one of its nested values.
+ */
+function typedView(target: object, depth: number, last: boolean | undefined): object {
+ const members = new Map();
+ const member = (key: PropertyKey): unknown => {
+ if (members.has(key)) return members.get(key);
+ const raw: unknown = Reflect.get(target, key, target);
+ const value =
+ typeof raw === "function"
+ ? (...args: unknown[]) => derive(Reflect.apply(raw, target, args), depth + 1, undefined)
+ : derive(raw, depth + 1, undefined);
+ members.set(key, value);
+ return value;
+ };
+ const derived = (key: PropertyKey): boolean | undefined => {
+ if (typeof key !== "string") return undefined;
+ if (key === LAST_ACCESSOR) return last;
+ if (!key.startsWith(HAS_PREFIX)) return undefined;
+ const rest = key.slice(HAS_PREFIX.length);
+ for (const field of [decapitalize(rest), rest]) {
+ if (!(field in target) || hasAccessorName(field) !== key) continue;
+ const raw: unknown = Reflect.get(target, field, target);
+ if (typeof raw === "function") continue;
+ const value = accessorValue(raw);
+ if (value !== undefined) return value;
+ }
+ return undefined;
+ };
+ return new Proxy(Object.create(null) as object, {
+ has: (_stand, key) => key in target || derived(key) !== undefined,
+ get: (_stand, key) => (key in target ? member(key) : derived(key)),
+ });
}
diff --git a/server/typescript/packages/render/src/verify.ts b/server/typescript/packages/render/src/verify.ts
index 54a8d7649..ffebdf907 100644
--- a/server/typescript/packages/render/src/verify.ts
+++ b/server/typescript/packages/render/src/verify.ts
@@ -11,7 +11,7 @@
import Mustache from "mustache";
import type { Provider } from "./provider.js";
-import { HAS_PREFIX, FIRST_ACCESSOR, LAST_ACCESSOR, hasAccessorName } from "./payload-accessors.js";
+import { HAS_PREFIX, LAST_ACCESSOR, hasAccessorName } from "./payload-accessors.js";
/** A `{{var}}` references a field the (contextual) payload does not declare. */
export const ERR_VAR_NOT_ON_PAYLOAD = "ERR_VAR_NOT_ON_PAYLOAD";
@@ -25,11 +25,14 @@ export const ERR_OUTPUT_TAG_MISSING = "ERR_OUTPUT_TAG_MISSING";
/**
* A plain field-tree node mirroring an `object.value` view-object's field walk.
* `fields` present → a context-pushing field (object / array-of-object); absent
- * → a scalar (string/number/boolean/scalar-array).
+ * → a scalar (string/number/boolean/scalar-array). `isArray` is the field's
+ * effective array-ness: with `fields` it is a list of objects, without it a list
+ * of scalars.
*/
export interface PayloadField {
name: string;
fields?: PayloadField[];
+ isArray?: boolean;
}
export interface VerifyError {
@@ -85,14 +88,14 @@ function isBooleanAccessor(stack: ResolveStack, name:
}
/**
- * True when `name` is a positional accessor (`-first` / `-last`) and the current scope is an
- * element of a collection section — the engine injects both into every map-shaped element
- * (payload-accessors.ts). Like `has` it is permissive about shape: `inElement` is true
- * whenever the innermost context was pushed by a `{{#x}}` over a container field, which an
- * object-typed field also is.
+ * True when `name` is the positional accessor `-last` and it is available in the current scope.
+ * `positional` is true exactly when the innermost enclosing ARRAY section iterates object
+ * elements — the engine puts `-last` on each of those (payload-accessors.ts). An array of
+ * scalars makes it unavailable even inside an outer object list: a scalar element carries no
+ * key, so the lookup would bind to the OUTER element's position and render wrong.
*/
-function isPositionalAccessor(inElement: boolean, name: string): boolean {
- return inElement && (name === FIRST_ACCESSOR || name === LAST_ACCESSOR);
+function isPositionalAccessor(positional: boolean, name: string): boolean {
+ return positional && name === LAST_ACCESSOR;
}
function find(fields: F[], name: string): F | undefined {
@@ -183,7 +186,7 @@ export function verify(
// (no second resolution pass).
const staticTexts: string[] = [templateText];
- function walk(tokens: Token[], stack: ResolveStack, seen: readonly string[], inElement = false): void {
+ function walk(tokens: Token[], stack: ResolveStack, seen: readonly string[], positional = false): void {
const atRoot = stack.length === 1 && stack[0] === root;
for (const tok of tokens) {
const type = tok[0] as string;
@@ -195,7 +198,7 @@ export function verify(
// {{{x}}} (spec); mustache.js emits "&" for it too
if (value === ".") break; // implicit iterator — always valid
if (atRoot) referencedAtRoot.add(value.split(".")[0]!);
- if (!resolve(stack, value) && !isBooleanAccessor(stack, value) && !isPositionalAccessor(inElement, value))
+ if (!resolve(stack, value) && !isBooleanAccessor(stack, value) && !isPositionalAccessor(positional, value))
errors.push({ code: ERR_VAR_NOT_ON_PAYLOAD, path: value });
break;
}
@@ -204,23 +207,23 @@ export function verify(
// {{^x}}…{{/x}}
const sub = Array.isArray(tok[4]) ? (tok[4] as Token[]) : [];
if (value === ".") {
- walk(sub, stack, seen, inElement);
+ walk(sub, stack, seen, positional);
break;
}
if (atRoot) referencedAtRoot.add(value.split(".")[0]!);
const field = resolve(stack, value);
if (!field) {
- // A positional `-first` / `-last` gate is a BOOLEAN on the current element: it
- // pushes nothing, so the body walks in the SAME scope.
- if (isPositionalAccessor(inElement, value)) {
- walk(sub, stack, seen, inElement);
+ // A positional `-last` gate is a BOOLEAN on the current element: it pushes
+ // nothing, so the body walks in the SAME scope.
+ if (isPositionalAccessor(positional, value)) {
+ walk(sub, stack, seen, positional);
break;
}
// A derived `has` gate is a BOOLEAN over the current context, so it
// resolves nothing and pushes nothing — walk the body in the SAME scope,
// which is what `{{#hasAbilities}}{{#abilities}}…` depends on.
if (isBooleanAccessor(stack, value)) {
- walk(sub, stack, seen, inElement);
+ walk(sub, stack, seen, positional);
break;
}
// Unresolved section head is itself drift; skip the body (its
@@ -231,7 +234,12 @@ export function verify(
// `#` over a container pushes its element fields; `^` (and `#` over a
// scalar, used as a conditional) keep the current context.
const push = type === "#" && field.fields !== undefined;
- walk(sub, push ? [...stack, field.fields!] : stack, seen, push ? true : inElement);
+ // Only a `#` over an ARRAY iterates, so only it moves the positional scope: object
+ // elements carry `-last`, scalar elements do not. A section over a non-array field
+ // (a scalar conditional, a single nested object) and every `^` section are
+ // transparent — the enclosing element's position is still the one in reach.
+ const iterates = type === "#" && field.isArray === true;
+ walk(sub, push ? [...stack, field.fields!] : stack, seen, iterates ? field.fields !== undefined : positional);
break;
}
case ">": {
@@ -244,7 +252,7 @@ export function verify(
break;
}
staticTexts.push(text);
- walk(parse(text), stack, [...seen, value], inElement);
+ walk(parse(text), stack, [...seen, value], positional);
break;
}
default:
diff --git a/server/typescript/packages/render/test/payload-accessors.test.ts b/server/typescript/packages/render/test/payload-accessors.test.ts
index de7f22ada..f3afc06a6 100644
--- a/server/typescript/packages/render/test/payload-accessors.test.ts
+++ b/server/typescript/packages/render/test/payload-accessors.test.ts
@@ -18,6 +18,8 @@
// looked at this shape.
import { test, expect, describe } from "bun:test";
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
import { render } from "../src/render.js";
import { verify } from "../src/verify.js";
import { hasAccessorName, accessorValue, withDerivedAccessors } from "../src/payload-accessors.js";
@@ -134,8 +136,8 @@ describe("verify — accepts exactly what render resolves", () => {
//
// The first draft rebuilt ANY non-array object from Object.entries(), which flattened
// everything carrying its own prototype: a Date stringified to "[object Object]" and a
-// class instance lost its getters. Rendering is not allowed to reshape the payload; only
-// plain map-shaped values get derived keys, which is also what the other four ports do.
+// class instance lost its getters. Rendering is not allowed to reshape the payload: only
+// a plain object is copied, and anything with its own prototype is read through a view.
describe("only PLAIN objects are rebuilt", () => {
test("a Date still renders as a Date", () => {
const out = r("{{when}}", { when: new Date("2020-01-02T03:04:05Z") });
@@ -161,3 +163,122 @@ describe("only PLAIN objects are rebuilt", () => {
expect(r("{{#inner}}{{#hasXs}}Y{{/hasXs}}{{/inner}}", { inner: { xs: [1] } })).toBe("Y");
});
});
+
+// ── A typed payload renders exactly like the equal plain-object payload ─────────
+//
+// The oracle is the shared `render-list-separator` fixture: its `expected.txt` is what every
+// port must produce from its `payload.json`. Here the SAME data arrives as class instances,
+// the shape a caller holds when the payload is built by constructors rather than literals.
+// Before the derived accessors reached typed elements, a list of instances rendered a
+// trailing separator (`a, b, c, `) because no element carried `-last`.
+describe("a typed payload renders like the equal plain-object payload", () => {
+ const dir = join(import.meta.dir, "../../../../../fixtures/render-conformance/render-list-separator");
+ const template = readFileSync(join(dir, "template.mustache"), "utf8");
+ const expected = readFileSync(join(dir, "expected.txt"), "utf8");
+ interface Fixture {
+ items: { name: string }[];
+ solo: { name: string }[];
+ none: { name: string }[];
+ groups: { tags: { label: string }[] }[];
+ entries: { name: string; note?: string; owner: { nick: string } }[];
+ authored: { name: string }[];
+ }
+ const json = JSON.parse(readFileSync(join(dir, "payload.json"), "utf8")) as Fixture;
+
+ class Named {
+ constructor(readonly name: string) {}
+ }
+ class Tag {
+ constructor(readonly label: string) {}
+ }
+ class Group {
+ constructor(readonly tags: Tag[]) {}
+ }
+ // A private field behind a getter: the getter only works when it runs against the
+ // instance itself, which is what a view over the instance has to preserve.
+ class Owner {
+ readonly #nick: string;
+ constructor(nick: string) {
+ this.#nick = nick;
+ }
+ get nick(): string {
+ return this.#nick;
+ }
+ }
+ class Entry {
+ constructor(
+ readonly name: string,
+ readonly owner: Owner,
+ readonly note?: string,
+ ) {}
+ }
+ // An AUTHORED `-last` member: it must win over the derived position.
+ class Pinned {
+ readonly "-last" = true;
+ constructor(readonly name: string) {}
+ }
+ class Payload {
+ readonly items = json.items.map((i) => new Named(i.name));
+ readonly solo = json.solo.map((i) => new Named(i.name));
+ readonly none = json.none.map((i) => new Named(i.name));
+ readonly groups = json.groups.map((g) => new Group(g.tags.map((t) => new Tag(t.label))));
+ readonly entries = json.entries.map((e) => new Entry(e.name, new Owner(e.owner.nick), e.note));
+ readonly authored = json.authored.map((a) => new Pinned(a.name));
+ }
+
+ test("the list-separator fixture renders its expected output from class instances", () => {
+ expect(r(template, json)).toBe(expected);
+ expect(r(template, new Payload())).toBe(expected);
+ });
+
+ test("a frozen typed payload renders the same", () => {
+ const frozen = Object.freeze(new Payload());
+ for (const item of frozen.items) Object.freeze(item);
+ expect(r(template, frozen)).toBe(expected);
+ });
+
+ test("has is derived for a class instance exactly as for the equal plain object", () => {
+ class Hero {
+ readonly abilities = ["fly"];
+ readonly bio = " ";
+ readonly level = 3;
+ get nickname(): string {
+ return "Ace";
+ }
+ }
+ const tpl =
+ "{{#hasAbilities}}A{{/hasAbilities}}{{^hasBio}}no-bio{{/hasBio}}{{#hasNickname}}N{{/hasNickname}}" +
+ "[{{#hasLevel}}L{{/hasLevel}}]";
+ const plain = { abilities: ["fly"], bio: " ", level: 3, nickname: "Ace" };
+ expect(r(tpl, plain)).toBe("Ano-bioN[]");
+ expect(r(tpl, new Hero())).toBe(r(tpl, plain));
+ });
+
+ test("an authored has member wins on a class instance", () => {
+ class Quiet {
+ readonly abilities = ["fly"];
+ readonly hasAbilities = false;
+ }
+ expect(r("{{#hasAbilities}}A{{/hasAbilities}}{{^hasAbilities}}none{{/hasAbilities}}", new Quiet())).toBe("none");
+ });
+
+ test("a value nested in a class instance keeps its own string form", () => {
+ class Stamped {
+ readonly when = new Date("2020-01-02T03:04:05Z");
+ }
+ expect(r("{{when}}", new Stamped())).toBe(r("{{when}}", { when: new Date("2020-01-02T03:04:05Z") }));
+ });
+
+ test("a method on a class instance still resolves as a computed value", () => {
+ class Person {
+ constructor(
+ readonly first: string,
+ readonly last: string,
+ ) {}
+ full(): string {
+ return `${this.first} ${this.last}`;
+ }
+ }
+ expect(r("{{full}}", new Person("Ada", "Lovelace"))).toBe("Ada Lovelace");
+ });
+});
diff --git a/server/typescript/packages/render/test/verify-conformance.test.ts b/server/typescript/packages/render/test/verify-conformance.test.ts
index 676454bc8..d4e351f18 100644
--- a/server/typescript/packages/render/test/verify-conformance.test.ts
+++ b/server/typescript/packages/render/test/verify-conformance.test.ts
@@ -16,7 +16,9 @@ import {
// Verify-conformance corpus: the cross-language template-drift oracle (FR-004
// Plan #3 C). Each fixture dir under fixtures/verify-conformance/ holds
// payload.json the payload FIELD-TREE — PayloadField[], the declared
-// shape (NOT render data, unlike render-conformance)
+// shape (NOT render data, unlike render-conformance); a node
+// may carry "isArray": true (a list of objects with `fields`,
+// a list of scalars without)
// template.mustache the template text whose {{vars}} are drift-checked
// partials/*.mustache optional partial bodies, referenced as `partials/`
// options.json optional { requiredSlots?, requiredTags?: string[]; provider?: "with" | "without" }
diff --git a/spec/conformance-tests.md b/spec/conformance-tests.md
index 3c98adff4..53c57a464 100644
--- a/spec/conformance-tests.md
+++ b/spec/conformance-tests.md
@@ -239,14 +239,18 @@ Each fixture is a directory under `fixtures/verify-conformance/`:
- **`payload.json`** is a `PayloadField[]` — the recursive field tree a port
derives from an `object.value` view-object. A node *with* a `fields` array is a
- context-pushing field (object / array-of-object); a node *without* is a scalar:
+ context-pushing field (object / array-of-object); a node *without* is a scalar.
+ A node may carry `"isArray": true`, the field's effective array-ness: with
+ `fields` it is a list of objects, without it a list of scalars. Absent means not
+ a list. It decides where the positional accessor `-last` is available:
```json
[
{ "name": "displayName" },
- { "name": "posts", "fields": [
+ { "name": "posts", "isArray": true, "fields": [
{ "name": "title" },
- { "name": "tags", "fields": [{ "name": "name" }] }
+ { "name": "tags", "isArray": true, "fields": [{ "name": "name" }] },
+ { "name": "labels", "isArray": true }
] }
]
```
diff --git a/spec/decisions/ADR-0026-shared-and-provided-named-types.md b/spec/decisions/ADR-0026-shared-and-provided-named-types.md
index 0f998a5fc..ef621b23f 100644
--- a/spec/decisions/ADR-0026-shared-and-provided-named-types.md
+++ b/spec/decisions/ADR-0026-shared-and-provided-named-types.md
@@ -128,3 +128,53 @@ codegen + fixtures effort for a need Option 1 already serves.
- [ADR-0001](ADR-0001-cross-language-type-binding.md) — bind metadata→native types at build time, per port (no FQN in metadata).
- [enum datatype design](../../docs/superpowers/specs/2026-05-23-enum-datatype-design.md) — D6 abstract-enum reuse; int-backed values + display labels remain deferred.
- [FR-019](../../docs/superpowers/specs/2026-06-06-fr-019-shared-and-provided-enums-design.md) — implementation (enum shared-materialization + `@provided`, with value-object `@provided` as a noted follow-up).
+
+## Amendment 1 (2026-10-10) — type identity through an `extends` chain
+
+§2 says a field that `extends` a shared declaration references its one type, and says nothing
+about a field that reaches the declaration through ANOTHER field. Each port filled that gap on
+its own, and more than once per port: the loader's #246 check, the type namer and the
+materializer each carried a private notion of "the super". Four defects came from two of those
+answers disagreeing: a projection whose enum type did not generate (#259), a chained root
+abstract named after the wrong declaration, an own `@values` silently dropped (#246), and a
+two-hop field that got its own inlined copy of a shared enum.
+
+**The rule.** A *shared enum declaration* is a `field.enum` that is abstract AND a direct child
+of the metadata root. To resolve an enum field's type, start at the field itself and walk its
+`extends` chain. At each node, in this order:
+
+1. the node is a shared enum declaration: that declaration is the answer (the nearest wins, so
+ `Money extends Currency` answers `Money`);
+2. the node declares its OWN `@values` or its OWN `@intValueMap`: there is no shared type. The
+ node starts a new vocabulary, and the field keeps a per-object inline type;
+3. otherwise continue at the node's super.
+
+A non-enum node, the end of the chain or a cycle answers "no shared type". The hosting object's
+subtype plays no part, and there is no hop limit: an entity, projection or value-object field
+that reaches a shared declaration through any number of hops references that one type.
+
+**One authority per port.** The walk lives in each port's metadata layer
+(`resolveSharedEnumDeclaration` / `isSharedEnumDeclaration`, named idiomatically), and both the
+loader and every codegen site call it. The #246 check asks the predicate about the IMMEDIATE
+super only, which is unchanged: own `@values` or `@intValueMap` directly under a shared
+declaration is `ERR_ENUM_EXTENDS_VALUES_CONFLICT`, and a redeclaration further down the chain
+loads and is answered by step 2. The two JVM generators share the Java function, so they cannot
+disagree.
+
+**Materialization.** The member set and the int map are read from the DECLARATION (a resolving
+read, so a chained declaration materializes the members it inherits), never from whichever
+consuming field is visited first. The shared type is emitted whenever any generated consumer
+references it, including when the only consumer is a multi-hop projection or value-object
+field.
+
+**This supersedes #259's remedy.** #259 reported a type that did not generate, and was fixed by
+giving a Kotlin projection its own copy of the enum. The projection now references the shared
+type, and the shared type always generates. A field whose chain ends at an inline,
+object-hosted enum keeps each port's inline naming and output byte for byte. Sharing an inline
+enum across objects stays out of scope.
+
+**Gate.** `fixtures/codegen-conformance/shared-provided-enum/expected-enum-types.json` is a
+port-neutral decision table: for every object-hosted enum field of the shared input, the
+declaration it resolves to (or none), whether that declaration is `@provided`, and which
+declarations are materialized. All five ports compute it from the function their generators
+call and must match it in both directions.
From 34f61b0d5fcbca0a583990a434e798543ee8b98b Mon Sep 17 00:00:00 2001
From: Doug Mealing
Date: Sun, 11 Oct 2026 00:38:39 -0400
Subject: [PATCH 3/7] no-mistakes(review): Restore Java inline enum names;
overlay typed payload views
Assisted-by: no-mistakes:claude:claude-opus-5-5
---
CHANGELOG.md | 50 +++++-
.../expected-enum-types.json | 4 +-
.../shared-provided-enum/input/meta.json | 4 +
.../TypedPayloadTests.cs | 88 ++++++++++
.../MetaObjects.Render/PayloadAccessors.cs | 139 +++++++++++-----
server/csharp/MetaObjects.Render/Renderer.cs | 4 +-
...otlinFr019SharedProvidedConformanceTest.kt | 26 +--
.../kotlin/KotlinTypedPayloadRenderTest.kt | 51 ++++++
.../generator/spring/SpringTypeMapper.java | 49 +++++-
.../Fr019SharedProvidedConformanceTest.java | 64 +++++--
.../spring/InlineEnumNamesGoldenTest.java | 79 +++++++++
.../expected/acme/shipping/CrateDto.java | 27 +++
.../expected/acme/shipping/CratePatch.java | 56 +++++++
.../expected/acme/shipping/ParcelDto.java | 27 +++
.../expected/acme/shipping/ParcelPatch.java | 56 +++++++
.../expected/acme/shipping/ShipmentDto.java | 31 ++++
.../expected/acme/shipping/ShipmentNote.java | 34 ++++
.../expected/acme/shipping/ShipmentPatch.java | 59 +++++++
.../acme/shipping/ShipmentSummaryDto.java | 11 ++
.../acme/shipping/ShipmentSummaryPatch.java | 56 +++++++
.../acme/shipping/prompts/NoteParser.java | 80 +++++++++
.../golden/inline-enum-names/input/meta.json | 46 +++++
.../metaobjects/render/PayloadAccessors.java | 157 ++++++++++--------
.../com/metaobjects/render/PayloadField.java | 5 +
.../render/TypedPayloadRenderTest.java | 82 +++++++++
.../com/metaobjects/render/VerifyTest.java | 15 ++
...DR-0026-shared-and-provided-named-types.md | 23 +++
27 files changed, 1176 insertions(+), 147 deletions(-)
create mode 100644 server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/InlineEnumNamesGoldenTest.java
create mode 100644 server/java/codegen-spring/src/test/resources/golden/inline-enum-names/expected/acme/shipping/CrateDto.java
create mode 100644 server/java/codegen-spring/src/test/resources/golden/inline-enum-names/expected/acme/shipping/CratePatch.java
create mode 100644 server/java/codegen-spring/src/test/resources/golden/inline-enum-names/expected/acme/shipping/ParcelDto.java
create mode 100644 server/java/codegen-spring/src/test/resources/golden/inline-enum-names/expected/acme/shipping/ParcelPatch.java
create mode 100644 server/java/codegen-spring/src/test/resources/golden/inline-enum-names/expected/acme/shipping/ShipmentDto.java
create mode 100644 server/java/codegen-spring/src/test/resources/golden/inline-enum-names/expected/acme/shipping/ShipmentNote.java
create mode 100644 server/java/codegen-spring/src/test/resources/golden/inline-enum-names/expected/acme/shipping/ShipmentPatch.java
create mode 100644 server/java/codegen-spring/src/test/resources/golden/inline-enum-names/expected/acme/shipping/ShipmentSummaryDto.java
create mode 100644 server/java/codegen-spring/src/test/resources/golden/inline-enum-names/expected/acme/shipping/ShipmentSummaryPatch.java
create mode 100644 server/java/codegen-spring/src/test/resources/golden/inline-enum-names/expected/acme/shipping/prompts/NoteParser.java
create mode 100644 server/java/codegen-spring/src/test/resources/golden/inline-enum-names/input/meta.json
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4ebda3515..f5913df64 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -54,10 +54,16 @@ Total hand-written lines did not fall, so do not read it as a total line reducti
API docs), and `meta verify --docs` reports the affected pages as stale until they are
regenerated. Run `meta gen` and review the diff; three-way merge preserves hand edits.
- **A multi-hop enum field references the shared enum.** A field that reaches a
- package-level abstract enum through another field used to get its own enum type; it now
- references the shared one, in every port. In Kotlin this includes a projection or value-object
- field: its former `` enum file is no longer generated, and code that imported
- that type imports the shared enum instead. A model with no such field regenerates unchanged.
+ package-level abstract enum through another field, with no `@values` or `@intValueMap`
+ redeclared on the way, used to get an enum type of its own; it now references the shared one,
+ in every port. In Kotlin its former `` enum file is no longer written, and code
+ that imported that type imports the shared enum instead. Java has two more diffs: a
+ value-object field that reaches a shared or `@provided` enum, even in one hop, references that
+ type instead of nesting a copy (`TicketPayload.Priority` becomes the top-level `Priority`),
+ and an inline enum that redeclares `@values` or `@intValueMap` under a shared enum moves from
+ the shared enum's name to `` (`TaskDto.Priority` becomes `TaskDto.TaskPhase`).
+ Every other inline enum, in every port, keeps its previous name and bytes. The **Fixed** entry
+ lists each port's before and after.
**Not in 1.1.0.** Derived measures, the preview `reporting` library, the dbt exporter, the second
requirements slice and the advisory lint port, the JSON Schema and OpenAPI generators, the
@@ -81,7 +87,10 @@ generator catalog and the Atlas evaluation are 1.2 work.
a template that does not use it renders byte-for-byte as before. It works whatever shape the
payload arrives in, and so does `has`: a map or plain object, a TypeScript class
instance, a C# object with or without `[JsonPropertyName]`, a Java record or getter-based
- bean, a Kotlin data class, a Pydantic model. The same data renders the same bytes.
+ bean, a Kotlin data class, a Pydantic model. The same data renders the same bytes. A typed
+ object is overlaid, never copied: its own members (methods and fields included) resolve as
+ they always did and only when the template names them, its string form is its own, and the
+ derived names are added on top.
`meta verify --templates` accepts `-last` only when the innermost enclosing section over a
list iterates objects, and reports it as `ERR_VAR_NOT_ON_PAYLOAD` anywhere else: at the top
level, inside a list of scalars, and inside a list of scalars nested in a list of objects,
@@ -548,6 +557,37 @@ until you regenerate.
Kotlin. Gated by a decision table every port answers
(`fixtures/codegen-conformance/shared-provided-enum/expected-enum-types.json`).
+ What regenerates differently, port by port. The first case is the same in all five: a field
+ that reaches a shared enum through two or more hops, with nothing redeclared on the way, now
+ references the shared type. What it generated before:
+
+ | Port | Before, for such a field |
+ |---|---|
+ | TypeScript | an inline union and `Enum` constant in the object's own module, named for the field it extends |
+ | C# | a nested `` enum |
+ | Java | a nested enum in the DTO that reused the shared enum's name and shadowed it there |
+ | Kotlin | an `` enum file, which is no longer written |
+ | Python | an inline `Literal[...]` |
+
+ Java has two more changes, and no other port has them:
+
+ - A value-object field that reaches a shared or `@provided` enum, in one hop or many, now
+ references that type. The record used to nest its own copy, so one declaration became two
+ Java types that do not convert. Migration: `TicketPayload.Priority` becomes the top-level
+ `Priority`, and a `@provided` enum is referenced where `providedEnumNamespace` /
+ `providedEnumPackages` point, which the value-object and output-parser generators now read.
+ - An inline enum on a chain that redeclares `@values` or `@intValueMap` under a shared enum
+ was nested under the shared enum's own name and shadowed it inside the DTO. It is now named
+ ``: `TaskDto.Priority` (with the redeclared members) becomes
+ `TaskDto.TaskPhase`. It stays inline, with its own members.
+
+ Nothing else moves. Every other inline enum, in every port, keeps its previous name and bytes:
+ in Java an inline enum that extends another field is still named for its top-most super
+ (`ShipmentSummaryDto.Status`), which a golden test pins against the previous generator's
+ output. A model regenerates unchanged unless it has a field that reaches a shared enum through
+ another field, or, in Java only, a value-object field that reaches a shared or `@provided`
+ enum or a redeclaration under a shared enum.
+
- **`meta verify --db` no longer reports a matching view as drift, and says what differs when a
view does not.** An adopter on 1.1.0-rc.2 rebuilt a SQLite database from the committed chain:
every report and projection view's stored SQL was byte-identical to the metadata's, yet
diff --git a/fixtures/codegen-conformance/shared-provided-enum/expected-enum-types.json b/fixtures/codegen-conformance/shared-provided-enum/expected-enum-types.json
index 22bf2201d..d1652b74b 100644
--- a/fixtures/codegen-conformance/shared-provided-enum/expected-enum-types.json
+++ b/fixtures/codegen-conformance/shared-provided-enum/expected-enum-types.json
@@ -21,7 +21,9 @@
"acme::shop::Refund.ticketTier": { "shared": "acme::shop::Priority", "provided": false },
"acme::shop::Refund.invoiceTier": { "shared": "acme::shop::Money", "provided": false },
"acme::shop::TicketSummary.level": { "shared": "acme::shop::Priority", "provided": false },
- "acme::shop::TicketBadge.badge": { "shared": "acme::shop::Priority", "provided": false }
+ "acme::shop::TicketBadge.badge": { "shared": "acme::shop::Priority", "provided": false },
+ "acme::shop::TicketLabel.grade": { "shared": "acme::shop::Priority", "provided": false },
+ "acme::shop::TicketLabel.denomination": { "shared": "acme::shop::Currency", "provided": true }
},
"materialized": ["Money", "Priority"]
}
diff --git a/fixtures/codegen-conformance/shared-provided-enum/input/meta.json b/fixtures/codegen-conformance/shared-provided-enum/input/meta.json
index 029f5b1fa..c3bf2f1a0 100644
--- a/fixtures/codegen-conformance/shared-provided-enum/input/meta.json
+++ b/fixtures/codegen-conformance/shared-provided-enum/input/meta.json
@@ -63,6 +63,10 @@
] } },
{ "object.value": { "name": "TicketBadge", "children": [
{ "field.enum": { "name": "badge", "extends": "acme::shop::Ticket.severity" } }
+ ] } },
+ { "object.value": { "name": "TicketLabel", "children": [
+ { "field.enum": { "name": "grade", "extends": "Priority" } },
+ { "field.enum": { "name": "denomination", "extends": "Currency" } }
] } }
]
}
diff --git a/server/csharp/MetaObjects.Render.Tests/TypedPayloadTests.cs b/server/csharp/MetaObjects.Render.Tests/TypedPayloadTests.cs
index 062d45b3d..5827e40b6 100644
--- a/server/csharp/MetaObjects.Render.Tests/TypedPayloadTests.cs
+++ b/server/csharp/MetaObjects.Render.Tests/TypedPayloadTests.cs
@@ -112,4 +112,92 @@ public void HasFieldGateWorksOnTypedElementsLikeOnMaps()
Assert.Equal("a:y b:n ", viaMaps);
Assert.Equal(viaMaps, viaTyped);
}
+
+ // ---- The overlay adds names; it never takes any away -----------------------------------
+
+ private readonly record struct Money(long Cents, string Currency)
+ {
+ public override string ToString() => $"{Currency} {Cents}c";
+ }
+
+ private sealed class Line
+ {
+ public string Sku = "";
+ public Money Price { get; init; }
+ public string Label() => "label:" + Sku;
+ public string Exploding => throw new InvalidOperationException("read a member the template never names");
+ }
+
+ private sealed class Order
+ {
+ public string Id { get; init; } = "";
+ public Money Total { get; init; }
+ public List Lines { get; init; } = new();
+ public string Summary() => $"order {Id}";
+ public override string ToString() => $"Order[{Id}]";
+ }
+
+ private static Order SampleOrder() => new()
+ {
+ Id = "o-1",
+ Total = new Money(1250, "USD"),
+ Lines =
+ {
+ new Line { Sku = "a", Price = new Money(500, "USD") },
+ new Line { Sku = "b", Price = new Money(750, "USD") },
+ },
+ };
+
+ /// The engine with no MetaObjects view at all: what a typed payload rendered before.
+ private static string RenderWithBareEngine(string template, object payload) =>
+ new Stubble.Core.Builders.StubbleBuilder().Build().Render(template, payload);
+
+ [Fact]
+ public void MethodBackedAndFieldBackedMembersStillResolve()
+ {
+ const string template = "{{Summary}}|{{#Lines}}{{Sku}}={{Label}};{{/Lines}}";
+ Assert.Equal("order o-1|a=label:a;b=label:b;", Render(template, SampleOrder()));
+ }
+
+ [Fact]
+ public void AValueRendersThroughItsOwnStringForm()
+ {
+ const string template = "{{Total}}|{{#Lines}}{{Price}};{{/Lines}}|{{#Lines}}{{.}};{{/Lines}}";
+ var order = SampleOrder();
+ Assert.Equal(
+ $"USD 1250c|USD 500c;USD 750c;|{order.Lines[0]};{order.Lines[1]};",
+ Render(template, order));
+ }
+
+ [Fact]
+ public void AMemberTheTemplateNeverNamesIsNeverRead()
+ {
+ // Line.Exploding throws when read. No template below names it.
+ const string template = "{{#Lines}}{{Sku}}{{^-last}},{{/-last}}{{/Lines}}";
+ Assert.Equal("a,b", Render(template, SampleOrder()));
+ }
+
+ [Fact]
+ public void ATemplateWithoutDerivedNamesRendersExactlyAsTheBareEngineDoes()
+ {
+ const string template =
+ "{{Id}} {{Summary}} {{Total}} {{Total.Cents}} {{ToString}}"
+ + "{{#Lines}} [{{Sku}} {{Label}} {{Price}} {{Price.Currency}} {{Id}}]{{/Lines}}"
+ + "{{#Total}} {{Currency}}{{/Total}}{{^Missing}} none{{/Missing}}";
+ var order = SampleOrder();
+ string expected = RenderWithBareEngine(template, order);
+ Assert.Equal(
+ "o-1 order o-1 USD 1250c 1250 Order[o-1] [a label:a USD 500c USD o-1] [b label:b USD 750c USD o-1] USD none",
+ expected);
+ Assert.Equal(expected, Render(template, order));
+ }
+
+ [Fact]
+ public void DerivedNamesSitOnTopOfATypedObjectsOwnMembers()
+ {
+ const string template =
+ "{{#hasLines}}{{#Lines}}{{Label}}{{^-last}} + {{/-last}}{{/Lines}}{{/hasLines}}"
+ + "{{#hasId}} has-id{{/hasId}}{{^hasMissing}} no-missing{{/hasMissing}}";
+ Assert.Equal("label:a + label:b has-id no-missing", Render(template, SampleOrder()));
+ }
}
diff --git a/server/csharp/MetaObjects.Render/PayloadAccessors.cs b/server/csharp/MetaObjects.Render/PayloadAccessors.cs
index 021979b91..088afcd9e 100644
--- a/server/csharp/MetaObjects.Render/PayloadAccessors.cs
+++ b/server/csharp/MetaObjects.Render/PayloadAccessors.cs
@@ -103,12 +103,18 @@ public static bool IsBooleanAccessor(List> stack, st
}
///
- /// A view over carrying its derived has<Field>
- /// accessors, recursively. NON-MUTATING — a render must not change the object it was
- /// handed. An AUTHORED key always wins. Recursion follows Mustache's own scoping, so
+ /// A view over carrying its derived accessors, recursively:
+ /// has<Field> on every object, and -last on every object that is an
+ /// element of a collection. NON-MUTATING — a render must not change the object it was
+ /// handed. An AUTHORED member always wins. Recursion follows Mustache's own scoping, so
/// every nested object and every collection ELEMENT becomes a context in its own right.
+ /// A typed object with no wire names comes back as an overlay only
+ /// 's engine resolves ( / ).
///
- public static object? WithDerivedAccessors(object? payload, int depth = 0)
+ public static object? WithDerivedAccessors(object? payload, int depth = 0) => Derive(payload, depth, null);
+
+ /// is set only for an element of a collection: its position.
+ private static object? Derive(object? payload, int depth, bool? last)
{
if (depth > 32 || payload is null) return payload; // pathological graph
if (payload is string) return payload;
@@ -119,7 +125,7 @@ public static bool IsBooleanAccessor(List> stack, st
foreach (System.Collections.DictionaryEntry e in dict)
{
if (e.Key is not string k) continue;
- outMap[k] = WithDerivedAccessors(e.Value, depth + 1);
+ outMap[k] = Derive(e.Value, depth + 1, null);
}
foreach (System.Collections.DictionaryEntry e in dict)
{
@@ -129,60 +135,107 @@ public static bool IsBooleanAccessor(List> stack, st
bool? derived = AccessorValue(e.Value);
if (derived is not null) outMap[name] = derived;
}
+ if (last is bool position) outMap.TryAdd(LastAccessor, position);
return outMap;
}
if (payload is System.Collections.IEnumerable seq)
{
- var outList = new List();
- foreach (var item in seq) outList.Add(WithDerivedAccessors(item, depth + 1));
- for (int i = 0; i < outList.Count; i++)
- {
- if (outList[i] is not Dictionary el) continue;
- el.TryAdd(LastAccessor, i == outList.Count - 1);
- }
+ var items = seq.Cast().ToList();
+ var outList = new List(items.Count);
+ for (int i = 0; i < items.Count; i++) outList.Add(Derive(items[i], depth + 1, i == items.Count - 1));
return outList;
}
- // ADR-0056 — a typed payload (a generated value-object POCO, a record, an anonymous
- // object). A template names the FIELD (`{{bio}}`), and a generated POCO carries that
- // name in [JsonPropertyName] on a PascalCase member, so a typed object is viewed as the
- // map of its readable public members keyed by wire name (or the member's own name when
- // it declares none — exactly the name reflection lookup resolved before). The view is
- // what carries the derived has and -last accessors, so a typed payload renders
- // byte-identically to the equal map payload. A scalar-like value (number, enum, date,
- // Guid, JsonElement, ...) is never viewed: its string form is its own.
- if (IsScalarLike(payload.GetType())) return payload;
+ // ADR-0056 — a generated value-object POCO. Its members are PascalCase C# properties
+ // that carry the metadata field name in [JsonPropertyName], while a template names the
+ // FIELD (`{{bio}}`). So the POCO is viewed as the map of its wire names, which also gives
+ // it the derived accessors a map payload gets.
+ if (WireNamedProperties(payload.GetType()) is { } props)
{
var asMap = new Dictionary(StringComparer.Ordinal);
- foreach (var (name, read) in MemberReaders(payload.GetType())) asMap.TryAdd(name, read(payload));
- return WithDerivedAccessors(asMap, depth);
+ foreach (var (name, prop) in props) asMap[name] = prop.GetValue(payload);
+ return Derive(asMap, depth, last);
}
- }
- private static bool IsScalarLike(Type t) =>
- t.IsPrimitive || t.IsEnum || t.Namespace is "System" || t.Namespace?.StartsWith("System.", StringComparison.Ordinal) == true;
+ // A value the engine treats specially by its TYPE — a scalar, a formattable value, a
+ // lambda, a dynamic object — is handed over untouched. Any other object is overlaid.
+ if (payload is IConvertible or IFormattable or Delegate or System.Dynamic.IDynamicMetaObjectProvider)
+ return payload;
+ return new TypedView(payload, depth, last);
+ }
- private static readonly System.Collections.Concurrent.ConcurrentDictionary)[]>
- MemberCache = new();
+ private static readonly System.Collections.Concurrent.ConcurrentDictionary
+ WireNameCache = new();
///
- /// The readable public instance members of , each keyed by its
- /// [JsonPropertyName] (or its own name when it has none). Cached per type.
+ /// The readable public properties of , each keyed by its
+ /// [JsonPropertyName] (or its own name when it has none) — or null when no property
+ /// declares a wire name, which leaves the type to the overlay.
///
- private static (string, Func)[] MemberReaders(Type type) =>
- MemberCache.GetOrAdd(type, static t =>
+ private static (string, System.Reflection.PropertyInfo)[]? WireNamedProperties(Type type) =>
+ WireNameCache.GetOrAdd(type, static t =>
{
- const System.Reflection.BindingFlags Flags =
- System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance;
- string WireName(System.Reflection.MemberInfo m) =>
- (m.GetCustomAttributes(typeof(System.Text.Json.Serialization.JsonPropertyNameAttribute), inherit: true)
- .FirstOrDefault() as System.Text.Json.Serialization.JsonPropertyNameAttribute)?.Name ?? m.Name;
- var readers = new List<(string, Func)>();
- foreach (var p in t.GetProperties(Flags).Where(p => p.CanRead && p.GetIndexParameters().Length == 0))
- readers.Add((WireName(p), o => p.GetValue(o)));
- foreach (var f in t.GetFields(Flags))
- readers.Add((WireName(f), o => f.GetValue(o)));
- return readers.ToArray();
+ var props = t.GetProperties(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance)
+ .Where(p => p.CanRead && p.GetIndexParameters().Length == 0)
+ .ToArray();
+ string? WireName(System.Reflection.PropertyInfo p) =>
+ (p.GetCustomAttributes(typeof(System.Text.Json.Serialization.JsonPropertyNameAttribute), inherit: true)
+ .FirstOrDefault() as System.Text.Json.Serialization.JsonPropertyNameAttribute)?.Name;
+ if (!props.Any(p => WireName(p) is not null)) return null;
+ return props.Select(p => (WireName(p) ?? p.Name, p)).ToArray();
});
+
+ ///
+ /// The derived-accessor overlay of a typed object. The object stays the thing members are
+ /// resolved on, and only when the template asks for one; the overlay adds the derived names
+ /// on top, and its string form is the object's own.
+ ///
+ private sealed class TypedView(object target, int depth, bool? last)
+ {
+ internal readonly object Target = target;
+ internal readonly int Depth = depth;
+ internal readonly bool? Last = last;
+
+ public override string? ToString() => Target.ToString();
+ }
+
+ /// The overlay type is registered for.
+ internal static Type ViewType => typeof(TypedView);
+
+ // The engine's own reflective member lookup, so a typed object resolves exactly the
+ // members it always did: properties, fields and parameterless methods.
+ private static readonly Stubble.Core.Settings.RendererSettingsDefaults.ValueGetterDelegate OwnMember =
+ Stubble.Core.Settings.RendererSettingsDefaults.DefaultValueGetters()[typeof(object)];
+
+ private const System.Reflection.BindingFlags PublicInstance =
+ System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance;
+
+ ///
+ /// The engine's value getter for an overlay: the object's own member when it has one of that
+ /// name, else the derived -last / has<Field> .
+ ///
+ internal static object? ViewLookup(object value, string key, bool ignoreCase)
+ {
+ var view = (TypedView)value;
+ if (OwnMember(view.Target, key, ignoreCase) is { } own) return Derive(own, view.Depth + 1, null);
+ var flags = ignoreCase ? PublicInstance | System.Reflection.BindingFlags.IgnoreCase : PublicInstance;
+ Type type = view.Target.GetType();
+ if (type.GetMember(key, flags).Length > 0) return null; // authored wins, even when null
+ if (key == LastAccessor) return view.Last;
+ if (!key.StartsWith(HasPrefix, StringComparison.Ordinal) || key.Length == HasPrefix.Length) return null;
+ string rest = key.Substring(HasPrefix.Length);
+ foreach (string field in new[] { char.ToLowerInvariant(rest[0]) + rest.Substring(1), rest })
+ {
+ if (HasAccessorName(field) != key) continue;
+ object? read;
+ if (type.GetProperty(field, PublicInstance) is { CanRead: true } prop && prop.GetIndexParameters().Length == 0)
+ read = prop.GetValue(view.Target);
+ else if (type.GetField(field, PublicInstance) is { } fld)
+ read = fld.GetValue(view.Target);
+ else continue;
+ if (AccessorValue(read) is { } derived) return derived;
+ }
+ return null;
+ }
}
diff --git a/server/csharp/MetaObjects.Render/Renderer.cs b/server/csharp/MetaObjects.Render/Renderer.cs
index 37b332822..42d4bf0b7 100644
--- a/server/csharp/MetaObjects.Render/Renderer.cs
+++ b/server/csharp/MetaObjects.Render/Renderer.cs
@@ -97,7 +97,9 @@ public static string Render(RenderRequest request)
var escaper = Escapers.For(request.Format);
var stubble = new StubbleBuilder()
- .Configure(settings => settings.SetEncodingFunction(v => escaper(v)))
+ .Configure(settings => settings
+ .SetEncodingFunction(v => escaper(v))
+ .AddValueGetter(PayloadAccessors.ViewType, PayloadAccessors.ViewLookup))
.Build();
// Derived `has` accessors are part of the payload contract, not of the
diff --git a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinFr019SharedProvidedConformanceTest.kt b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinFr019SharedProvidedConformanceTest.kt
index 3d9144220..756686caa 100644
--- a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinFr019SharedProvidedConformanceTest.kt
+++ b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinFr019SharedProvidedConformanceTest.kt
@@ -205,7 +205,6 @@ class KotlinFr019SharedProvidedConformanceTest {
fun `multi-hop projection and value-object fields reference the shared type`() {
val out = generate("com.acme.ext")
try {
- // Task.importance (two hops) and Subtask.rank (three hops) both reach Priority.
// Multi-hop fields reference the ONE shared Priority — on an entity (Task.importance,
// two hops; Subtask.rank, three hops), a projection (TicketSummary.level) and a value
// object (TicketBadge.badge) alike — and no per-object enum is written for any of them.
@@ -218,10 +217,12 @@ class KotlinFr019SharedProvidedConformanceTest {
assertTrue("val importance: Priority?" in task, "Task.importance must be typed Priority; saw:\n$task")
val subtask = shop.resolve("Subtask.kt").readText()
assertTrue("val rank: Priority" in subtask, "Subtask.rank must be typed Priority; saw:\n$subtask")
- for (name in listOf("TicketSummary.kt", "TicketBadge.kt")) {
- val f = shop.resolve(name)
- if (Files.exists(f)) assertTrue("Priority" in f.readText(), "$name must reference Priority; saw:\n${f.readText()}")
- }
+ val summary = shop.resolve("TicketSummary.kt").readText()
+ assertTrue("val level: Priority" in summary,
+ "TicketSummary.level (projection) must be typed Priority; saw:\n$summary")
+ val badge = shop.resolve("TicketBadge.kt").readText()
+ assertTrue("val badge: Priority" in badge,
+ "TicketBadge.badge (value object) must be typed Priority; saw:\n$badge")
} finally {
out.toFile().deleteRecursively()
}
@@ -232,11 +233,16 @@ class KotlinFr019SharedProvidedConformanceTest {
val out = generate("com.acme.ext")
try {
val refund = out.resolve("acme/shop/Refund.kt").readText()
- assertTrue("ticketTier" in refund && "invoiceTier" in refund && "unit" in refund
- && "tender" in refund && "orderStatus" in refund,
- "Refund.kt must declare all five fields; saw:\n$refund")
- assertTrue("com.acme.ext.Currency" in refund,
- "Refund.tender must reference the configured external namespace; saw:\n$refund")
+ // Two fields whose immediate supers are same-named fields ("tier") of different
+ // objects resolve to DIFFERENT shared declarations.
+ assertTrue("val ticketTier: Priority" in refund, "Refund.ticketTier must be typed Priority; saw:\n$refund")
+ assertTrue("val invoiceTier: Money" in refund, "Refund.invoiceTier must be typed Money; saw:\n$refund")
+ assertTrue("val unit: Money" in refund, "Refund.unit must be typed Money; saw:\n$refund")
+ // Refund.tender -> Ticket.currency -> the @provided Currency at the configured namespace.
+ assertTrue("import com.acme.ext.Currency" in refund && "val tender: Currency" in refund,
+ "Refund.tender must be typed by the configured external Currency; saw:\n$refund")
+ assertTrue("val orderStatus: RefundOrderStatus" in refund,
+ "Refund.orderStatus must keep its inline per-object enum; saw:\n$refund")
} finally {
out.toFile().deleteRecursively()
}
diff --git a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinTypedPayloadRenderTest.kt b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinTypedPayloadRenderTest.kt
index 2ad908367..e30914234 100644
--- a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinTypedPayloadRenderTest.kt
+++ b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinTypedPayloadRenderTest.kt
@@ -65,4 +65,55 @@ class KotlinTypedPayloadRenderTest {
assertEquals("a:y b:n ", render(template, viaMaps))
assertEquals(render(template, viaMaps), render(template, viaData))
}
+
+ // ---- The overlay adds names; it never takes any away -----------------------------------
+
+ class Money(val cents: Long, val currency: String) {
+ override fun toString(): String = "$currency ${cents}c"
+ }
+
+ data class Line(val sku: String, val price: Money) {
+ fun label(): String = "label:$sku"
+ val exploding: String get() = throw IllegalStateException("read a member the template never names")
+ }
+
+ data class Order(val id: String, val total: Money, val lines: List) {
+ fun summary(): String = "order $id"
+ }
+
+ private val order = Order("o-1", Money(1250, "USD"), listOf(Line("a", Money(500, "USD")), Line("b", Money(750, "USD"))))
+
+ /** The engine with no MetaObjects view at all: what a typed payload rendered before. */
+ private fun renderWithBareEngine(template: String, payload: Any): String {
+ val out = java.io.StringWriter()
+ com.github.mustachejava.DefaultMustacheFactory()
+ .compile(java.io.StringReader(template), "bare").execute(out, payload).flush()
+ return out.toString()
+ }
+
+ @Test
+ fun `a function member and a value's own string form still render`() {
+ assertEquals(
+ "order o-1 USD 1250c|a=label:a USD 500c;b=label:b USD 750c;",
+ render("{{summary}} {{total}}|{{#lines}}{{sku}}={{label}} {{price}};{{/lines}}", order),
+ )
+ }
+
+ @Test
+ fun `a property the template never names is never read`() {
+ assertEquals("a,b", render("{{#lines}}{{sku}}{{^-last}},{{/-last}}{{/lines}}", order))
+ }
+
+ @Test
+ fun `a template without derived names renders exactly as the bare engine does`() {
+ val template = "{{id}} {{summary}} {{total}} {{total.cents}}" +
+ "{{#lines}} [{{sku}} {{label}} {{price}} {{price.currency}} {{id}}]{{/lines}}" +
+ "{{#total}} {{currency}}{{/total}}{{^missing}} none{{/missing}}"
+ val expected = renderWithBareEngine(template, order)
+ assertEquals(
+ "o-1 order o-1 USD 1250c 1250 [a label:a USD 500c USD o-1] [b label:b USD 750c USD o-1] USD none",
+ expected,
+ )
+ assertEquals(expected, render(template, order))
+ }
}
diff --git a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringTypeMapper.java b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringTypeMapper.java
index ad42f5155..2d888b4b2 100644
--- a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringTypeMapper.java
+++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringTypeMapper.java
@@ -22,7 +22,9 @@
import com.metaobjects.identity.MetaIdentity;
import com.metaobjects.object.MetaObject;
+import java.util.HashSet;
import java.util.List;
+import java.util.Set;
/**
* Centralised mapping from {@link MetaField} subtype to the Java type used in
@@ -315,14 +317,23 @@ public static String payloadJavaTypeName(MetaField> field, MetaObject owner, S
}
/**
- * The generated Java {@code enum} type name for an enum-subtype payload field — the SAME
- * shared scheme the other ports use ({@code KotlinTypeMapper.enumTypeName} /
- * C# {@code CSharpNaming.EnumTypeName}): when {@code field} reaches a package-level shared
- * enum DECLARATION via {@link EnumField#resolveSharedEnumDeclaration} (the single
- * cross-generator authority — possibly multi-hop, short-circuited by an own
- * {@code @values}/{@code @intValueMap} along the way), the name is
- * {@code Pascal(declaration.shortName)}; otherwise (an inline field — no reachable
- * declaration) it is {@code Pascal(owner.shortName) + Pascal(field.name)}. Members verbatim.
+ * The generated Java {@code enum} type name for an enum-subtype payload field, in this order:
+ *
+ * {@code field} reaches a package-level shared enum DECLARATION via
+ * {@link EnumField#resolveSharedEnumDeclaration} (the single cross-generator authority —
+ * possibly multi-hop, stopped by an own {@code @values}/{@code @intValueMap} along the
+ * way): {@code Pascal(declaration.shortName)}.
+ * otherwise the enum is INLINE (nested in the owner's record). A field with a super is
+ * named for its top-most super, {@code Pascal(topMostSuper.shortName)}, so every field
+ * extending the same inline enum shares one nested type; a field with no super is
+ * {@code Pascal(owner.shortName) + Pascal(field.name)}.
+ * the one exception to the inline rule: the top-most super is itself a shared
+ * declaration, which the field does NOT resolve to because a node on the chain
+ * redeclares {@code @values}/{@code @intValueMap}. The super's name is the shared type's
+ * own name and would shadow it inside the record, so the inline enum is
+ * {@code Pascal(owner.shortName) + Pascal(field.name)}.
+ *
+ * Members verbatim.
*
* This is a BARE (unqualified) name — a shared declaration materialized in a different
* Java package, or marked {@code @provided}, is qualified by
@@ -333,9 +344,31 @@ public static String enumTypeName(MetaObject owner, MetaField> field) {
if (declaration != null) {
return pascal(SpringNaming.splitFqn(declaration.getName())[1]);
}
+ MetaField> superRoot = resolveSuperRoot(field);
+ if (superRoot != null && !EnumField.isSharedEnumDeclaration(superRoot)) {
+ return pascal(SpringNaming.splitFqn(superRoot.getName())[1]);
+ }
return pascal(SpringNaming.splitFqn(owner.getName())[1]) + pascal(field.getName());
}
+ /**
+ * Walk a field's {@code extends} (super-field) chain to the top-most ancestor, returning it,
+ * or {@code null} when the field has no super. Naming an inline enum after the top-most
+ * super makes every extending field share one nested type. Cycle-guarded via a visited set.
+ */
+ static MetaField> resolveSuperRoot(MetaField> field) {
+ MetaField> current = field.getSuperField();
+ if (current == null) return null;
+ Set seen = new HashSet<>();
+ while (true) {
+ MetaField> next = current.getSuperField();
+ if (next == null) break;
+ if (!seen.add(current.getName())) break; // cycle guard
+ current = next;
+ }
+ return current;
+ }
+
/**
* Read the EFFECTIVE {@code @values} of an {@link EnumField} (inheriting from an
* {@code extends}-super when the field carries no own values), as the verbatim member list.
diff --git a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/Fr019SharedProvidedConformanceTest.java b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/Fr019SharedProvidedConformanceTest.java
index c5b70eea7..227177d4e 100644
--- a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/Fr019SharedProvidedConformanceTest.java
+++ b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/Fr019SharedProvidedConformanceTest.java
@@ -294,26 +294,66 @@ public void refundFieldsResolveToDistinctSharedDeclarationsOnTheSameObject() thr
public void ownValuesOrIntValueMapStopsTheWalkAndKeepsAnInlinePerObjectEnum() throws Exception {
// Task.phase (own @values) and Task.weight (own @intValueMap) each extend a field whose
// OWN chain reaches the shared Priority — but their own redeclaration stops the walk
- // before it gets there, so they keep their per-object inline enum. Same for the
- // Subtask fields that extend them.
+ // before it gets there, so they stay inline. Same for the Subtask fields that extend them.
MetaDataLoader loader = loadShared();
Path out = run(loader, "com.acme.ext");
+ // Their top-most super is the shared Priority itself, whose name a nested enum would
+ // shadow inside the DTO, so each is named and carries its OWN members.
String taskDto = Files.readString(out.resolve("acme/shop/TaskDto.java"));
- assertTrue("Task.phase must keep its inline enum; saw:\n" + taskDto,
- taskDto.contains("public enum TaskPhase"));
- assertTrue("Task.weight must keep its inline enum; saw:\n" + taskDto,
- taskDto.contains("public enum TaskWeight"));
+ assertTrue("Task.phase must be the nested TaskPhase with its own members; saw:\n" + taskDto,
+ taskDto.contains("public enum TaskPhase { OPEN, DONE }") && taskDto.contains("TaskPhase phase"));
+ assertTrue("Task.weight must be the nested int-backed TaskWeight; saw:\n" + taskDto,
+ taskDto.contains("public enum TaskWeight {") && taskDto.contains("LOW(1)")
+ && taskDto.contains("HIGH(3)") && taskDto.contains("TaskWeight weight"));
+ // A shared-typed field in the SAME DTO still binds to the top-level shared Priority.
+ assertTrue("Task.urgency must still be typed by the shared Priority; saw:\n" + taskDto,
+ taskDto.contains("Priority urgency"));
+ assertFalse("TaskDto must not nest an enum named for the shared declaration; saw:\n" + taskDto,
+ taskDto.contains("public enum Priority"));
String subtaskDto = Files.readString(out.resolve("acme/shop/SubtaskDto.java"));
- assertTrue("Subtask.phase must keep its inline enum; saw:\n" + subtaskDto,
- subtaskDto.contains("public enum SubtaskPhase"));
- assertTrue("Subtask.weight must keep its inline enum; saw:\n" + subtaskDto,
- subtaskDto.contains("public enum SubtaskWeight"));
+ assertTrue("Subtask.phase must be the nested SubtaskPhase with the members it inherits; saw:\n" + subtaskDto,
+ subtaskDto.contains("public enum SubtaskPhase { OPEN, DONE }") && subtaskDto.contains("SubtaskPhase phase"));
+ assertTrue("Subtask.weight must be the nested int-backed SubtaskWeight; saw:\n" + subtaskDto,
+ subtaskDto.contains("public enum SubtaskWeight {") && subtaskDto.contains("MEDIUM(2)")
+ && subtaskDto.contains("SubtaskWeight weight"));
+ assertTrue("Subtask.rank must still be typed by the shared Priority; saw:\n" + subtaskDto,
+ subtaskDto.contains("Priority rank"));
+ assertFalse("SubtaskDto must not nest an enum named for the shared declaration; saw:\n" + subtaskDto,
+ subtaskDto.contains("public enum Priority"));
+ // Refund.orderStatus extends Order.status, an inline enum with no shared declaration
+ // anywhere on the chain: it keeps the name it always had, the top-most super's.
String refundDto = Files.readString(out.resolve("acme/shop/RefundDto.java"));
- assertTrue("Refund.orderStatus must keep its inline enum; saw:\n" + refundDto,
- refundDto.contains("public enum RefundOrderStatus"));
+ assertTrue("Refund.orderStatus must keep its nested Status enum; saw:\n" + refundDto,
+ refundDto.contains("public enum Status { NEW, PAID }") && refundDto.contains("Status orderStatus"));
+ }
+
+ @Test
+ public void valueObjectFieldOneHopFromASharedOrProvidedDeclarationReferencesItAndNestsNoEnum() throws Exception {
+ // TicketLabel.grade extends the shared Priority and TicketLabel.denomination the
+ // @provided Currency, each in ONE hop. The record references the top-level type and the
+ // configured external type; it declares no enum of its own.
+ Path voOut = tmp.newFolder().toPath();
+ Map args = new HashMap<>();
+ args.put("outputDir", voOut.toString());
+ args.put("providedEnumNamespace", "com.acme.ext");
+ SpringValueObjectGenerator voGen = new SpringValueObjectGenerator();
+ voGen.setArgs(args);
+ voGen.execute(loadShared());
+
+ String label = Files.readString(voOut.resolve("acme/shop/TicketLabel.java"));
+ assertTrue("TicketLabel.grade must be typed by the top-level shared Priority; saw:\n" + label,
+ label.contains("Priority grade"));
+ assertTrue("TicketLabel.denomination must reference the configured external Currency; saw:\n" + label,
+ label.contains("com.acme.ext.Currency denomination"));
+ assertFalse("TicketLabel must nest no enum; saw:\n" + label, label.contains("enum "));
+ assertTrue("the shared Priority must be emitted by the value-object run itself",
+ Files.readString(voOut.resolve("acme/shop/Priority.java"))
+ .contains("public enum Priority { LOW, MEDIUM, HIGH }"));
+ assertFalse("the @provided Currency must never be generated",
+ Files.exists(voOut.resolve("acme/shop/Currency.java")));
}
@Test
diff --git a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/InlineEnumNamesGoldenTest.java b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/InlineEnumNamesGoldenTest.java
new file mode 100644
index 000000000..e5d4e6822
--- /dev/null
+++ b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/InlineEnumNamesGoldenTest.java
@@ -0,0 +1,79 @@
+package com.metaobjects.generator.spring;
+
+import com.metaobjects.loader.MetaDataLoader;
+import com.metaobjects.registry.SharedRegistryTestBase;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.TreeMap;
+import java.util.stream.Stream;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+
+/**
+ * A model with NO shared enum declaration regenerates byte-identical Java.
+ *
+ * The contract under test is the generated output itself: {@code SpringDtoGenerator},
+ * {@code SpringValueObjectGenerator} and {@code SpringOutputParserGenerator} run over
+ * {@code golden/inline-enum-names/input/meta.json} — inline entity enums, a projection field and
+ * a value-object field that extend an entity's inline enum, a value object's own inline enum,
+ * and a two-hop chain that ends at an inline enum — and every emitted file must equal its
+ * counterpart under {@code golden/inline-enum-names/expected/}.
+ *
+ *
The oracle is independent of the code under test: the expected files were written by the
+ * generators as they stood at commit {@code 166c30786}, the commit the shared-enum rule
+ * (ADR-0026 Amendment 1) branched from. That commit's {@code server/java} tree was extracted
+ * with {@code git archive} into a temporary directory, built there, and the same three
+ * generators were run over this same input. What a wrong implementation gets caught on: naming
+ * an inline enum {@code } where it used to be named for its top-most super (so
+ * {@code ShipmentSummaryDto.Status} would become {@code ShipmentSummaryDto.ShipmentSummaryStatus}).
+ */
+public class InlineEnumNamesGoldenTest extends SharedRegistryTestBase {
+
+ private static final Path GOLDEN = Path.of("src/test/resources/golden/inline-enum-names");
+
+ @Rule
+ public TemporaryFolder tmp = new TemporaryFolder();
+
+ private static Map filesUnder(Path root) throws IOException {
+ Map files = new TreeMap<>();
+ try (Stream walk = Files.walk(root)) {
+ for (Path p : walk.filter(Files::isRegularFile).toList()) {
+ files.put(root.relativize(p).toString().replace('\\', '/'),
+ Files.readString(p, StandardCharsets.UTF_8));
+ }
+ }
+ return files;
+ }
+
+ @Test
+ public void aModelWithNoSharedEnumRegeneratesByteIdenticalJava() throws Exception {
+ MetaDataLoader loader = SpringTestFixtures.loadFixture(tmp.newFolder().toPath(), "inlineEnumNames",
+ Files.readString(GOLDEN.resolve("input/meta.json"), StandardCharsets.UTF_8));
+ Path out = tmp.newFolder().toPath();
+ Map args = new HashMap<>();
+ args.put("outputDir", out.toString());
+ for (var generator : List.of(
+ new SpringDtoGenerator(), new SpringValueObjectGenerator(), new SpringOutputParserGenerator())) {
+ generator.setArgs(args);
+ generator.execute(loader);
+ }
+
+ Map expected = filesUnder(GOLDEN.resolve("expected"));
+ Map actual = filesUnder(out);
+ assertFalse("the golden tree must not be empty", expected.isEmpty());
+ assertEquals("the generated file set must match the golden tree", expected.keySet(), actual.keySet());
+ for (Map.Entry e : expected.entrySet()) {
+ assertEquals(e.getKey() + " must be byte-identical to the golden file", e.getValue(), actual.get(e.getKey()));
+ }
+ }
+}
diff --git a/server/java/codegen-spring/src/test/resources/golden/inline-enum-names/expected/acme/shipping/CrateDto.java b/server/java/codegen-spring/src/test/resources/golden/inline-enum-names/expected/acme/shipping/CrateDto.java
new file mode 100644
index 000000000..36bb6b96d
--- /dev/null
+++ b/server/java/codegen-spring/src/test/resources/golden/inline-enum-names/expected/acme/shipping/CrateDto.java
@@ -0,0 +1,27 @@
+package acme.shipping;
+
+import jakarta.validation.constraints.*;
+
+/** GENERATED — wire DTO for Crate. Do not hand-edit; regenerated from metadata. */
+public record CrateDto(
+ @NotNull Long id,
+ @NotNull Status stage
+) {
+ public enum Status { PENDING, SHIPPED, DELIVERED }
+
+ /** A fluent builder, so a caller can set a subset of the components. */
+ public static Builder builder() { return new Builder(); }
+
+ /** Builds a {@link CrateDto}; an unset component stays null. */
+ public static final class Builder {
+ private Long id;
+ private Status stage;
+
+ private Builder() {}
+
+ public Builder id(Long v) { this.id = v; return this; }
+ public Builder stage(Status v) { this.stage = v; return this; }
+
+ public CrateDto build() { return new CrateDto(id, stage); }
+ }
+}
diff --git a/server/java/codegen-spring/src/test/resources/golden/inline-enum-names/expected/acme/shipping/CratePatch.java b/server/java/codegen-spring/src/test/resources/golden/inline-enum-names/expected/acme/shipping/CratePatch.java
new file mode 100644
index 000000000..6cb19bbf4
--- /dev/null
+++ b/server/java/codegen-spring/src/test/resources/golden/inline-enum-names/expected/acme/shipping/CratePatch.java
@@ -0,0 +1,56 @@
+package acme.shipping;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.metaobjects.generator.spring.runtime.PatchValidationException;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Set;
+
+/** GENERATED — presence-tracked partial-update (PATCH) shape for Crate. Do not hand-edit; regenerated from metadata. */
+public final class CratePatch {
+
+ private final Map assigned;
+
+ private CratePatch(Map assigned) { this.assigned = assigned; }
+
+ public boolean hasStage() { return assigned.containsKey("stage"); }
+ public CrateDto.Status stage() { return (CrateDto.Status) assigned.get("stage"); }
+
+ /** The field names ASSIGNED by this patch (present in the body), in order. */
+ public Set assignedFields() { return assigned.keySet(); }
+
+ /** The name→value map of fields ASSIGNED by this patch (present values, incl. explicit null). */
+ public java.util.Map assignedValues() { return java.util.Collections.unmodifiableMap(assigned); }
+
+ /**
+ * Build from a JSON body. A non-object body is rejected; an ABSENT key is
+ * untouched; a present null CLEARS a non-@required field and is a validation
+ * error on a @required one; a present value binds via Jackson exactly as
+ * create does. Unknown keys are ignored (FR-035 PATCH-3 deferred).
+ */
+ public static CratePatch fromJson(JsonNode body, ObjectMapper mapper) {
+ if (!body.isObject()) throw new PatchValidationException("(request body)");
+ Map assigned = new LinkedHashMap<>();
+ bind(body, mapper, assigned, "stage", new TypeReference() {}, true);
+ return new CratePatch(assigned);
+ }
+
+ private static void bind(JsonNode body, ObjectMapper mapper, Map assigned,
+ String field, TypeReference> type, boolean required) {
+ if (!body.has(field)) return; // absent → untouched
+ JsonNode node = body.get(field);
+ if (node.isNull()) {
+ if (required) throw new PatchValidationException(field);
+ assigned.put(field, null); // explicit null → clear
+ return;
+ }
+ try {
+ assigned.put(field, mapper.treeToValue(node, type));
+ } catch (JsonProcessingException e) {
+ throw new PatchValidationException(field);
+ }
+ }
+}
diff --git a/server/java/codegen-spring/src/test/resources/golden/inline-enum-names/expected/acme/shipping/ParcelDto.java b/server/java/codegen-spring/src/test/resources/golden/inline-enum-names/expected/acme/shipping/ParcelDto.java
new file mode 100644
index 000000000..3fd14af43
--- /dev/null
+++ b/server/java/codegen-spring/src/test/resources/golden/inline-enum-names/expected/acme/shipping/ParcelDto.java
@@ -0,0 +1,27 @@
+package acme.shipping;
+
+import jakarta.validation.constraints.*;
+
+/** GENERATED — wire DTO for Parcel. Do not hand-edit; regenerated from metadata. */
+public record ParcelDto(
+ @NotNull Long id,
+ @NotNull Status phase
+) {
+ public enum Status { PENDING, SHIPPED, DELIVERED }
+
+ /** A fluent builder, so a caller can set a subset of the components. */
+ public static Builder builder() { return new Builder(); }
+
+ /** Builds a {@link ParcelDto}; an unset component stays null. */
+ public static final class Builder {
+ private Long id;
+ private Status phase;
+
+ private Builder() {}
+
+ public Builder id(Long v) { this.id = v; return this; }
+ public Builder phase(Status v) { this.phase = v; return this; }
+
+ public ParcelDto build() { return new ParcelDto(id, phase); }
+ }
+}
diff --git a/server/java/codegen-spring/src/test/resources/golden/inline-enum-names/expected/acme/shipping/ParcelPatch.java b/server/java/codegen-spring/src/test/resources/golden/inline-enum-names/expected/acme/shipping/ParcelPatch.java
new file mode 100644
index 000000000..7000886cf
--- /dev/null
+++ b/server/java/codegen-spring/src/test/resources/golden/inline-enum-names/expected/acme/shipping/ParcelPatch.java
@@ -0,0 +1,56 @@
+package acme.shipping;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.metaobjects.generator.spring.runtime.PatchValidationException;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Set;
+
+/** GENERATED — presence-tracked partial-update (PATCH) shape for Parcel. Do not hand-edit; regenerated from metadata. */
+public final class ParcelPatch {
+
+ private final Map assigned;
+
+ private ParcelPatch(Map assigned) { this.assigned = assigned; }
+
+ public boolean hasPhase() { return assigned.containsKey("phase"); }
+ public ParcelDto.Status phase() { return (ParcelDto.Status) assigned.get("phase"); }
+
+ /** The field names ASSIGNED by this patch (present in the body), in order. */
+ public Set assignedFields() { return assigned.keySet(); }
+
+ /** The name→value map of fields ASSIGNED by this patch (present values, incl. explicit null). */
+ public java.util.Map assignedValues() { return java.util.Collections.unmodifiableMap(assigned); }
+
+ /**
+ * Build from a JSON body. A non-object body is rejected; an ABSENT key is
+ * untouched; a present null CLEARS a non-@required field and is a validation
+ * error on a @required one; a present value binds via Jackson exactly as
+ * create does. Unknown keys are ignored (FR-035 PATCH-3 deferred).
+ */
+ public static ParcelPatch fromJson(JsonNode body, ObjectMapper mapper) {
+ if (!body.isObject()) throw new PatchValidationException("(request body)");
+ Map assigned = new LinkedHashMap<>();
+ bind(body, mapper, assigned, "phase", new TypeReference() {}, true);
+ return new ParcelPatch(assigned);
+ }
+
+ private static void bind(JsonNode body, ObjectMapper mapper, Map assigned,
+ String field, TypeReference> type, boolean required) {
+ if (!body.has(field)) return; // absent → untouched
+ JsonNode node = body.get(field);
+ if (node.isNull()) {
+ if (required) throw new PatchValidationException(field);
+ assigned.put(field, null); // explicit null → clear
+ return;
+ }
+ try {
+ assigned.put(field, mapper.treeToValue(node, type));
+ } catch (JsonProcessingException e) {
+ throw new PatchValidationException(field);
+ }
+ }
+}
diff --git a/server/java/codegen-spring/src/test/resources/golden/inline-enum-names/expected/acme/shipping/ShipmentDto.java b/server/java/codegen-spring/src/test/resources/golden/inline-enum-names/expected/acme/shipping/ShipmentDto.java
new file mode 100644
index 000000000..071d54e9f
--- /dev/null
+++ b/server/java/codegen-spring/src/test/resources/golden/inline-enum-names/expected/acme/shipping/ShipmentDto.java
@@ -0,0 +1,31 @@
+package acme.shipping;
+
+import jakarta.validation.constraints.*;
+
+/** GENERATED — wire DTO for Shipment. Do not hand-edit; regenerated from metadata. */
+public record ShipmentDto(
+ @NotNull Long id,
+ @NotNull ShipmentStatus status,
+ ShipmentMode mode
+) {
+ public enum ShipmentStatus { PENDING, SHIPPED, DELIVERED }
+ public enum ShipmentMode { AIR, SEA }
+
+ /** A fluent builder, so a caller can set a subset of the components. */
+ public static Builder builder() { return new Builder(); }
+
+ /** Builds a {@link ShipmentDto}; an unset component stays null. */
+ public static final class Builder {
+ private Long id;
+ private ShipmentStatus status;
+ private ShipmentMode mode;
+
+ private Builder() {}
+
+ public Builder id(Long v) { this.id = v; return this; }
+ public Builder status(ShipmentStatus v) { this.status = v; return this; }
+ public Builder mode(ShipmentMode v) { this.mode = v; return this; }
+
+ public ShipmentDto build() { return new ShipmentDto(id, status, mode); }
+ }
+}
diff --git a/server/java/codegen-spring/src/test/resources/golden/inline-enum-names/expected/acme/shipping/ShipmentNote.java b/server/java/codegen-spring/src/test/resources/golden/inline-enum-names/expected/acme/shipping/ShipmentNote.java
new file mode 100644
index 000000000..6b095b6f3
--- /dev/null
+++ b/server/java/codegen-spring/src/test/resources/golden/inline-enum-names/expected/acme/shipping/ShipmentNote.java
@@ -0,0 +1,34 @@
+package acme.shipping;
+
+import jakarta.validation.constraints.*;
+
+/** GENERATED — value object ShipmentNote. Do not hand-edit; regenerated from metadata. */
+public record ShipmentNote(
+ @NotNull @Size(min = 1) String text,
+ ShipmentNoteKind kind,
+ @NotNull Status status,
+ @NotNull Status stage
+) {
+ public enum ShipmentNoteKind { INFO, ALERT }
+ public enum Status { PENDING, SHIPPED, DELIVERED }
+
+ /** A fluent builder, so a caller can set a subset of the components. */
+ public static Builder builder() { return new Builder(); }
+
+ /** Builds a {@link ShipmentNote}; an unset component stays null. */
+ public static final class Builder {
+ private String text;
+ private ShipmentNoteKind kind;
+ private Status status;
+ private Status stage;
+
+ private Builder() {}
+
+ public Builder text(String v) { this.text = v; return this; }
+ public Builder kind(ShipmentNoteKind v) { this.kind = v; return this; }
+ public Builder status(Status v) { this.status = v; return this; }
+ public Builder stage(Status v) { this.stage = v; return this; }
+
+ public ShipmentNote build() { return new ShipmentNote(text, kind, status, stage); }
+ }
+}
diff --git a/server/java/codegen-spring/src/test/resources/golden/inline-enum-names/expected/acme/shipping/ShipmentPatch.java b/server/java/codegen-spring/src/test/resources/golden/inline-enum-names/expected/acme/shipping/ShipmentPatch.java
new file mode 100644
index 000000000..e20cbd04b
--- /dev/null
+++ b/server/java/codegen-spring/src/test/resources/golden/inline-enum-names/expected/acme/shipping/ShipmentPatch.java
@@ -0,0 +1,59 @@
+package acme.shipping;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.metaobjects.generator.spring.runtime.PatchValidationException;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Set;
+
+/** GENERATED — presence-tracked partial-update (PATCH) shape for Shipment. Do not hand-edit; regenerated from metadata. */
+public final class ShipmentPatch {
+
+ private final Map assigned;
+
+ private ShipmentPatch(Map assigned) { this.assigned = assigned; }
+
+ public boolean hasStatus() { return assigned.containsKey("status"); }
+ public ShipmentDto.ShipmentStatus status() { return (ShipmentDto.ShipmentStatus) assigned.get("status"); }
+ public boolean hasMode() { return assigned.containsKey("mode"); }
+ public ShipmentDto.ShipmentMode mode() { return (ShipmentDto.ShipmentMode) assigned.get("mode"); }
+
+ /** The field names ASSIGNED by this patch (present in the body), in order. */
+ public Set assignedFields() { return assigned.keySet(); }
+
+ /** The name→value map of fields ASSIGNED by this patch (present values, incl. explicit null). */
+ public java.util.Map assignedValues() { return java.util.Collections.unmodifiableMap(assigned); }
+
+ /**
+ * Build from a JSON body. A non-object body is rejected; an ABSENT key is
+ * untouched; a present null CLEARS a non-@required field and is a validation
+ * error on a @required one; a present value binds via Jackson exactly as
+ * create does. Unknown keys are ignored (FR-035 PATCH-3 deferred).
+ */
+ public static ShipmentPatch fromJson(JsonNode body, ObjectMapper mapper) {
+ if (!body.isObject()) throw new PatchValidationException("(request body)");
+ Map assigned = new LinkedHashMap<>();
+ bind(body, mapper, assigned, "status", new TypeReference() {}, true);
+ bind(body, mapper, assigned, "mode", new TypeReference() {}, false);
+ return new ShipmentPatch(assigned);
+ }
+
+ private static void bind(JsonNode body, ObjectMapper mapper, Map assigned,
+ String field, TypeReference> type, boolean required) {
+ if (!body.has(field)) return; // absent → untouched
+ JsonNode node = body.get(field);
+ if (node.isNull()) {
+ if (required) throw new PatchValidationException(field);
+ assigned.put(field, null); // explicit null → clear
+ return;
+ }
+ try {
+ assigned.put(field, mapper.treeToValue(node, type));
+ } catch (JsonProcessingException e) {
+ throw new PatchValidationException(field);
+ }
+ }
+}
diff --git a/server/java/codegen-spring/src/test/resources/golden/inline-enum-names/expected/acme/shipping/ShipmentSummaryDto.java b/server/java/codegen-spring/src/test/resources/golden/inline-enum-names/expected/acme/shipping/ShipmentSummaryDto.java
new file mode 100644
index 000000000..bdfadca7f
--- /dev/null
+++ b/server/java/codegen-spring/src/test/resources/golden/inline-enum-names/expected/acme/shipping/ShipmentSummaryDto.java
@@ -0,0 +1,11 @@
+package acme.shipping;
+
+import jakarta.validation.constraints.*;
+
+/** GENERATED — wire DTO for ShipmentSummary. Do not hand-edit; regenerated from metadata. */
+public record ShipmentSummaryDto(
+ @NotNull Long id,
+ @NotNull Status status
+) {
+ public enum Status { PENDING, SHIPPED, DELIVERED }
+}
diff --git a/server/java/codegen-spring/src/test/resources/golden/inline-enum-names/expected/acme/shipping/ShipmentSummaryPatch.java b/server/java/codegen-spring/src/test/resources/golden/inline-enum-names/expected/acme/shipping/ShipmentSummaryPatch.java
new file mode 100644
index 000000000..a7726a101
--- /dev/null
+++ b/server/java/codegen-spring/src/test/resources/golden/inline-enum-names/expected/acme/shipping/ShipmentSummaryPatch.java
@@ -0,0 +1,56 @@
+package acme.shipping;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.metaobjects.generator.spring.runtime.PatchValidationException;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Set;
+
+/** GENERATED — presence-tracked partial-update (PATCH) shape for ShipmentSummary. Do not hand-edit; regenerated from metadata. */
+public final class ShipmentSummaryPatch {
+
+ private final Map assigned;
+
+ private ShipmentSummaryPatch(Map assigned) { this.assigned = assigned; }
+
+ public boolean hasStatus() { return assigned.containsKey("status"); }
+ public ShipmentSummaryDto.Status status() { return (ShipmentSummaryDto.Status) assigned.get("status"); }
+
+ /** The field names ASSIGNED by this patch (present in the body), in order. */
+ public Set assignedFields() { return assigned.keySet(); }
+
+ /** The name→value map of fields ASSIGNED by this patch (present values, incl. explicit null). */
+ public java.util.Map assignedValues() { return java.util.Collections.unmodifiableMap(assigned); }
+
+ /**
+ * Build from a JSON body. A non-object body is rejected; an ABSENT key is
+ * untouched; a present null CLEARS a non-@required field and is a validation
+ * error on a @required one; a present value binds via Jackson exactly as
+ * create does. Unknown keys are ignored (FR-035 PATCH-3 deferred).
+ */
+ public static ShipmentSummaryPatch fromJson(JsonNode body, ObjectMapper mapper) {
+ if (!body.isObject()) throw new PatchValidationException("(request body)");
+ Map assigned = new LinkedHashMap<>();
+ bind(body, mapper, assigned, "status", new TypeReference() {}, true);
+ return new ShipmentSummaryPatch(assigned);
+ }
+
+ private static void bind(JsonNode body, ObjectMapper mapper, Map assigned,
+ String field, TypeReference> type, boolean required) {
+ if (!body.has(field)) return; // absent → untouched
+ JsonNode node = body.get(field);
+ if (node.isNull()) {
+ if (required) throw new PatchValidationException(field);
+ assigned.put(field, null); // explicit null → clear
+ return;
+ }
+ try {
+ assigned.put(field, mapper.treeToValue(node, type));
+ } catch (JsonProcessingException e) {
+ throw new PatchValidationException(field);
+ }
+ }
+}
diff --git a/server/java/codegen-spring/src/test/resources/golden/inline-enum-names/expected/acme/shipping/prompts/NoteParser.java b/server/java/codegen-spring/src/test/resources/golden/inline-enum-names/expected/acme/shipping/prompts/NoteParser.java
new file mode 100644
index 000000000..728376546
--- /dev/null
+++ b/server/java/codegen-spring/src/test/resources/golden/inline-enum-names/expected/acme/shipping/prompts/NoteParser.java
@@ -0,0 +1,80 @@
+// GENERATED — DO NOT EDIT — response parser for template.prompt `acme::shipping::Note`
+package acme.shipping.prompts;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.metaobjects.render.extract.Format;
+import com.metaobjects.render.extract.ExtractMap;
+
+/** Parser for LLM responses matching the `Note` template.prompt. */
+public final class NoteParser {
+
+ private static final ObjectMapper MAPPER = new ObjectMapper().findAndRegisterModules();
+
+ private NoteParser() { /* no instances */ }
+
+ /**
+ * Parse an LLM response into a typed {@link acme.shipping.ShipmentNote}.
+ *
+ * @throws JsonProcessingException when the input is not valid JSON for the response schema.
+ */
+ public static acme.shipping.ShipmentNote parse(String text) throws JsonProcessingException {
+ return MAPPER.readValue(text, acme.shipping.ShipmentNote.class);
+ }
+
+ /** Payload FQN this parser extracts — resolved against the supplied loader at runtime. */
+ public static final String PAYLOAD_FQN = "acme::shipping::ShipmentNote";
+
+ /**
+ * Tolerant best-effort extraction delegating to the runtime
+ * {@link com.metaobjects.object.extract.MetaObjectExtractor}; never throws.
+ * Fully populates nested-object and array-of-object components (unlike the
+ * self-contained {@code extractLenient(String)} overload). Resolves this payload's
+ * {@code MetaObject} by {@link #PAYLOAD_FQN} from {@code loader}.
+ */
+ public static com.metaobjects.render.extract.ExtractionResult extractLenient(com.metaobjects.loader.MetaDataLoader loader, String text) {
+ return extractLenient(loader, text, com.metaobjects.render.extract.ExtractOptions.defaults());
+ }
+
+ public static com.metaobjects.render.extract.ExtractionResult extractLenient(com.metaobjects.loader.MetaDataLoader loader, String text, com.metaobjects.render.extract.ExtractOptions opts) {
+ com.metaobjects.object.MetaObject mo = loader.getMetaObjectByName(PAYLOAD_FQN);
+ com.metaobjects.render.extract.ExtractionResult raw =
+ com.metaobjects.object.extract.MetaObjectExtractor.extract(mo, text, Format.JSON, opts);
+ // The assembled graph is a ValueObject (a Map) with nested
+ // ValueObjects / List — map it into the value objects' records.
+ @SuppressWarnings("unchecked")
+ java.util.Map d = (java.util.Map) raw.data();
+ return new com.metaobjects.render.extract.ExtractionResult<>(fromShipmentNote(d), raw.report());
+ }
+
+ /** Map an assembled ValueObject (Map) into a typed {@link acme.shipping.ShipmentNote}. Generated; null-tolerant. */
+ private static acme.shipping.ShipmentNote fromShipmentNote(java.util.Map d) {
+ if (d == null) return null;
+ return new acme.shipping.ShipmentNote(
+ ExtractMap.asString(d, "text"),
+ java.util.Optional.ofNullable(ExtractMap.asString(d, "kind")).map(acme.shipping.ShipmentNote.ShipmentNoteKind::valueOf).orElse(null),
+ java.util.Optional.ofNullable(ExtractMap.asString(d, "status")).map(acme.shipping.ShipmentNote.Status::valueOf).orElse(null),
+ java.util.Optional.ofNullable(ExtractMap.asString(d, "stage")).map(acme.shipping.ShipmentNote.Status::valueOf).orElse(null)
+ );
+ }
+
+ /** Null-tolerant cast of an assembled value to a Map (a ValueObject IS a Map). */
+ @SuppressWarnings("unchecked")
+ private static java.util.Map asMap(Object v) {
+ return (v instanceof java.util.Map) ? (java.util.Map) v : null;
+ }
+
+ /** Map each element of an assembled List via {@code fn}; null/absent -> null; non-Map elements skipped. */
+ private static