Skip to content

Commit 494ebbb

Browse files
authored
Merge pull request #260 from metaobjectsdev/fix/246-shared-enum-cross-package
fix: shared enum — reject conflicting @values across ports + Kotlin codegen fixes
2 parents 0aacb68 + 97cee42 commit 494ebbb

40 files changed

Lines changed: 1732 additions & 35 deletions

File tree

CHANGELOG.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,49 @@ here. The format follows [Keep a Changelog](https://keepachangelog.com/), and
55
this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
66
(pre-1.0; MINOR bumps may introduce breaking changes with notice).
77

8+
## [Unreleased]
9+
10+
Shared-enum cross-package hardening (**#246** + its sibling **#259**). When cut this releases
11+
as a coordinated PATCH — the loader change (#246) lands in all five ports, the Kotlin codegen
12+
changes (#246 Bug 1, #259) land on Maven Central; no metadata vocabulary changes, byte-identical
13+
output for any model that doesn't hit the specific cross-package/two-hop enum shapes below.
14+
15+
- **#246 — a `field.enum` may now be shared across packages, and a conflicting redeclaration is
16+
rejected instead of silently dropped.** Two independent fixes:
17+
- **Kotlin codegen (Bug 1).** The Exposed table generator dropped the cross-package import for a
18+
shared `field.enum`: when two entities in different packages `extends` one abstract enum
19+
declared in a common package, the generated `<Entity>Table` referenced the shared enum by its
20+
simple name but never imported it (`Unresolved reference`). It now emits the cross-package
21+
import for enum columns on both the vanilla and TPH-fold paths, mirroring the existing
22+
FK-import machinery. Same-package models are byte-identical (an enum in the table's own
23+
package adds no import). Gated by a new `enum-xpkg` fixture + a `KotlinCompilation` compile-gate.
24+
- **Cross-port loader error `ERR_ENUM_EXTENDS_VALUES_CONFLICT`.** A `field.enum` that both
25+
`extends` a shared package-level abstract enum **and** declares its own `@values` now fails to
26+
load (was silently dropped — one shared enum type has one member set, so the own `@values`
27+
would be discarded by the shared-enum codegen collapse). Enforced identically in all four
28+
loaders (TypeScript, Python, Java, C#; Kotlin inherits the JVM loader), gated by a shared
29+
conformance fixture with an exact cross-port error `jsonPath`. Extending a **concrete**
30+
(non-shared) enum with your own `@values` is still legal.
31+
- **#259 — a `field.enum` inheriting `@values` through TWO `extends` hops now generates correctly
32+
(Kotlin codegen; sibling of #246).** A projection field extending an entity field, where that
33+
entity field itself extends a shared abstract enum, generated **no** per-projection enum at all
34+
— its type collapsed onto the shared enum because the collapse decision inspected the top-most
35+
super rather than the immediate one, so every consumer of that column failed to resolve the
36+
absent per-projection type. The collapse now keys on the **immediate** super: a field whose
37+
direct `extends` target is a package-level abstract enum collapses onto the shared type; a field
38+
whose direct super is a concrete entity/projection field gets its own `<Object><Field>` enum,
39+
populated with the values it inherits (resolved across any number of hops). Byte-identical for
40+
one-hop projections, shared-enum (FR-019) collapse, and entity-extends-shared. Also hardens the
41+
enum emitter: a `field.enum` that resolves to no `@values` now fails loudly at generate-time
42+
instead of silently emitting nothing (the exact silent no-emit #259 reported). Gated by a
43+
two-hop `KotlinCompilation` compile-gate + a depth-2 cross-port conformance fixture.
44+
45+
The C# materialized-enum cross-namespace sibling and the enum-primary-key-as-`String` issue are
46+
documented as out-of-scope in the design spec
47+
(`docs/superpowers/specs/2026-07-31-shared-enum-cross-package-design.md`); a follow-up is the
48+
Kotlin `enumTypeName` collapse gaining the `isAbstract` leg the other ports already carry (so a
49+
root-level *concrete* enum extended with own `@values` gets a per-field enum on every port).
50+
851
## [0.20.10] — 2026-08-02
952

1053
**Coordinated PATCH** — npm `0.20.10` · PyPI `0.19.9` · NuGet `0.19.7` · Maven Central `7.11.7`.

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ _Last refreshed 2026-08-02._
3030
- **Kotlin**`codegen-kotlin` (KotlinPoet on JVM): entity + Exposed table + Spring controller + payload + relations + filter allowlist + validator + stored-proc + output-parser generators. `integration-tests-kotlin` runs the persistence-conformance corpus through Exposed against Testcontainers Postgres.
3131

3232
**Cross-port conformance corpora** (every port runs the shared corpus):
33-
- Metamodel: `fixtures/conformance/` (255 fixtures; 19 shared corpora in total — per-corpus counts + the corpus x port matrix live in `docs/CONFORMANCE.md`). TS / C# / Java / Python all green.
33+
- Metamodel: `fixtures/conformance/` (258 fixtures; 19 shared corpora in total — per-corpus counts + the corpus x port matrix live in `docs/CONFORMANCE.md`). TS / C# / Java / Python all green.
3434
- Render: `fixtures/render-conformance/`. TS / C# / Java / Kotlin / Python byte-identical.
3535
- Persistence: `fixtures/persistence-conformance/`. **Query** scenarios run on every port (TS / C# / Java / Kotlin / Python), each provisioning its test DB by executing the committed, TS-produced `canonical/schema.postgres.sql` (Postgres only — Derby dropped for the cross-port query corpus, ADR-0015). The **migration** scenarios are exercised by **TS only** (TS owns schema migrations). **The corpus now gates WRITES, not just reads (SP-H):** an `op: roundtrip` scenario type INSERTs through each port's runtime/ORM write codec (NOT raw SQL), reads the row back, and asserts the wire-normalized value. The `AllTypes` entity (`roundtrip-all-types.yaml`) carries one field of **every** persistable `field.*` subtype — string/int/long/double/float/decimal/boolean/date/time/timestamp(+tz)/currency/enum/uuid/object — plus an **array-of-VO** `field.object @isArray @storage:jsonb` column (`labels`, written as 2-element / empty-`[]` / single-element arrays across the three rows) — so every subtype write+read (incl. the array-of-value-object jsonb codec) round-trips through every port against Testcontainers PG. (`field.byte`/`field.short`/`field.class` were cut as non-functional registration-only stubs — the matrix tracks only genuinely-supported subtypes; see `fixtures/registry-conformance/README.md` → "Per-subtype write-round-trip matrix".)
3636
- API-contract: `fixtures/api-contract-conformance/`. TS / C# / Java / Kotlin / Python all green — each port runs **two lanes**: a hand-rolled reference server AND its **generated** API artifact booted over HTTP (the deployed controller/routes; TS+C# full-stack vs Testcontainers PG, Java/Kotlin/Python generated controller + in-memory repo behind the consumer seam). The generated fan-out found 10 real deployment bugs golden snapshots missed.

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,7 @@ metaobjects/
166166
├── CLAUDE.md # project instructions for Claude
167167
├── spec/ # canonical metamodel docs, ADRs, roadmap
168168
├── fixtures/ # 19 cross-language conformance corpora — the oracle
169-
│ ├── conformance/ # metamodel (loader + serializer + navigation), 249 fixtures
169+
│ ├── conformance/ # metamodel (loader + serializer + navigation), 258 fixtures
170170
│ ├── yaml-conformance/ # YAML authoring desugar
171171
│ ├── render-conformance/ # FR-004 byte-identical render oracle
172172
│ ├── verify-conformance/ # FR-004 template-drift gate

docs/CONFORMANCE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ regenerate with `ls -d fixtures/<corpus>/*/ | wc -l`.
2525

2626
| Corpus | Fixtures | TS | Java | Kotlin | C# | Python |
2727
|---|---|---|---|---|---|---|
28-
| [`fixtures/conformance/`](../fixtures/conformance/) (metamodel) | 255 ||| inherits via `metadata-ktx` |||
28+
| [`fixtures/conformance/`](../fixtures/conformance/) (metamodel) | 258 ||| inherits via `metadata-ktx` |||
2929
| [`fixtures/yaml-conformance/`](../fixtures/yaml-conformance/) | 15 | 15 / 15 | 14 / 15 (1 ledgered: `yaml-quoted-leading-zero` — Java pipeline strips quotes off `"007"`) | inherits via Java | 14 / 15 (1 ledgered: `error-yaml-coerced-hex-in-string` — YamlDotNet doesn't coerce `0xFF`) | 15 / 15 |
3030
| [`fixtures/verify-conformance/`](../fixtures/verify-conformance/) | 31 ||| inherits via Java |||
3131
| [`fixtures/verify-strict-conformance/`](../fixtures/verify-strict-conformance/) | 1 ||||||
@@ -69,7 +69,7 @@ unit-test runners (`bun test`, `dotnet test`, `pytest`, `mvn test`) pull Docker.
6969

7070
## Fixture-to-doc mapping
7171

72-
### `fixtures/conformance/` — metamodel loader + canonical serializer (255)
72+
### `fixtures/conformance/` — metamodel loader + canonical serializer (258)
7373

7474
| Fixture prefix | Feature doc |
7575
|---|---|

docs/features/abstracts-and-inheritance.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -390,6 +390,9 @@ inheritance chain.
390390
`ERR_UNRESOLVED_SUPER` when the reference doesn't resolve
391391
- [`enum-abstract-extends`](../../fixtures/conformance/enum-abstract-extends/)
392392
abstracts on enum fields specifically
393+
- [`enum-extends-two-hop-projection`](../../fixtures/conformance/enum-extends-two-hop-projection/)
394+
— inherited enum members resolve through two `extends` hops (projection → entity
395+
→ shared abstract enum)
393396

394397
## See also
395398

docs/features/field-types.md

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,11 +66,42 @@ must be a non-empty set of unique members matching `^[A-Za-z_][A-Za-z0-9_]*$`.
6666
```
6767

6868
The loader enforces members own-only and emits `ERR_BAD_ATTR_VALUE` on a bad
69-
member or `ERR_MISSING_REQUIRED_ATTR` on missing `@values`. Reuse via an abstract
70-
`field.enum` + `extends`. Int-backed enums, display labels, and native Postgres
71-
`ENUM` types are deferred (see
69+
member or `ERR_MISSING_REQUIRED_ATTR` on missing `@values`. Int-backed enums,
70+
display labels, and native Postgres `ENUM` types are deferred (see
7271
[enum-datatype-design.md](../superpowers/specs/2026-05-23-enum-datatype-design.md)).
7372

73+
### Sharing one enum — abstract `field.enum` + `extends`
74+
75+
Reuse a constraint set across entities by declaring one **abstract** `field.enum`
76+
(with the `@values`) and having each concrete field `extends` it — including
77+
**across packages** (declare the shared enum in a common package and reference it
78+
by FQN). Every field that extends the same abstract enum collapses onto one
79+
generated enum type, so the members stay in exactly one place:
80+
81+
```jsonc
82+
// meta.common.json — package acme::common
83+
{ "field.enum": { "name": "RecordStatus", "abstract": true,
84+
"@values": ["DRAFT", "ACTIVE", "CLOSED"] } }
85+
86+
// meta.orders.json — package acme::orders
87+
{ "field.enum": { "name": "status", "extends": "acme::common::RecordStatus" } }
88+
```
89+
90+
Two rules the loader/codegen enforce:
91+
92+
- **A field extending a shared abstract enum must NOT declare its own `@values`.**
93+
A shared enum is one type with one member set, so an own `@values` on the
94+
extending field would be silently dropped by the shared-enum collapse — it is a
95+
load error, `ERR_ENUM_EXTENDS_VALUES_CONFLICT`. Remove the own `@values` to
96+
inherit the shared set. If you need a **different** member set, declare a
97+
separate `field.enum` with its own `@values` rather than extending the shared
98+
one — extending is for *reuse* of one member set, not for overriding it.
99+
- **Inherited members resolve through any number of `extends` hops.** A projection
100+
field that extends an entity field which itself extends a shared abstract enum
101+
gets the shared members transitively — the projection still materializes its own
102+
per-projection enum type, populated from the inherited set (a projection is
103+
self-contained: its enum lives in the projection's own package).
104+
74105
## Embedded value objects — `field.object` + `@storage`
75106

76107
`field.object` declares an embedded structured value backed by another `object`
@@ -236,6 +267,8 @@ The following conformance fixtures gate this feature's behavior across ports:
236267
- [`fixtures/conformance/enum-inline/`](../../fixtures/conformance/enum-inline/) — inline `field.enum @values`
237268
- [`fixtures/conformance/enum-array/`](../../fixtures/conformance/enum-array/) — array-of-enum field
238269
- [`fixtures/conformance/enum-abstract-extends/`](../../fixtures/conformance/enum-abstract-extends/) — reuse via abstract `field.enum` + `extends`
270+
- [`fixtures/conformance/enum-extends-two-hop-projection/`](../../fixtures/conformance/enum-extends-two-hop-projection/) — inherited members resolve through two `extends` hops (projection → entity → shared)
271+
- [`fixtures/conformance/error-enum-extends-values-conflict/`](../../fixtures/conformance/error-enum-extends-values-conflict/)`ERR_ENUM_EXTENDS_VALUES_CONFLICT` when extending a shared abstract enum and also declaring own `@values`
239272
- [`fixtures/conformance/error-enum-missing-values/`](../../fixtures/conformance/error-enum-missing-values/)`ERR_MISSING_REQUIRED_ATTR` when `@values` absent
240273
- [`fixtures/conformance/error-enum-empty-values/`](../../fixtures/conformance/error-enum-empty-values/) — empty `@values` rejected
241274
- [`fixtures/conformance/error-enum-duplicate-member/`](../../fixtures/conformance/error-enum-duplicate-member/) — duplicate member symbols rejected

0 commit comments

Comments
 (0)