Skip to content

fix: shared enum — reject conflicting @values across ports + Kotlin codegen fixes - #260

Merged
dmealing merged 17 commits into
mainfrom
fix/246-shared-enum-cross-package
Aug 2, 2026
Merged

fix: shared enum — reject conflicting @values across ports + Kotlin codegen fixes#260
dmealing merged 17 commits into
mainfrom
fix/246-shared-enum-cross-package

Conversation

@dmealing

@dmealing dmealing commented Aug 2, 2026

Copy link
Copy Markdown
Member

Intent

Branch fix/246-shared-enum-cross-package: two sibling shared-enum issues for a coordinated PATCH release (npm/PyPI/NuGet 0.20.11, Maven 7.11.8).

#246: (A) Kotlin KotlinExposedTableGenerator emits the cross-package import for a shared field.enum (was Unresolved reference), vanilla + TPH-fold; byte-identical for same-package (import only when enum packageName non-empty AND != table package). (B) NEW cross-port loader error ERR_ENUM_EXTENDS_VALUES_CONFLICT: a field.enum that both extends a shared package-level ABSTRACT enum AND declares its own @values now fails to load (was silently dropped). Identical in all four loaders (TS attr-schema-validate.ts, Python validation_passes.py, Java ValidationPhase.java, C# ValidationPasses.cs; Kotlin inherits JVM loader): fires iff OWN @values present AND resolving immediate super is abstract AND that super's parent is the metadata root. Deliberately a new load-rejection of a previously-silently-accepted INVALID model (a correction, not a break; valid models byte-identical). Registered in all six error-ledger files. Gated by conformance fixtures error-enum-extends-values-conflict (exact cross-port jsonPath) + enum-extends-concrete-with-own-values (negative/legal).

#259 (Kotlin codegen, sibling): KotlinTypeMapper.enumTypeName keys the shared-enum collapse on the IMMEDIATE super (field.superField.declaringObject == null) not the top-most, so a two-hop projection->entity->shared-abstract enum materializes its own per-projection enum (was generating nothing). getMetaAttr is inheritance-aware multi-hop, so values were never the issue. Hardens KotlinEnumEmitter: a field.enum resolving to no @values fails loudly at generate-time. Byte-identical for one-hop projections, FR-019 shared enums, entity-extends-shared. Gated by a two-hop KotlinCompilation compile-gate + a depth-2 conformance fixture.

Docs: CHANGELOG [Unreleased], field-types.md/abstracts-and-inheritance.md enum guidance, CONFORMANCE.md/CLAUDE.md fixture count 258.

Already passed two review rounds (focused #259 = APPROVED; Fable whole-branch = SHIP, doc fixes applied). Green on all four ports: TS conformance 508, Python 367, Java ConformanceTest 508, C# 829, codegen-kotlin 310. NOTES: the new load-rejection is deliberate (not an over-reject bug); byte-identity for valid models is the guardrail; a pre-existing Kotlin isAbstract-leg divergence is a documented out-of-scope follow-up (NOT in this PATCH).

What Changed

  • New cross-port loader error ERR_ENUM_EXTENDS_VALUES_CONFLICT: a field.enum that both extends a shared package-level abstract enum AND declares its own @values now fails to load in all four ports (TS/Python/Java/C#; Kotlin inherits the JVM loader) — previously this invalid model was silently dropped. Registered in all six error-ledger files; gated by the error-enum-extends-values-conflict and enum-extends-concrete-with-own-values conformance fixtures.
  • Kotlin shared-enum codegen: KotlinExposedTableGenerator now emits the cross-package import for a shared field.enum (vanilla and TPH-fold paths), and KotlinTypeMapper keys shared-enum collapse on the immediate super so a two-hop projection→entity→shared-abstract enum materializes its own enum instead of generating nothing. Same-package output is byte-identical; KotlinEnumEmitter now fails loudly at generate-time when a resolved enum has no @values.
  • Docs: CHANGELOG [Unreleased] entry, shared/cross-package enum authoring guidance in the field-types and abstracts-and-inheritance docs, and synced the conformance-fixture count (258) across affected docs.

Risk Assessment

✅ Low: Both Kotlin fixes are correct, byte-identity-guarded, and compile-gated; the new cross-port load-rejection is deliberate, documented, gated by an exact-jsonPath fixture, and only rejects an INVALID model (the legal non-abstract-super case is pinned) — the sole observation is a benign, untested, degenerate edge-case divergence in secondary-error count that leaves the model failing on all ports.

Testing

Ran the smallest relevant per-port tests (not full suites): all four loader ports for the new ERR_ENUM_EXTENDS_VALUES_CONFLICT (TS/Python/Java/C#, each 3 tests pinning the precise conflict-vs-legal boundary), the TS cross-port conformance corpus (508/0, covering the jsonPath error fixture and the legal negative), and the two Kotlin codegen compile-gates for #246(A) cross-package import and #259 two-hop projection enum. Every targeted test passed; reproduced the generated cross-package Kotlin source to confirm the import is emitted only when the enum's package differs from the table's (byte-identity guardrail) and that the two-hop projection now materializes its own enum class. Captured two evidence artifacts; working tree left clean after removing the throwaway dump test. No actionable issues found.

Evidence: Generated cross-package enum table source (#246(A))

Generated Exposed table classes. OrderTable.kt (pkg acme.orders) and ShipmentTable.kt (pkg acme.shipping, TPH-fold base) now emit import acme.common.RecordStatus and reference RecordStatus::class in enumerationByName; same-package ShipmentCarrier correctly gets NO import (byte-identity guardrail). Before the fix this import was absent -> Unresolved reference compile error.

===== acme/common/RecordStatus.kt =====
package acme.common

import kotlinx.serialization.Serializable

/**
 * GENERATED — do not hand-edit. Regenerated from metadata.
 */
@Serializable
public enum class RecordStatus {
  DRAFT,
  ACTIVE,
  CLOSED,
}

===== acme/orders/OrderTable.kt =====
package acme.orders

import org.jetbrains.exposed.sql.Table
import acme.common.RecordStatus

/** GENERATED — do not hand-edit. Regenerated from metadata. */
object OrderTable : Table("orders") {
    val id = long("id").autoIncrement()
    val status = enumerationByName("status", 64, RecordStatus::class)

    override val primaryKey = PrimaryKey(id)
}

===== acme/billing/InvoiceTable.kt =====
package acme.billing

import org.jetbrains.exposed.sql.Table
import acme.common.RecordStatus

/** GENERATED — do not hand-edit. Regenerated from metadata. */
object InvoiceTable : Table("invoices") {
    val id = long("id").autoIncrement()
    val status = enumerationByName("status", 64, RecordStatus::class)

    override val primaryKey = PrimaryKey(id)
}

===== acme/shipping/ShipmentTable.kt =====
package acme.shipping

import org.jetbrains.exposed.sql.Table
import acme.common.RecordStatus

/** GENERATED — do not hand-edit. Regenerated from metadata. */
object ShipmentTable : Table("shipments") {
    val id = long("id").autoIncrement()
    val carrier = enumerationByName("carrier", 64, ShipmentCarrier::class).nullable()
    val recordStatus = enumerationByName("record_status", 64, RecordStatus::class).nullable()

    override val primaryKey = PrimaryKey(id)
}
Evidence: Cross-port enum test results (#246 + #259)

Consolidated results: ERR_ENUM_EXTENDS_VALUES_CONFLICT green on all 4 loaders (TS/Python/Java/C#, 3 tests each, identical three-way boundary); TS conformance 508/0; #259 KotlinProjectionTwoHopEnumTest materializes ProgramViewStatus (projection's own enum); #246(A) KotlinExposedTableCrossPackageEnumTest compile-gate green.

Branch: fix/246-shared-enum-cross-package  (base c2dce37 → target c37dfdb)
Scope: #246 (cross-package shared-enum import + ERR_ENUM_EXTENDS_VALUES_CONFLICT) + #259 (two-hop projection enum)

================= #246(B) ERR_ENUM_EXTENDS_VALUES_CONFLICT — all four loader ports =================
Each port's focused test asserts the SAME three-way boundary:
  CONFLICT fires   : extends a root-level ABSTRACT enum AND own @values  -> ERR_ENUM_EXTENDS_VALUES_CONFLICT
  LEGAL (concrete) : extends a CONCRETE enum + own @values              -> no error
  LEGAL (non-root) : extends an ABSTRACT-but-NON-ROOT enum + own @values -> no error

[TS]      server/typescript/packages/metadata/test/enum-extends-values-conflict.test.ts
          bun test -> 3 pass / 0 fail
[Python]  server/python/tests/loader/test_enum_extends_values_conflict.py
          pytest  -> 3 passed
[Java/JVM] com.metaobjects.loader.EnumExtendsValuesConflictTest (Kotlin inherits this loader)
          mvn -pl metadata -Dtest=EnumExtendsValuesConflictTest -> Tests run: 3, Failures: 0, Errors: 0
[C#]      MetaObjects.Conformance.Tests/EnumExtendsValuesConflictTests.cs
          dotnet test --filter ~EnumExtendsValuesConflictTests -> Passed: 3, Failed: 0, Total: 3

[TS conformance] packages/metadata/test/conformance.test.ts -> 508 pass / 0 fail
                 (includes error-enum-extends-values-conflict jsonPath fixture
                  + enum-extends-concrete-with-own-values legal fixture)

================= #259 two-hop projection enum (Kotlin codegen) =================
com.metaobjects.generator.kotlin.KotlinProjectionTwoHopEnumTest  (compile-gate)
SharedStatus(abstract) <- Program.status(extends, hop1) <- ProgramView.status(extends, hop2)
mvn -pl codegen-kotlin -Dtest=KotlinProjectionTwoHopEnumTest -> Tests run: 1, Failures: 0, Errors: 0
Generated + compiled (the fix materializes the projection's OWN per-projection enum class):
  acme/commerce/Program.class, ProgramTable.class, ProgramView.class,
  ProgramViewStatus.class  <- projection's own enum (was: generated nothing before fix)
  SharedStatus.class        <- the shared abstract enum

================= #246(A) cross-package shared-enum import (Kotlin codegen) =================
com.metaobjects.generator.kotlin.KotlinExposedTableCrossPackageEnumTest  (compile-gate)
mvn -pl codegen-kotlin -Dtest=KotlinExposedTableCrossPackageEnumTest -> Tests run: 1, Failures: 0, Errors: 0
(aggregate over both new kotlin classes: Tests run: 2, Failures: 0, Errors: 0)
Generated source captured in: generated-cross-package-enum-tables.kt
  OrderTable.kt   (pkg acme.orders)  -> "import acme.common.RecordStatus"  + RecordStatus::class
  InvoiceTable.kt (pkg acme.billing) -> "import acme.common.RecordStatus"  + RecordStatus::class
  ShipmentTable.kt (pkg acme.shipping, TPH-fold base) -> import for the subtype-only cross-package enum
  Byte-identity guardrail: same-package ShipmentCarrier gets NO import (cn.packageName != pkg)
  Full generated tree compiles (ExitCode.OK) — resolves the prior "Unresolved reference: RecordStatus".

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

⚠️ **Review** - 1 info
  • ℹ️ server/python/src/metaobjects/loader/validation_passes.py:578 - Narrow cross-port divergence in the EMPTY-@values edge case for the new ERR_ENUM_EXTENDS_VALUES_CONFLICT check: when a field.enum declares an empty @values: [] AND extends a shared abstract root enum, TS (attr-schema-validate.ts:316/357) and C# (ValidationPasses.cs:2292/2329) emit BOTH ERR_BAD_ATTR_VALUE(empty) and ERR_ENUM_EXTENDS_VALUES_CONFLICT, because their empty-check pushes-and-falls-through to the unconditional Kotlin codegen: generated *Table / *RepositoryBase reference a cross-package shared field.enum without importing it #246 check. Python short-circuits with continue at validation_passes.py:578, and Java throws eagerly inside validateEnumValues at ValidationPhase.java:518 before reaching the Kotlin codegen: generated *Table / *RepositoryBase reference a cross-package shared field.enum without importing it #246 check — so both emit only ERR_BAD_ATTR_VALUE. The intent's stated contract ('fires iff OWN @values present AND resolving immediate super is abstract AND that super's parent is the metadata root') is technically better honored by TS/C# (an empty array IS a present attr); Python/Java never reach the check for empty arrays. This is benign — the model still fails to load on all four ports, it is not gated by any conformance fixture (the error-enum-extends-values-conflict fixture uses non-empty @values where all four ports agree exactly), and the scenario (empty values array on a field that also extends a shared enum) is semantically degenerate. Noting only because the intent asserts strict cross-port identity; no valid model and no gated scenario is affected.
✅ **Test** - passed

✅ No issues found.

  • TS loader: cd server/typescript &amp;&amp; bun test packages/metadata/test/enum-extends-values-conflict.test.ts -> 3 pass / 0 fail
  • Python loader: PYTHONPATH=server/python/src python3 -m pytest server/python/tests/loader/test_enum_extends_values_conflict.py -> 3 passed
  • Java/JVM loader (Kotlin inherits): mvn test -pl metadata -Dtest=EnumExtendsValuesConflictTest -o -> Tests run: 3, Failures: 0, Errors: 0
  • C# loader: dotnet test server/csharp/MetaObjects.Conformance.Tests/MetaObjects.Conformance.Tests.csproj --filter ~EnumExtendsValuesConflictTests -> Passed: 3, Failed: 0, Total: 3
  • TS cross-port conformance: cd server/typescript &amp;&amp; bun test packages/metadata/test/conformance.test.ts -> 508 pass / 0 fail (includes error-enum-extends-values-conflict jsonPath + enum-extends-concrete-with-own-values legal fixtures)
  • #259 Kotlin two-hop compile-gate: mvn test -pl codegen-kotlin -Dtest=KotlinProjectionTwoHopEnumTest -o -> Tests run: 1, Failures: 0; generated ProgramViewStatus.kt/.class (projection's own enum) compiles
  • #246(A) Kotlin cross-package compile-gate: mvn test -pl codegen-kotlin -Dtest=KotlinExposedTableCrossPackageEnumTest -o -> Tests run: 1, Failures: 0; full generated tree compiles (ExitCode.OK)
  • Evidence capture: throwaway dump test reproduced generation of OrderTable/InvoiceTable/ShipmentTable/RecordStatus.kt proving import acme.common.RecordStatus present and same-package ShipmentCarrier import absent — test removed, tree verified clean
⚠️ **Document** - 1 info
  • ℹ️ The metamodel conformance fixture count is hand-copied in 5 docs (README.md:169, spec/README.md:26, spec/roadmap.md:78, docs/CONFORMANCE.md, CLAUDE.md). This change bumped only the two owners (CONFORMANCE.md, CLAUDE.md) to 258; the other three had silently drifted (249 → 255 → 258 across prior releases without anyone updating them) and were corrected in this pass. As a generated/schema-backed fact it is prone to re-drift on the next fixture addition. Suggested follow-up (out of scope for this PATCH doc pass): make docs/CONFORMANCE.md the single source and reduce the peripheral copies (README tree comment, spec/README prose, spec/roadmap status line) to short pointers, or generate the count from ls -d fixtures/conformance/*/ | wc -l, so the number can't silently diverge again.
✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

dmealing and others added 17 commits July 31, 2026 21:28
…x + extends/values load error)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HLoJkFSyoticveo5ehMUAr
A field.enum that both extends a shared package-level abstract enum AND
declares its own @values silently dropped the own set under the shared-enum
codegen collapse. Mirrors the TS reference: fire ERR_ENUM_EXTENDS_VALUES_CONFLICT
only when the resolved super is abstract AND root-level (parent.type ==
metadata) — a concrete super or a non-root abstract super stays legal.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HLoJkFSyoticveo5ehMUAr
…covers Kotlin)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HLoJkFSyoticveo5ehMUAr
…alues conflict

The error fixture pins ERR_ENUM_EXTENDS_VALUES_CONFLICT with an exact
source.jsonPath on the offending field.enum; the negative fixture proves a
field.enum extending a CONCRETE (non-root) enum with its own @values loads
clean. Verified across all four ports (TS 506, Python 366, Java 506, C# 827)
— identical jsonPath, negative loads with zero errors under strict.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HLoJkFSyoticveo5ehMUAr
A field.enum inheriting @values through TWO extends hops (projection.status ->
entity.status -> shared-abstract enum) generated no per-projection enum at all,
so every consumer of that column failed to resolve the (absent) type.

Root cause: KotlinTypeMapper.enumTypeName decided "collapse onto the shared
enum" from the TOP-MOST super, so a projection field walked past its concrete
entity super to the root abstract enum and wrongly collapsed. Values were never
the issue — getMetaAttr(@values) is inheritance-aware across any number of hops.

Fix: key 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 <Object><Field> enum, populated with the
depth-inherited members. Byte-identical for one-hop projections, FR-019 shared
enums, and entity-extends-shared (codegen-kotlin 310/310).

Also hardens KotlinEnumEmitter: a field.enum resolving to no @values now fails
loudly at generate-time (error) instead of silently emitting no file — the
exact silent no-emit #259 reported. Sibling of #246.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HLoJkFSyoticveo5ehMUAr
Pins cross-port that a field.enum inheriting @values through TWO extends hops
(projection.status -> entity.status -> shared-abstract enum) resolves its
effective member set and loads clean under strict. Happy-path fixture; verified
on all four ports (TS 506+, Python 366+, Java 508, C# 829). Complements the
codegen-kotlin two-hop compile-gate that guards the Kotlin-codegen naming bug.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HLoJkFSyoticveo5ehMUAr
…-package enums

Adds an [Unreleased] CHANGELOG entry covering #246 (cross-package shared-enum
import + ERR_ENUM_EXTENDS_VALUES_CONFLICT) and #259 (two-hop projection enum
resolution + loud no-values guard). Documents in field-types.md how to share one
enum across packages via an abstract field.enum + extends, the two enforced
rules (no own @values on a shared-enum child; members resolve through any number
of extends hops), and links the two new conformance fixtures.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HLoJkFSyoticveo5ehMUAr
…re counts

- field-types.md: drop the 'extend a concrete enum for an independent set'
  recommendation (steered users into a shape Kotlin's collapse mishandles);
  guide to a separate field.enum instead.
- CHANGELOG: reword the closing note (the C# materialized-enum + enum-PK items
  are recorded in the design spec, not open trackers) and note the Kotlin
  isAbstract-leg follow-up.
- CONFORMANCE.md + CLAUDE.md: metamodel conformance fixture count 255 -> 258.
- design spec: record the pre-existing Kotlin enumTypeName isAbstract-leg
  divergence as an out-of-scope follow-up (surfaced by the Fable review).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HLoJkFSyoticveo5ehMUAr
@dmealing
dmealing merged commit 494ebbb into main Aug 2, 2026
1 check passed
@dmealing
dmealing deleted the fix/246-shared-enum-cross-package branch August 2, 2026 14:44
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