Skip to content

feat: shared enum through multi-hop extends, list -last accessor, value-object rules - #426

Merged
dmealing merged 7 commits into
mainfrom
fm/mo-1-1-0-adopter-gaps
Oct 11, 2026
Merged

dmealing merged 7 commits into
mainfrom
fm/mo-1-1-0-adopter-gaps

Conversation

@dmealing

@dmealing dmealing commented Oct 11, 2026 •

Copy link
Copy Markdown
Member

What Changed

  • Enum fields keep the shared enum type through any number of extends hops in all five ports. One resolver in server/typescript/packages/metadata/src/core/field/shared-enum.ts is called by the loader and every codegen site. A field that redeclares @values or @intValueMap on the chain still gets its own inline enum. Java value objects and Kotlin generators now reference shared and @provided enums instead of nesting copies, which changes some generated names and needs the build settings and re-eject steps listed in the CHANGELOG.
  • Mustache templates get a derived -last accessor on every object element of a collection, so {{^-last}}, {{/-last}} separators need no payload flag. meta verify --templates accepts -last only inside a section over a list of objects, and the render path (payload-accessors in TypeScript, C#, Java, Kotlin and Python) adds has<Field> and -last without changing rendered bytes for templates that do not use them.
  • TypeScript object.value modules now export a const <ValueObject> with per-field rules (and name, label, view), matching the entity constant minus $table and $path. meta gen adds it to existing value-object modules, and three-way merge keeps hand edits.

Gate runs

This PR took 2 gate runs.

  • Run 1 applied three review fix rounds, then stopped on a review-provider error, not on a code finding. Its commits were kept.
  • Run 2 rebased onto main, applied two more review fix rounds and one test-step fix round, and passed.

Two notes on the validation details below:

  • The Testing section's open question (an abstract object.value getting the field-rules constant) does not reproduce on the final commit. The composer in value-object-file.ts excludes an abstract value object, a regression test covers it, and the test step was approved on that evidence.
  • The gate's test step ran the TypeScript lanes only. On the final commit the other ports were run locally and pass: Java and Kotlin (six Maven modules, 3,423 tests), C# (2,696 passed, 1 skipped), Python (2,824 passed), plus scripts/ci-local.sh --quick. The Docker integration lanes were not run.
Validation

Risk Assessment

⚠️ Medium: The change alters generated enum types in five ports and puts a new reflective overlay on the render path for typed payloads, so its reach is wide, but this pass found no functional defect in the current code and the one remaining issue is a release-note sentence.

Testing

Eight TypeScript scenarios driven live against the CLI and render library from worktree source in disposable projects, with a base-commit comparison. All intended behaviors pass. One open question: abstract object.value gets the constant, contradicting the CHANGELOG (review ask-user item). Per-package regression all green: render 382, codegen-ts 2557, cli 1470, metadata 3061. Non-TS ports untested. Evidence in ~/.no-mistakes/evidence/01M4NBD22X9F5TYV6EYEHZZHDV. Worktree clean.

  • Live validation: ✅ go - 9 of 10 scenarios driven live against the product
Scenario Result Live Evidence
Multi-hop enum field reuses the shared abstract enum (codegen-ts) ✅ pass live meta gen in /tmp/mo-val/proj: Task.ts uses PriorityEnum, no UrgencyEnum. Base 86bbadb emits UrgencyEnum inline. Copies in evidence gen-fixed/ and gen-base/.
meta verify --codegen clean after fixed gen ✅ pass live meta verify --codegen in /tmp/mo-val/proj: no codegen drift.
Mustache -last separator renders a, b, c with no last flag in payload ✅ pass live /tmp/mo-val/drive/render-drive.ts, R1 gives "a, b, c". Saved as render-scenarios.json.
Single-element and empty list separator behavior ✅ pass live render-drive R2: single gives "[only]", empty gives "[]".
Emptiness test {{^hasTags}} / {{#hasTags}} without hand-written boolean ✅ pass live render-drive R3: empty gives "none", non-empty gives "has".
Class-instance payload renders same bytes as plain map ✅ pass live render-drive R4: both give "a|b".
Adversarial: -last outside a list rejected by verify ✅ pass live render-drive R5: ERR_VAR_NOT_ON_PAYLOAD for top-level and scalar-list; in-list object -last returns [].
Value object emits field-rules constant (Label.title.rules.maxLength.value 40) ✅ pass live Fixed Label.ts has export const Label; base emits none.
Adversarial: abstract object.value gets rules constant (review concern) ✅ pass live BaseAddress generated file contains const BaseAddress = {...}, contradicting CHANGELOG 'abstract objects unchanged'. Open ruling.
Java, Kotlin, C#, Python ports ⏸️ untested no Not driven live: needs Maven, dotnet, Kotlin or pip toolchains, not set up in the workspace.
  • Evidence: Fixed generated output (local file: ~/.no-mistakes/evidence/01M4NBD22X9F5TYV6EYEHZZHDV/gen-fixed)
  • Evidence: Base generated output (local file: ~/.no-mistakes/evidence/01M4NBD22X9F5TYV6EYEHZZHDV/gen-base)
Evidence: Render scenario results
{
  "R1_separator": "\"a, b, c\"",
  "R2_single": "\"[only]\"",
  "R2_empty_list": "\"[]\"",
  "R3_empty_inverted": "\"none\"",
  "R3_nonempty": "\"has\"",
  "R4_plain": "\"a|b\"",
  "R4_class": "\"a|b\"",
  "R5_verify_top_level": "[{\"code\":\"ERR_VAR_NOT_ON_PAYLOAD\",\"path\":\"-last\"}]",
  "R5_verify_list_ok": "[]",
  "R5_verify_scalar_list": "[{\"code\":\"ERR_VAR_NOT_ON_PAYLOAD\",\"path\":\"-last\"}]",
  "ERR_CODE": "ERR_VAR_NOT_ON_PAYLOAD"
}
  • Outcome: ⚠️ 1 warning across 2 runs (1h33m11s)

Pipeline

Updates from git push no-mistakes

✅ **Intent** - passed

✅ No issues found.

🔧 **Rebase** - 2 issues found → auto-fixed ✅
  • ⚠️ CHANGELOG.md - merge conflict rebasing onto origin/main
  • ⚠️ docs/CONFORMANCE.md - merge conflict rebasing onto origin/main

🔧 Fix applied.
✅ Re-checked - no issues remain.

⚠️ **Review** - 2 infos
  • ⚠️ server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinTypeMapper.kt:204 - Fix round 3 (8165ab4) changed the signatures of Kotlin helper functions that ejected generators call, and left no source-compatible form. mvn metaobjects:eject copies a generator's source verbatim into the adopter's codegen/ module (codegen-kotlin/pom.xml ship-eject-reference-source), and that copy keeps importing the helpers from the metaobjects-codegen-kotlin jar. Concrete sequence: an adopter ejected KotlinEntityGenerator before this change. The copy calls Fr019SharedEnum.ProvidedEnumConfig.of(getArg(...), getArg(...)), KotlinEnumEmitter.emitEnumFile(obj, field, outRoot, emittedEnumFqns), Fr019SharedEnum.providedClassName(field, fr019Config)?.let {...}, KotlinTypeMapper.enumTypeName(field, owner) and KotlinGenUtil.reportEnumClasses(obj, loader). After the upgrade none of these resolve, so the adopter's codegen module stops compiling, on a model with no @provided enum at all. Changed or removed signatures: KotlinTypeMapper.enumTypeName (KotlinTypeMapper.kt:204, two-argument form removed), ProvidedEnumConfig.of (Fr019SharedEnum.kt:41, removed), providedClassName (Fr019SharedEnum.kt, parameter and nullability changed), KotlinEnumEmitter.emitEnumFile (KotlinEnumEmitter.kt:35), KotlinM2mSupport.resolve and tphQueryFnNames (KotlinM2mSupport.kt:99, 223), KotlinGenUtil.reportEnumClasses (KotlinGenUtil.kt:699, 725), KotlinExtractSchemaEmitter.emitMirrorFiles and mirrorSource (KotlinExtractSchemaEmitter.kt:66, 84), and the KotlinApiModelBuilder constructor. Ejectable generators whose old copies call them: KotlinEntityGenerator, KotlinExposedTableGenerator, KotlinSpringControllerGenerator, KotlinRelationsGenerator, KotlinOutputParserGenerator (GeneratorRegistry.kt:121-206). This is a sibling of the round-2 finding java-payloadfield-ctor-break, which was fixed with a compatibility constructor; round 3 left the Kotlin helpers behind. The CHANGELOG [1.1.0] entry tells TypeScript adopters that an ejected entity generator needs no resync and says nothing for Kotlin. docs/compatibility-policy.md allows generator internals to change in a minor, so the remedy needs a decision, not the defect: either keep deprecated overloads with the old signatures that delegate with an empty provided config, or state in the CHANGELOG upgrade notes that an ejected Kotlin generator that types an enum must be re-ejected or updated to the new signatures.

🔧 Fix applied.
3 issues (2 warnings, 1 info) still open:

  • ⚠️ server/java/render/src/main/java/com/metaobjects/render/PayloadAccessors.java:199 - isViewable treats a primitive array as an application object, so the Java renderer wraps it in a TypedView and stops iterating it. This code came from the previous run's fix rounds (20ce9ed added isViewable; 34f61b0 kept it when it made the view an overlay). Trace: a payload value int[] {1,2,3} (a map value, a record component, a bean getter, or a Kotlin data class IntArray / DoubleArray property, which uses the same path). withDerivedAccessors skips it at line 185 because that branch requires a non-primitive component type. Line 191 then calls isViewable: the value is not a Number, CharSequence or Iterable, and its class name is [I, which matches none of the java. / javax. / jdk. / kotlin. prefixes, so it returns true. The engine receives a TypedView, which is a Map. Mustache.java 0.9.14 AbstractObjectHandler.iterate iterates a value only when it is a List, Iterable, Iterator or array, so {{#scores}}{{.}},{{/scores}} renders the body once with [I@&lt;hash&gt; in place of 1,2,3,. falsey treats any other object as truthy, so {{^scores}}none{{/scores}} over an empty primitive array renders nothing. At the base commit the array reached the engine untouched and rendered per element. No error is raised, and it breaks the standing constraint that existing templates render identical bytes. The new typed-payload tests use List members only. Fix: return false from isViewable when payload.getClass().isArray(). No sibling in the other ports: C# arrays take the IEnumerable branch, TypeScript arrays take Array.isArray, and Python sequences are handled before any view.
  • ⚠️ server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringOutputParserGenerator.java:399 - A Java adopter who owns (ejected) exactly one of value-object and output-parser gets generated code that no longer compiles after upgrading, and the CHANGELOG does not say so. Both generators are ejectable (codegen-spring/pom.xml ship-eject-reference-source). This is the Java sibling of the Kotlin owned-generator finding fixed in this run's round 1, which covered Kotlin only. Sequence: a response value object TicketPayload has priority extends Priority, a shared root abstract enum, one hop. Before this change the value-object generator nested TicketPayload.Priority and the parser emitted TicketPayload.Priority.valueOf(s). (1) Owned old parser copy with the upgraded package value-object generator: the record no longer nests the enum (SpringValueObjectGenerator.java:226 skips it, :202 types the component by the top-level Priority), while the owned parser still emits payloadClass + &#34;.&#34; + SpringTypeMapper.enumTypeName(owner, ef), which is TicketPayload.Priority, a class that no longer exists. (2) Owned old value-object copy with the upgraded package parser: the record still nests TicketPayload.Priority, and the parser now passes acme.shop.Priority.valueOf(s) (line 399, qualifiedTypeReference) to that constructor, which is an incompatible type. Both builds compiled before. An owned dto copy is not affected, because it resolves through the jar's Fr019SharedEnum. CHANGELOG.md:579-598 lists the Java value-object change and CHANGELOG.md:610 gives Kotlin a re-eject note; Java has none. No compatibility overload can repair a disagreement in emitted shape, so the remedy needs a decision, not the defect: for example a Java upgrade note in the CHANGELOG and docs/ports/java.md saying an owned value-object or output-parser copy must be re-ejected together with the other (mvn metaobjects:eject -Dnames=value-object,output-parser -Dforce, or the manual diff procedure for an edited copy).
  • ℹ️ server/typescript/packages/codegen-ts/src/templates/value-object-file.ts:92 - The field-rules constant is also emitted for an ABSTRACT object.value, which the recorded decision and the CHANGELOG say does not happen. The decision reads: "Emit the field-rules constant only for the value-object subtype (use the named subtype constant). No constant for abstract objects, for an entity emitted to a contract-only target, for a TPH subtype or for a sourceless entity." The gate is obj.subType === OBJECT_SUBTYPE_VALUE and has no abstract check. An abstract value object such as a shared payload base reaches this composer through templates/entity-file.ts:75 when emitAbstractShapes is on, which is the TypeScript default, so BaseAddress.ts now exports const BaseAddress = { $entity, ... }. The comment at line 89 and CHANGELOG.md:120 ("Only the value subtype takes it: an abstract object ... are unchanged") both state the opposite, and the absence test in test/generators/entity-file.test.ts uses an abstract entity only. The two sentences of the decision conflict for this one case, so it needs a ruling: either add &amp;&amp; !obj.isAbstract to the gate with a test for an abstract value object, or keep the constant (the limits are real and concrete subtypes inherit them) and correct the comment and the CHANGELOG sentence.

🔧 Fix applied.
2 infos still open:

  • ℹ️ CHANGELOG.md:100 - The sentence "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" follows a list that includes a Java record, a C# object with [JsonPropertyName] and a Pydantic model. Those three shapes are not overlaid. They are copied into a map with every member read up front: server/java/render/src/main/java/com/metaobjects/render/PayloadAccessors.java:151-162 invokes every record accessor and recurses into each value; server/csharp/MetaObjects.Render/PayloadAccessors.cs:154-159 calls GetValue on every wire-named property; server/python/src/metaobjects/render/payload_accessors.py:113-115 calls model_dump(mode=&#34;json&#34;). These are the generated payload types of the Java, C# and Python ports, and a method on a Java record that is not a component accessor does not resolve at all. The behavior is unchanged from the base commit, so nothing regressed; only the release note is wrong. The overlay (lazy, own members and string form kept) applies to a TypeScript class instance, a C# object without wire names, a Java getter-based bean and a Kotlin data class. Fix the wording: limit the overlay sentence to those four shapes and say that a record, a wire-named C# object and a Pydantic model are viewed as the map of their fields, as before. This sentence was written by the earlier fix round that made the C# and Java views an overlay.
  • ℹ️ server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinTypedPayloadRenderTest.kt:122 - The two JVM laziness tests assert only the rendered output, so they pass on an eager view that swallows read failures. Both were added in the previous run's fix round 2 (34f61b0), whose decision required lazy resolution and tests that fail on round 1's code. Round 1's Java view (20ce9ed, readersFor loop) read every getter up front inside catch (ReflectiveOperationException | RuntimeException e). Trace the Kotlin test on that code: Line.getExploding() throws and is swallowed, getSku() is read, -last is injected, and the output is a,b. The test passes with the eager walk present. Sibling: server/java/render/src/test/java/com/metaobjects/render/TypedPayloadRenderTest.java:194 (aMemberTheTemplateNeverNamesIsNeverRead) fails on round 1's code only because sku is a package-private field, not because a member was read. The current TypedView.resolve is lazy, so no product defect exists today. Fix in both tests: count reads instead of throwing only (a counter incremented in the unused getter, asserted 0 after render), so an eager view fails whether or not it swallows.
⚠️ **Test** - 1 warning
  • ⚠️ Full TypeScript regression suite (cd server/typescript &amp;&amp; bun test) timed out at the 1500-second wrapper (exit 124) with no output. No pass or fail result exists. Run per package, or with a longer timeout, and confirm the outcome before relying on the verdict.
  • ℹ️ Mustache -last on a scalar list element: no -last key is set, so {{^-last}} is true for every element and a separator follows each one ([&#34;p&#34;,&#34;q&#34;] renders ", , "). This matches the documented contract, since scalars carry no positional accessor. Template authors over scalar lists should use a different separator pattern or an object wrapper.
  • ⚠️ live validation verdict: inconclusive (7 of 8 scenarios were driven live against the product); untested: Full TS regression suite
  • Live validation: ⚠️ inconclusive - 7 of 8 scenarios driven live against the product
Scenario Result Live Evidence
Multi-hop enum extends reuses the shared enum (no per-object inline union) ✅ pass live meta gen on disposable project: Refund.ts imports PriorityEnum from ./enums.js; enums.ts has one Priority declaration
Value object emits field-rules constant with maxLength ✅ pass live meta gen: Address.ts contains Address.&lt;field&gt;.rules.maxLength value 60
Abstract value object emits no field-rules constant ✅ pass live meta gen: BaseAddress.ts has no rules export const
Mustache -last separator: multiple items, single item, empty list ✅ pass live render-check.ts: 'a, b, c|', 'only|', '|NONE'
Adversarial: authored -last member wins over derived ✅ pass live render-check.ts: 'xy|' with authored -last AUTH on first element
Adversarial: scalar list elements get no -last (documented) ✅ pass live render-check.ts: ', , |' matches documented contract
verify --templates-level check accepts -last and rejects unknown positional ✅ pass live verify-check.ts: good template [], '-first' -> ERR_VAR_NOT_ON_PAYLOAD
Full TS regression suite ⏸️ untested no bun test over server/typescript exceeded the 1500-second timeout; no pass or fail count was produced
  • scripts/ci-local.sh --only ts-fast --only ts-unit --strict-toolchains
  • Built disposable project under /tmp; ran bun server/typescript/packages/cli/bin/meta.ts gen against it (live CLI)
  • Inspected generated enums.ts, Refund.ts, Address.ts, BaseAddress.ts
  • Ran render() via /tmp render-check.ts with {{#items}}{{name}}{{^-last}}, {{/-last}}{{/items}} and {{^items}} cases
  • Ran verify() and render() with verify option via /tmp verify-check.ts
  • Background timeout 1500 bun test in server/typescript (log /tmp/mo-ts-full-test.log): killed at 1500s, EXIT=124, no output

🔧 Fix applied.
1 warning still open:

  • ⚠️ server/typescript/packages/codegen-ts/src/templates/value-object-file.ts:92 - Abstract object.value gets a field-rules constant, contradicting the CHANGELOG sentence that abstract objects are unchanged. Needs a ruling.
  • Live validation: ✅ go - 9 of 10 scenarios driven live against the product
Scenario Result Live Evidence
Multi-hop enum field reuses the shared abstract enum (codegen-ts) ✅ pass live meta gen in /tmp/mo-val/proj: Task.ts uses PriorityEnum, no UrgencyEnum. Base 86bbadb emits UrgencyEnum inline. Copies in evidence gen-fixed/ and gen-base/.
meta verify --codegen clean after fixed gen ✅ pass live meta verify --codegen in /tmp/mo-val/proj: no codegen drift.
Mustache -last separator renders a, b, c with no last flag in payload ✅ pass live /tmp/mo-val/drive/render-drive.ts, R1 gives "a, b, c". Saved as render-scenarios.json.
Single-element and empty list separator behavior ✅ pass live render-drive R2: single gives "[only]", empty gives "[]".
Emptiness test {{^hasTags}} / {{#hasTags}} without hand-written boolean ✅ pass live render-drive R3: empty gives "none", non-empty gives "has".
Class-instance payload renders same bytes as plain map ✅ pass live render-drive R4: both give "a|b".
Adversarial: -last outside a list rejected by verify ✅ pass live render-drive R5: ERR_VAR_NOT_ON_PAYLOAD for top-level and scalar-list; in-list object -last returns [].
Value object emits field-rules constant (Label.title.rules.maxLength.value 40) ✅ pass live Fixed Label.ts has export const Label; base emits none.
Adversarial: abstract object.value gets rules constant (review concern) ✅ pass live BaseAddress generated file contains const BaseAddress = {...}, contradicting CHANGELOG 'abstract objects unchanged'. Open ruling.
Java, Kotlin, C#, Python ports ⏸️ untested no Not driven live: needs Maven, dotnet, Kotlin or pip toolchains, not set up in the workspace.
  • scripts/ci-local.sh --only ts-fast --only ts-unit --strict-toolchains
  • meta init, eject, gen, verify --codegen from worktree source in /tmp/mo-val/proj
  • Base comparison via git archive 86bbadb in /tmp/mo-val/base-proj
  • bun /tmp/mo-val/drive/render-drive.ts (live render and verify)
  • bun test per package: render 382 pass, codegen-ts 2557 pass, cli 1470 pass, metadata 3061 pass
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

…-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
  <Entity>.<field>.rules, without the table and path keys.
…red enums

Assisted-by: no-mistakes:claude:claude-sonnet-5-5
…yload views

Assisted-by: no-mistakes:claude:claude-opus-5-5
…lver, C# cycle guard

Assisted-by: no-mistakes:claude:claude-opus-5-5
…ted generators

Assisted-by: no-mistakes:claude:claude-sonnet-5-5
…bject gate; Java re-eject note

Assisted-by: no-mistakes:claude:claude-opus-5-5
…ad CHANGELOG wording

Assisted-by: no-mistakes:claude:claude-opus-5-5
@dmealing
dmealing merged commit 9a35298 into main Oct 11, 2026
1 check passed
@dmealing
dmealing deleted the fm/mo-1-1-0-adopter-gaps branch October 11, 2026 17:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant