From 3cf3a6aa25971b5a40229d4fd83417fca8359bb0 Mon Sep 17 00:00:00 2001
From: Doug Mealing
Date: Wed, 29 Jul 2026 22:03:14 -0400
Subject: [PATCH 01/19] =?UTF-8?q?docs(#228):=20implementation=20plan=20?=
=?UTF-8?q?=E2=80=94=20extract-tier=20collision-scoped=20naming=20(5=20por?=
=?UTF-8?q?ts)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-Authored-By: Claude Opus 4.8
Claude-Session: https://claude.ai/code/session_01TJRi8FtEW24z9HKGUL1xh8
---
...issue-228-extract-tier-collision-naming.md | 165 ++++++++++++++++++
1 file changed, 165 insertions(+)
create mode 100644 docs/superpowers/plans/2026-07-29-issue-228-extract-tier-collision-naming.md
diff --git a/docs/superpowers/plans/2026-07-29-issue-228-extract-tier-collision-naming.md b/docs/superpowers/plans/2026-07-29-issue-228-extract-tier-collision-naming.md
new file mode 100644
index 000000000..c851271d9
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-29-issue-228-extract-tier-collision-naming.md
@@ -0,0 +1,165 @@
+# #228 — Collision-scoped payload naming in the extract/output-parser tier (all 5 ports) Implementation Plan
+
+> **For agentic workers:** Execute with superpowers:subagent-driven-development, one fresh implementer per task + per-task review. Steps use checkbox syntax.
+
+**Goal:** ADR-0044 made payload-record naming collision-scoped in every port's PAYLOAD generator (two cross-package same-short-name `object.value`s → package-qualified names like `AcmeAlphaNotePayload`). #228: the **extract / output-parser tier** — which imports those nested classes — still names/imports them by BARE short name, so under a collision it references a class the payload generator no longer emits. Extend the collision-scoped naming to that tier in all 5 ports, gated by a new json/xml collision fixture. **Latent today** (the only collision fixture is `@format: html`; the extract tier gates on `@format ∈ {json,xml}`), so nothing shipped is wrong — but it's the last known instance of the bare-name bug class (sibling of #219/#220/#244).
+
+**Design settled (fable ruling, 2026-07-29):** TS uses **Option A** — the extractor's strict type is the per-VO ENTITY module (not the payload interface, which deliberately differs: `?:T|null` vs `?:T`, decimal `number` vs `string`, uuid/uri/inet/map → `unknown`, and lives in the optional relocatable `promptRender()` output). Cross-port invariant: the extractor's strict type = **each port's canonical strict artifact** — payload record (Py/C#/Kotlin), flavored class (Java), per-VO entity module (TS). So the 4 non-TS ports reuse their OWN payload name-map (mechanical); TS additionally brings its entity tier into ADR-0044 scope (the entity-file clobber is the load-bearing half in TS — NOT deferrable).
+
+**Reference:** ADR-0044 (`spec/decisions/ADR-0044-payload-record-naming-cross-package-collision.md`). Backstop `ERR_PAYLOAD_NAME_COLLISION` already in the shared ledger since 0.19.3.
+
+## Global Constraints
+
+- **Byte-identical, all ports, when non-colliding.** Qualification activates ONLY when two closure/domain members share a bare short name. A collision-free model emits today's exact names/paths. Pin with no-churn tests. **TS: the golden byte-gate lives OUTSIDE the per-package suite — run `cd server/typescript/packages/codegen-ts && bun test test/golden/` and regen only with proof.**
+- **The collision domain differs by tier (TS-specific, load-bearing):** the PAYLOAD/prompts artifact keeps its per-payload-closure domain; the ENTITY tier's domain is the run/target's emitted-object SET (filenames + `runner.ts` `packageOf` are per-outDir global). These two artifacts may assign different names to the same VO — each internally consistent. The extract tier imports from entity modules, so it MUST use the entity-domain name map, never payload-codegen's closure map.
+- **Resolution stays ADR-0041/0042 FQN-exact/package-local.** Where a port's extract tier resolves a `@objectRef` by bare name (Python `ref_vo`/`_find_object`, C# `RefVo`/`FindObject`), route it through the port's canonical resolver (`resolve_object_ref` / `NamingRefs.ResolveObjectRef`) — this fixes a wrong-node resolution bug (the #219 disease), not just naming.
+- Reuse `ERR_PAYLOAD_NAME_COLLISION` as the backstop (already central, all ports). No new vocabulary; no metamodel change (ADR-0023 unaffected).
+- Each port compiles + tests locally before its commit. Scope tests to the port (`scripts/ci-local.sh --only ` or the port's native runner). Never bare repo-root `bun test`.
+- Stage explicit paths; never `git add -A` (untracked `.serena/`). Commit to this branch.
+- Detailed per-file:line scope lives in each task below (captured from the scoping pass).
+
+---
+
+### Task 1: Shared json/xml collision fixture
+
+**Files:** Create `fixtures/template-output-render-conformance/xpkg-collision-json/{meta.alpha.json,meta.beta.json,meta.app.json}`; modify that corpus's `README.md`; modify `docs/CONFORMANCE.md`.
+
+The extract/parser tier gates on `@format ∈ {json,xml}`; the existing `xpkg-collision/` fixture is `@format: html`, so it never exercises the tier. This fixture is a near-copy that DOES.
+
+- [ ] **Step 1:** Copy the three metadata files from `fixtures/template-output-render-conformance/xpkg-collision/` verbatim (`meta.alpha.json` = pkg `acme::alpha`, `object.value Note{alphaText @required}`; `meta.beta.json` = pkg `acme::beta`, `object.value Note{betaText @required}`; `meta.app.json` = pkg `acme::app`, `object.value Digest` with `field.object fromAlpha @objectRef=acme::alpha::Note` + `fromBeta @objectRef=acme::beta::Note`, and a `template.output DigestDoc @payloadRef=Digest @textRef=xpkg/digest`). In the copy, change the `template.output`'s `"@format": "html"` → `"@format": "json"`. Everything else identical. No `expected.json` (this corpus pins expectations in prose + inline per-port test assertions).
+- [ ] **Step 2:** Add a section to `fixtures/template-output-render-conformance/README.md` mirroring the existing "Cross-package short-name collision" section, describing the json variant and that it exercises the extract/output-parser tier; state the expected emitted nested names (`AcmeAlphaNotePayload`/`AcmeBetaNotePayload` for Py/Java/Kotlin; `AcmeAlphaNote`/`AcmeBetaNote` for TS/C#).
+- [ ] **Step 3:** Bump the fixture count in `docs/CONFORMANCE.md` (the template-output-render-conformance row).
+- [ ] **Step 4:** Validate the three JSON files parse (`node -e "JSON.parse(require('fs').readFileSync(''))"` each). Commit `test(#228): add json xpkg-collision fixture for the extract/output-parser tier`.
+
+Note: `template-output-render-conformance` has NO auto-discovery — each port's test hardcodes the dir + filenames. So this fixture does nothing until a port adds a test method referencing it (done per-port in Tasks 4-8).
+
+---
+
+### Task 2: TS — shared collision-naming module (pure refactor, no behavior change)
+
+**Files:** Create `server/typescript/packages/codegen-ts/src/naming/collision-names.ts`; modify `src/payload-codegen.ts`; Test `test/naming/collision-names.test.ts`.
+
+`assignEmittedNames` (payload-codegen.ts:139) and `packageQualifiedName` (within payload-codegen.ts:111-176) are pure functions of `(fqn, bareName, package)` triples. Extract them to a shared module so the entity + extract tiers can reuse the identical algorithm.
+
+- [ ] **Step 1 (test):** Write `collision-names.test.ts`: assert `assignEmittedNames` over a closure with a unique bare name → bare emitted name; over a closure with two same-bare-name FQNs → both package-qualified (`AcmeAlphaNote`/`AcmeBetaNote`); a still-colliding derived name → throws `ERR_PAYLOAD_NAME_COLLISION`. Mirror the existing payload-codegen collision tests.
+- [ ] **Step 2:** Move `assignEmittedNames` + `packageQualifiedName` (+ the `ERR_PAYLOAD_NAME_COLLISION` throw) verbatim into `collision-names.ts`, exported. Keep signatures identical.
+- [ ] **Step 3:** `payload-codegen.ts` re-imports them (delete the local copies). Run `bun test test/payload-codegen.test.ts` — must stay green (byte-identical payload output). Run the golden gate `bun test test/golden/`.
+- [ ] **Step 4:** Typecheck (`cd server/typescript && bun run --filter '@metaobjectsdev/codegen-ts' typecheck`). Commit `refactor(#228): extract collision-name assignment into a shared module`.
+
+---
+
+### Task 3: TS — entity tier into ADR-0044 scope (Option A)
+
+**Files:** modify `src/templates/entity-file.ts` (+ `src/generators/entity-file.ts` if the filename is decided there), `src/templates/inferred-types.ts`, `src/templates/zod-validators.ts`, `src/templates/drizzle-schema.ts`, `src/import-path.ts`, `src/runner.ts`; Tests: extend the relevant per-template tests + a new collision test.
+
+Make the per-VO entity module (interface name + output filename) collision-aware, keyed by the **run/target emitted-object set** (NOT the payload closure). This is the load-bearing half of TS #228 (the extract tier imports FROM here).
+
+- [ ] **Step 1 (test):** Add a collision test: two same-short-name `object.value`s across packages, run entity-file generation, assert both emit distinct interfaces (`AcmeAlphaNote`/`AcmeBetaNote`) to distinct module paths, and every `valueObjectModuleSpecifier`-routed reference (Zod `[InsertSchema`, Drizzle `.$type<>()`, inferred-types field.object/map) uses the qualified name. Assert non-colliding case byte-identical (no-churn).
+- [ ] **Step 2 (impl):**
+ - Build the entity-domain name map once (over the run's emitted `object.value` set) using `collision-names.ts` `assignEmittedNames`.
+ - `runner.ts` (~146-148): `packageOf` currently `Map` keyed by bare `o.name` — the second same-named object overwrites the first (the #244 disease, load-order-dependent misbinding in package layout). Key it by the emitted name (unique by construction via the backstop), so `valueObjectModuleSpecifier` resolves the right module.
+ - `inferred-types.ts` (`valueObjectFieldType` field.object/field.map ref branches, ~260-264/274-278): `stripPackage(ref)` → name-map lookup by `resolutionKey()`. `renderValueObjectInterface` (~314) declaration name → emitted name.
+ - `entity-file.ts` output filename → emitted name; `import-path.ts` `valueObjectModuleSpecifier` (~69-78) → emitted name.
+ - `zod-validators.ts` `][InsertSchema` imports; `drizzle-schema.ts` `.$type<>()` imports → emitted names.
+ - Enum alias names follow the emitted owner name (`enumUnionAliasName(ownerName, …)`), matching what payload-codegen.ts:265 already does.
+- [ ] **Step 3:** Run the codegen-ts suite + the golden gate. Non-colliding output MUST be byte-identical (regen golden only with proof the diff is collision-only). Typecheck.
+- [ ] **Step 4:** Commit `fix(#228): TS entity tier emits collision-scoped value-object names (Option A)`.
+
+---
+
+### Task 4: TS — extract/output-parser tier + TS collision test
+
+**Files:** modify `src/templates/extractor.ts`, `src/templates/extract-delegate-emitter.ts`, `src/templates/output-parser.ts`; Test: a TS test method vs the Task-1 fixture + inline collision tests.
+
+Thread the entity-domain name map through the extract tier; replace every bare `vo.name` name/dedupe key with `resolutionKey()`-keyed lookups.
+
+- [ ] **Step 1 (test):** TS test loading `fixtures/template-output-render-conformance/xpkg-collision-json/` (hardcoded path, like the existing render-helper-conformance xpkg test at `test/render-helper-conformance.test.ts`), running `extractor()` + `outputParser()` + `entityFile()`, asserting the emitted extractor/parser source imports/references `AcmeAlphaNote`/`AcmeBetaNote` (matching Task 3's entity module) and NOT bare `Note`; and that both mirror types/mappers are emitted (not dropped). Compile the generated output if the test harness supports it.
+- [ ] **Step 2 (impl):**
+ - `extract-delegate-emitter.ts`: `mirrorName`(54)/`mapperName`(59) → name-map; the four `seen`-by-`vo.name`/`cur.name` sets (109,171,245,288) → `resolutionKey()`. Signature changes ripple to exported `nestedMirrorInterfaces`/`nestedMappers`/`mirrorName`/`usedHelpers`/`hasNested` (thread the map).
+ - `extractor.ts`: `mapperName`(91-93), `emitMapper` dedupe/mir (181,186), `reachablePayloadGroups` dedupe+naming+module-target (229-230,235,243 — module target = the entity-domain emitted name from Task 3), `reachableMirrorTypes` (259,264-266), `strictType = vo.name`(308) → entity-domain name map.
+ - `output-parser.ts`: the extract-delegate calls (180,205,207) thread the map. (Its inline Zod schema has no type names — no change. The `root.findObject(vo.name)` runtime lookup at 196/224 is a separate FQN-runtime hazard — note it, but keep in scope only if a fixture exercises it; otherwise leave a `// #228: bare findObject...` comment and a follow-up note in the report.)
+ - `refVo()` in both files already resolves FQN-exact (no change needed).
+- [ ] **Step 3:** codegen-ts suite + golden gate + typecheck. Non-colliding byte-identical.
+- [ ] **Step 4:** Commit `fix(#228): TS extract/output-parser tier uses collision-scoped names`.
+
+---
+
+### Task 5: Python — extract tier collision naming + wrong-node fix
+
+**Files:** modify `server/python/src/metaobjects/codegen/extract_delegate_emitter.py`, `server/python/src/metaobjects/codegen/generators/extractor_generator.py`; promote 2 funcs from `codegen/generators/payload_vo_generator.py` (or a small shared module); Test: `server/python/tests/codegen/` new collision test.
+
+Python has TWO bug classes here (worse than naming): `reachable_vos` drops the 2nd colliding VO; `ref_vo` mis-resolves.
+
+- [ ] **Step 1 (test):** pytest loading `xpkg-collision-json/` (hardcoded path like `test_render_helper_conformance.py`), asserting the extractor + output-parser emit BOTH `AcmeAlphaNotePayload`/`AcmeBetaNotePayload` mirror/mapper/imports (not a dropped 2nd VO, not bare `NotePayload`).
+- [ ] **Step 2 (impl):**
+ - Promote `_assign_nested_names` + `_package_qualified_name` (payload_vo_generator.py:471-479,547-582) to shared/exported; reuse `ERR_PAYLOAD_NAME_COLLISION` (errors.py:138).
+ - `extract_delegate_emitter.py`: `ref_vo`/`_find_object` (38-63) → `resolve_object_ref(root, ref, referrer_pkg)` (naming_refs.py:190), drop the bare-tail fallback; `reachable_vos` (131-148) dedupe key `cur.name` → `cur.resolution_key()`; `mirror_name`/`_mapper_name` (72-79) → name-map; thread the map through `_nested_mirror_type`(109), `nested_mirror_dataclasses`/`_one_mirror`(177,190), `nested_mappers`/`_one_mapper`/`_mapper_arg`(219-270).
+ - `extractor_generator.py`: `_strict_class`(62-68), `_mapper_name`(71-73), strict_imports loop (181-185) → name-map.
+- [ ] **Step 3:** `cd server/python && uv run --extra integration pytest tests/codegen/` (scope to codegen). Commit `fix(#228): Python extract tier collision-scoped naming + FQN resolution`.
+
+---
+
+### Task 6: C# — extract tier collision naming (shared ExtractDelegateEmitter)
+
+**Files:** modify `server/csharp/MetaObjects.Codegen/Generators/ExtractDelegateEmitter.cs`, `Generators/ExtractorGenerator.cs`, `Generators/OutputParserGenerator.cs`, `MetaObjects.Codegen/PayloadCodegen.cs` (visibility); Test: `MetaObjects.Codegen.Tests/` new collision test.
+
+Both generators funnel through `ExtractDelegateEmitter` — highest leverage.
+
+- [ ] **Step 1 (test):** test loading `xpkg-collision-json/`, asserting extractor + output-parser emit `AcmeAlphaNote`/`AcmeBetaNote` mirror/mapper/refs, not bare/dropped. Mirror `PayloadGeneratorTests.cs:121`.
+- [ ] **Step 2 (impl):**
+ - `PayloadCodegen.cs`: promote `CollectClosure`(123)+`AssignEmittedNames`(171) private→internal (or add one internal wrapper returning `(order, byFqn, nameMap)`). `ResolveEmittedName`(218) already internal.
+ - `ExtractDelegateEmitter.cs`: `FindObject`(40-42)/`RefVo`(49-57) → `NamingRefs.ResolveObjectRef`/`EffectivePackage` (NamingRefs.cs:49,70, public); `MirrorName`(68)/`MapperName`(71) → name-map; thread through all consumers.
+ - `ExtractorGenerator.cs`: root strict/mirror/class (87-89), `EmitMapper`(157-158), `StrictArg`(187-190), `EnumTypeRef`(238 — pass the EMITTED owner name to `PayloadCodegen.EnumTypeName`).
+ - `OutputParserGenerator.cs`: payload-root resolution (95/107 `StripPkg`+root-scan) → FQN-aware.
+- [ ] **Step 3:** `cd server/csharp && dotnet test` (or `scripts/ci-local.sh --only csharp`). Commit `fix(#228): C# extract/output-parser tier collision-scoped naming`.
+
+---
+
+### Task 7: Java — SpringOutputParserGenerator collision naming
+
+**Files:** modify `server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringOutputParserGenerator.java`, `SpringPayloadGenerator.java` (visibility); Test: `SpringOutputParserGeneratorTest.java` (or a new test).
+
+Smallest port — single-file fix + reuse the payload name-map.
+
+- [ ] **Step 1 (test):** test loading `xpkg-collision-json/` (hardcoded, like `SpringPayloadGeneratorTest.java:812`), asserting the output-parser's `from` mappers reference `AcmeAlphaNotePayload`/`AcmeBetaNotePayload`.
+- [ ] **Step 2 (impl):**
+ - `SpringPayloadGenerator.java`: `computePayloadNameMap`(159-207)+`collectNestedClosure`+`nestedTargetOf`+`packageQualifiedName` protected/instance → `public static`.
+ - `SpringOutputParserGenerator.java`: `execute()`(113-131) gather ALL `MetaTemplate` (not just `SUBTYPE_OUTPUT`) so the nameMap domain matches the payload generator's; thread a `Map nameMap` through `emit → emitMapperMethods → emitMapper → mapperArgForField`; fix `nestedPayloadClass`(369-371) to consult it.
+- [ ] **Step 3:** `cd server/java && mvn -pl codegen-spring test` (or `scripts/ci-local.sh --only java`; NO `-T`). Commit `fix(#228): Java output-parser tier collision-scoped payload naming`.
+
+---
+
+### Task 8: Kotlin — extract tier collision naming (three-tier)
+
+**Files:** modify `server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinPayloadGenerator.kt` (or lift to `KotlinGenUtil.kt`), `KotlinOutputParserGenerator.kt`, `KotlinExtractSchemaEmitter.kt`, `KotlinExtractMapperEmitter.kt`, `KotlinExtractorGenerator.kt`; Test: `KotlinOutputParserGeneratorTest.kt`/new.
+
+Heaviest — three tiers (Payload → Extracted mirror → strict Payload); Kotlin `protected` ≠ same-package.
+
+- [ ] **Step 1 (test):** test loading `xpkg-collision-json/`, asserting the extractor/parser reference `AcmeAlphaNotePayload`/`AcmeBetaNotePayload` (strict) AND collision-scoped `...Extracted` mirror names.
+- [ ] **Step 2 (impl):**
+ - LIFT `computePayloadNameMap`+`collectNestedClosure`+`nestedTargetOf`+`packageQualifiedName` from `KotlinPayloadGenerator.kt`(107-221) into `KotlinGenUtil` (public object).
+ - `KotlinOutputParserGenerator.kt`: `execute()` gather ALL MetaTemplate; thread nameMap.
+ - `KotlinExtractSchemaEmitter.kt`: `nestedExtractedClass`(66-67) — collision-scope the "Extracted" mirror family (its own second naming scheme); thread nameMap through `extractedClassDeclsNested→emitMirror→nestedNullableTypeName`.
+ - `KotlinExtractMapperEmitter.kt`: 74,91 — thread nameMap.
+ - `KotlinExtractorGenerator.kt`: 239-242,271 — use the strict Payload nameMap (for `toStrict`) AND the mirror nameMap (for `Extracted`).
+- [ ] **Step 3:** `cd server/java && mvn -pl codegen-kotlin test` (or `scripts/ci-local.sh --only kotlin`). Commit `fix(#228): Kotlin extract/output-parser tier collision-scoped naming`.
+
+---
+
+### Task 9: Docs + CHANGELOG
+
+**Files:** modify `CHANGELOG.md`; touch ADR-0044 Consequences (mark the extract-tier follow-up shipped) if apt.
+
+- [ ] **Step 1:** `CHANGELOG.md` `## [Unreleased]` — coordinated cross-port bug fix (all 5 ports): the extract/output-parser tier now uses ADR-0044 collision-scoped payload names (was bare — under a cross-package short-name collision it referenced a class the payload generator no longer emits; Python/C# additionally mis-resolved the wrong node, TS additionally clobbered the entity module). Note it's LATENT (no shipped-wrong output; the collision fixture was html-only), byte-identical for non-colliding models, gated by the new json fixture. TS = Option A (entity tier). `ERR_PAYLOAD_NAME_COLLISION` reused.
+- [ ] **Step 2:** In ADR-0044 Consequences, note the extract/output-parser sibling-generator recurrence (line ~51) is now addressed by #228.
+- [ ] **Step 3:** Commit `docs(#228): CHANGELOG + ADR-0044 note for the extract-tier collision-naming fix`.
+
+---
+
+## Self-Review
+
+**Spec coverage:** fixture → Task 1; TS Option A (naming module → entity tier → extract tier) → Tasks 2-4; Python → 5; C# → 6; Java → 7; Kotlin → 8; docs → 9. Each port task carries its own collision test vs the shared fixture (per-port hardcoded path — no auto-discovery). Byte-identical-when-non-colliding pinned per port. Wrong-node resolution (Python/C#) fixed via each port's canonical resolver.
+
+**Domain correctness:** the TS entity tier uses the run/target emitted-object domain (Task 3), the extract tier consumes THAT map (Task 4) — not payload-codegen's per-payload-closure domain. The 4 non-TS ports reuse their own payload name-map (their strict artifact IS the payload record/flavored class).
+
+**Ordering:** Task 1 (fixture) first so every port test can reference it. Task 2 (shared module) before 3-4. Ports 5-8 independent. Task 9 last.
From c6444113a423ea5b0296aa368913a6b09d73d12e Mon Sep 17 00:00:00 2001
From: Doug Mealing
Date: Wed, 29 Jul 2026 22:06:37 -0400
Subject: [PATCH 02/19] test(#228): add json xpkg-collision fixture for the
extract/output-parser tier
Co-Authored-By: Claude Opus 4.8
Claude-Session: https://claude.ai/code
---
docs/CONFORMANCE.md | 2 +-
.../README.md | 13 ++++++++++
.../xpkg-collision-json/meta.alpha.json | 15 +++++++++++
.../xpkg-collision-json/meta.app.json | 25 +++++++++++++++++++
.../xpkg-collision-json/meta.beta.json | 15 +++++++++++
5 files changed, 69 insertions(+), 1 deletion(-)
create mode 100644 fixtures/template-output-render-conformance/xpkg-collision-json/meta.alpha.json
create mode 100644 fixtures/template-output-render-conformance/xpkg-collision-json/meta.app.json
create mode 100644 fixtures/template-output-render-conformance/xpkg-collision-json/meta.beta.json
diff --git a/docs/CONFORMANCE.md b/docs/CONFORMANCE.md
index 8f438ced2..5f076a32f 100644
--- a/docs/CONFORMANCE.md
+++ b/docs/CONFORMANCE.md
@@ -39,7 +39,7 @@ regenerate with `ls -d fixtures//*/ | wc -l`.
| [`fixtures/object-model-conformance/`](../fixtures/object-model-conformance/) | 1 shared metadata fixture (per-port scenarios) | ✓ | ✓ | ✓ | ✓ | ✓ |
| [`fixtures/codegen-conformance/`](../fixtures/codegen-conformance/) | 4 | ✓ | ✓ | ✓ | ✓ | ✓ |
| [`fixtures/template-codegen-conformance/`](../fixtures/template-codegen-conformance/) | 3 | ✓ | ✓ | ✓ | ✓ | ✓ |
-| [`fixtures/template-output-render-conformance/`](../fixtures/template-output-render-conformance/) | 4 | ✓ | ✓ | ✓ | ✓ | ✓ |
+| [`fixtures/template-output-render-conformance/`](../fixtures/template-output-render-conformance/) | 5 | ✓ | ✓ | ✓ | ✓ | ✓ |
| [`fixtures/generator-registry-conformance/`](../fixtures/generator-registry-conformance/) | 1 canonical manifest | ✓ | ✓ | ✓ | ✓ | ✓ |
| [`fixtures/provider-composition-conformance/`](../fixtures/provider-composition-conformance/) | 5 cases | ✓ | ✓ | — (JVM registry via Java) | ✓ | ✓ |
| [`fixtures/agent-context-conformance/`](../fixtures/agent-context-conformance/) | 4 | ✓ (the emitter is TS-owned) | — | — | — | — |
diff --git a/fixtures/template-output-render-conformance/README.md b/fixtures/template-output-render-conformance/README.md
index cbcaa3ae7..9a959026e 100644
--- a/fixtures/template-output-render-conformance/README.md
+++ b/fixtures/template-output-render-conformance/README.md
@@ -166,6 +166,19 @@ The render-output pins above (`"Alpha=AA Beta=BB"`) are UNCHANGED by this contra
idiomatic (Tier-1 codegen); this sub-corpus gates it by compile + construct +
render + name assertions, strengthening the gate rather than weakening it.
+## Cross-package short-name collision with extract/output-parser tier — `xpkg-collision-json/`
+
+Identical to `xpkg-collision/` above (same three metadata files, same `Digest`
+payload with colliding `Note` VOs from `acme::alpha` and `acme::beta`), but the
+`DigestDoc` `template.output` has `@format="json"` instead of `@format="html"`.
+This variant exercises the extract/output-parser tier (which gates on
+`@format ∈ {json,xml}`); the html variant does not. The generated render helper,
+collision-aware payload naming, and render output remain identical — see
+[Cross-package short-name collision](#cross-package-short-name-collision--xpkg-collision-digestdoc)
+above for the full contract and expected payload names
+(`AcmeAlphaNotePayload`/`AcmeBetaNotePayload` for Java/Kotlin/Python;
+`AcmeAlphaNote`/`AcmeBetaNote` for TS/C#).
+
## Expected build-time drift FAILURE — `drift/`
`drift/meta.json` declares the same `Welcome` VO and a `document`
diff --git a/fixtures/template-output-render-conformance/xpkg-collision-json/meta.alpha.json b/fixtures/template-output-render-conformance/xpkg-collision-json/meta.alpha.json
new file mode 100644
index 000000000..dd60ca834
--- /dev/null
+++ b/fixtures/template-output-render-conformance/xpkg-collision-json/meta.alpha.json
@@ -0,0 +1,15 @@
+{
+ "metadata.root": {
+ "package": "acme::alpha",
+ "children": [
+ {
+ "object.value": {
+ "name": "Note",
+ "children": [
+ { "field.string": { "name": "alphaText", "@required": true } }
+ ]
+ }
+ }
+ ]
+ }
+}
diff --git a/fixtures/template-output-render-conformance/xpkg-collision-json/meta.app.json b/fixtures/template-output-render-conformance/xpkg-collision-json/meta.app.json
new file mode 100644
index 000000000..f7b1c3cf7
--- /dev/null
+++ b/fixtures/template-output-render-conformance/xpkg-collision-json/meta.app.json
@@ -0,0 +1,25 @@
+{
+ "metadata.root": {
+ "package": "acme::app",
+ "children": [
+ {
+ "object.value": {
+ "name": "Digest",
+ "children": [
+ { "field.object": { "name": "fromAlpha", "@objectRef": "acme::alpha::Note" } },
+ { "field.object": { "name": "fromBeta", "@objectRef": "acme::beta::Note" } }
+ ]
+ }
+ },
+ {
+ "template.output": {
+ "name": "DigestDoc",
+ "@kind": "document",
+ "@payloadRef": "Digest",
+ "@textRef": "xpkg/digest",
+ "@format": "json"
+ }
+ }
+ ]
+ }
+}
diff --git a/fixtures/template-output-render-conformance/xpkg-collision-json/meta.beta.json b/fixtures/template-output-render-conformance/xpkg-collision-json/meta.beta.json
new file mode 100644
index 000000000..74ddf76fa
--- /dev/null
+++ b/fixtures/template-output-render-conformance/xpkg-collision-json/meta.beta.json
@@ -0,0 +1,15 @@
+{
+ "metadata.root": {
+ "package": "acme::beta",
+ "children": [
+ {
+ "object.value": {
+ "name": "Note",
+ "children": [
+ { "field.string": { "name": "betaText", "@required": true } }
+ ]
+ }
+ }
+ ]
+ }
+}
From 2b8e17a8175548a8959a76b995c08459e05e963d Mon Sep 17 00:00:00 2001
From: Doug Mealing
Date: Thu, 30 Jul 2026 15:13:16 -0400
Subject: [PATCH 03/19] refactor(#228): extract collision-name assignment into
a shared module
---
.../codegen-ts/src/naming/collision-names.ts | 87 ++++++++++++++++
.../codegen-ts/src/payload-codegen.ts | 83 +---------------
.../test/naming/collision-names.test.ts | 99 +++++++++++++++++++
3 files changed, 190 insertions(+), 79 deletions(-)
create mode 100644 server/typescript/packages/codegen-ts/src/naming/collision-names.ts
create mode 100644 server/typescript/packages/codegen-ts/test/naming/collision-names.test.ts
diff --git a/server/typescript/packages/codegen-ts/src/naming/collision-names.ts b/server/typescript/packages/codegen-ts/src/naming/collision-names.ts
new file mode 100644
index 000000000..d15e8eabe
--- /dev/null
+++ b/server/typescript/packages/codegen-ts/src/naming/collision-names.ts
@@ -0,0 +1,87 @@
+// ADR-0044 collision-scoped naming — shared across every codegen tier that emits
+// declarations from a reference closure (payload records today; the entity +
+// extract/output-parser tiers per issue #228).
+//
+// `assignEmittedNames` is a PURE function of the closure: a bare short name
+// unique in the closure emits bare; a collision emits EVERY member under its
+// package-qualified derived name (PascalCase each package segment + short
+// name). A still-colliding derived name fails loud (ERR_PAYLOAD_NAME_COLLISION).
+
+import { type MetaData, PACKAGE_SEPARATOR } from "@metaobjectsdev/metadata";
+
+// ADR-0044 backstop error code — a codegen-time (not loader) error, peer of
+// @metaobjectsdev/render's ERR_VAR_NOT_ON_PAYLOAD. Declared LOCALLY rather than
+// added to packages/metadata/src/errors.ts's ERROR_CODES ledger: that ledger is
+// checked for FULL cross-port agreement against fixtures/conformance/ERROR-CODES.json
+// (packages/metadata/test/errors.test.ts) and, on the Python side, for corpus-code
+// coverage — registering it there before every port implements the ADR-0044 fix
+// would turn those OTHER ports' tests red. It moves into the shared ledger once the
+// Java/Kotlin/Python follow-up (ADR-0044 §4, items 3-4) lands alongside this code.
+export const ERR_PAYLOAD_NAME_COLLISION = "ERR_PAYLOAD_NAME_COLLISION";
+
+function pascalSegment(s: string): string {
+ return s.length > 0 ? s[0]!.toUpperCase() + s.slice(1) : s;
+}
+
+/** ADR-0044 — package-qualified derived name for a collision member: PascalCase
+ * each `::`-segment of the node's effective package, concatenated, then the
+ * bare short name (`acme::alpha::Note` -> `AcmeAlphaNote`). A root-level
+ * (no-package) node has nothing to qualify with and keeps its bare name — two
+ * root-level VOs can never share a name (the loader's own-package uniqueness
+ * already rejects that), so this can't silently under-qualify. */
+export function packageQualifiedName(pkg: string, shortName: string): string {
+ if (pkg === "") return shortName;
+ return (
+ pkg
+ .split(PACKAGE_SEPARATOR)
+ .map(pascalSegment)
+ .join("") + shortName
+ );
+}
+
+/**
+ * ADR-0044 pass 2 — assign the emitted TS name for every VO in the closure. A
+ * PURE function of the closure's (fqn, bareName, package) triples — never of
+ * traversal order: bare short name unique in the closure -> bare name; a
+ * collision -> EVERY member gets its package-qualified derived name. If two
+ * DISTINCT fqns still derive the same name after qualification, throws
+ * (ERR_PAYLOAD_NAME_COLLISION) — never silently wrong.
+ */
+export function assignEmittedNames(closure: ReadonlyMap): Map {
+ const byShortName = new Map();
+ for (const [fqn, node] of closure) {
+ const bucket = byShortName.get(node.name);
+ if (bucket) bucket.push(fqn);
+ else byShortName.set(node.name, [fqn]);
+ }
+
+ const nameMap = new Map();
+ for (const [shortName, fqns] of byShortName) {
+ if (fqns.length === 1) {
+ nameMap.set(fqns[0]!, shortName);
+ continue;
+ }
+ for (const fqn of fqns) {
+ const node = closure.get(fqn)!;
+ const pkg = node.package ?? node.fileDefaultPackage ?? "";
+ nameMap.set(fqn, packageQualifiedName(pkg, shortName));
+ }
+ }
+
+ // Backstop — sorted by fqn so which pair the message names (and whether the
+ // set of colliding names is non-empty) is a pure function of the closure, not
+ // of Map insertion/traversal order.
+ const ownerOf = new Map();
+ for (const fqn of [...nameMap.keys()].sort()) {
+ const emitted = nameMap.get(fqn)!;
+ const existing = ownerOf.get(emitted);
+ if (existing !== undefined && existing !== fqn) {
+ throw new Error(
+ `${ERR_PAYLOAD_NAME_COLLISION}: payload record name collision: "${emitted}" derives from both "${existing}" and "${fqn}" — rename one value-object or move it to a package that derives a distinct name`,
+ );
+ }
+ ownerOf.set(emitted, fqn);
+ }
+
+ return nameMap;
+}
diff --git a/server/typescript/packages/codegen-ts/src/payload-codegen.ts b/server/typescript/packages/codegen-ts/src/payload-codegen.ts
index c8b6587d9..928cf193b 100644
--- a/server/typescript/packages/codegen-ts/src/payload-codegen.ts
+++ b/server/typescript/packages/codegen-ts/src/payload-codegen.ts
@@ -19,7 +19,9 @@
// unique in the closure emits bare; a collision emits
// EVERY member under its package-qualified derived
// name (PascalCase each package segment + short name).
-// A still-colliding derived name fails loud.
+// A still-colliding derived name fails loud. Lives in
+// ./naming/collision-names.js — shared with the
+// entity + extract/output-parser tiers (#228).
// 3. emitClosureDeclarations — emit each declaration + every reference through
// the name map.
@@ -35,12 +37,12 @@ import {
TEMPLATE_ATTR_PAYLOAD_REF,
TEMPLATE_ATTR_TEXT_REF,
TEMPLATE_ATTR_FORMAT,
- PACKAGE_SEPARATOR,
resolveObjectRef,
stripPackage,
} from "@metaobjectsdev/metadata";
import { enumValues } from "./enum-meta.js";
import { enumUnionAliasName, enumUnionString } from "./templates/inferred-types.js";
+import { assignEmittedNames } from "./naming/collision-names.js";
const SCALAR_TS: Record = {
string: "string",
@@ -59,16 +61,6 @@ const SCALAR_TS: Record = {
timestamp: "string",
};
-// ADR-0044 backstop error code — a codegen-time (not loader) error, peer of
-// @metaobjectsdev/render's ERR_VAR_NOT_ON_PAYLOAD. Declared LOCALLY rather than
-// added to packages/metadata/src/errors.ts's ERROR_CODES ledger: that ledger is
-// checked for FULL cross-port agreement against fixtures/conformance/ERROR-CODES.json
-// (packages/metadata/test/errors.test.ts) and, on the Python side, for corpus-code
-// coverage — registering it there before every port implements the ADR-0044 fix
-// would turn those OTHER ports' tests red. It moves into the shared ledger once the
-// Java/Kotlin/Python follow-up (ADR-0044 §4, items 3-4) lands alongside this code.
-const ERR_PAYLOAD_NAME_COLLISION = "ERR_PAYLOAD_NAME_COLLISION";
-
// ADR-0039: resolving — root has no super (children()==ownChildren()); a top-level object/template may itself extend, so resolve rather than work-by-accident.
// ADR-0042: resolveObjectRef gives package-local-before-root-level precedence for a bare ref, FQN-exact otherwise.
function findObject(root: MetaData, name: string, referrerPkg = ""): MetaData | undefined {
@@ -108,73 +100,6 @@ function collectClosure(
}
}
-function pascalSegment(s: string): string {
- return s.length > 0 ? s[0]!.toUpperCase() + s.slice(1) : s;
-}
-
-/** ADR-0044 — package-qualified derived name for a collision member: PascalCase
- * each `::`-segment of the node's effective package, concatenated, then the
- * bare short name (`acme::alpha::Note` -> `AcmeAlphaNote`). A root-level
- * (no-package) node has nothing to qualify with and keeps its bare name — two
- * root-level VOs can never share a name (the loader's own-package uniqueness
- * already rejects that), so this can't silently under-qualify. */
-function packageQualifiedName(pkg: string, shortName: string): string {
- if (pkg === "") return shortName;
- return (
- pkg
- .split(PACKAGE_SEPARATOR)
- .map(pascalSegment)
- .join("") + shortName
- );
-}
-
-/**
- * ADR-0044 pass 2 — assign the emitted TS name for every VO in the closure. A
- * PURE function of the closure's (fqn, bareName, package) triples — never of
- * traversal order: bare short name unique in the closure -> bare name; a
- * collision -> EVERY member gets its package-qualified derived name. If two
- * DISTINCT fqns still derive the same name after qualification, throws
- * (ERR_PAYLOAD_NAME_COLLISION) — never silently wrong.
- */
-function assignEmittedNames(closure: ReadonlyMap): Map {
- const byShortName = new Map();
- for (const [fqn, node] of closure) {
- const bucket = byShortName.get(node.name);
- if (bucket) bucket.push(fqn);
- else byShortName.set(node.name, [fqn]);
- }
-
- const nameMap = new Map();
- for (const [shortName, fqns] of byShortName) {
- if (fqns.length === 1) {
- nameMap.set(fqns[0]!, shortName);
- continue;
- }
- for (const fqn of fqns) {
- const node = closure.get(fqn)!;
- const pkg = node.package ?? node.fileDefaultPackage ?? "";
- nameMap.set(fqn, packageQualifiedName(pkg, shortName));
- }
- }
-
- // Backstop — sorted by fqn so which pair the message names (and whether the
- // set of colliding names is non-empty) is a pure function of the closure, not
- // of Map insertion/traversal order.
- const ownerOf = new Map();
- for (const fqn of [...nameMap.keys()].sort()) {
- const emitted = nameMap.get(fqn)!;
- const existing = ownerOf.get(emitted);
- if (existing !== undefined && existing !== fqn) {
- throw new Error(
- `${ERR_PAYLOAD_NAME_COLLISION}: payload record name collision: "${emitted}" derives from both "${existing}" and "${fqn}" — rename one value-object or move it to a package that derives a distinct name`,
- );
- }
- ownerOf.set(emitted, fqn);
- }
-
- return nameMap;
-}
-
/** Resolve `ref`'s emitted TS interface name under the ADR-0044 naming rule,
* scoped to `ref`'s OWN reference closure (the same closure
* `generatePayloadInterfaces(root, ref, referrerPkg)` would emit). Returns
diff --git a/server/typescript/packages/codegen-ts/test/naming/collision-names.test.ts b/server/typescript/packages/codegen-ts/test/naming/collision-names.test.ts
new file mode 100644
index 000000000..d1b67f2d9
--- /dev/null
+++ b/server/typescript/packages/codegen-ts/test/naming/collision-names.test.ts
@@ -0,0 +1,99 @@
+import { describe, test, expect } from "bun:test";
+import { MetaDataLoader, InMemoryStringSource, resolveObjectRef, type MetaData } from "@metaobjectsdev/metadata";
+import { assignEmittedNames, packageQualifiedName } from "../../src/naming/collision-names.js";
+
+/** Multi-file (multi-package) load — one source per (package, children) pair,
+ * merged into a single root. Mirrors payload-codegen.test.ts's ADR-0044
+ * collision-naming fixtures (this module is the extracted pass-2 algorithm
+ * those tests exercise indirectly via generatePayloadInterfaces). */
+async function loadMultiPackageRoot(files: { package: string; children: unknown[] }[]) {
+ const sources = files.map(
+ (f) => new InMemoryStringSource(JSON.stringify({ "metadata.root": { package: f.package, children: f.children } })),
+ );
+ const res = await new MetaDataLoader().load(sources);
+ expect(res.errors).toEqual([]);
+ return res.root;
+}
+
+/** Build a (resolutionKey -> node) closure map — the same shape
+ * payload-codegen's `collectClosure` produces — from a flat list of FQN
+ * `object.value` refs resolved against `root`. */
+function closureOf(root: MetaData, refs: string[]): Map {
+ const closure = new Map();
+ for (const ref of refs) {
+ const node = resolveObjectRef(root, ref, "").node;
+ if (node) closure.set(node.resolutionKey(), node);
+ }
+ return closure;
+}
+
+describe("collision-names — packageQualifiedName", () => {
+ test("a root-level (no package) node keeps its bare short name", () => {
+ expect(packageQualifiedName("", "Note")).toBe("Note");
+ });
+
+ test("PascalCases each `::`-segment of the package, concatenated with the short name", () => {
+ expect(packageQualifiedName("acme::alpha", "Note")).toBe("AcmeAlphaNote");
+ expect(packageQualifiedName("acme::beta", "Note")).toBe("AcmeBetaNote");
+ });
+});
+
+describe("collision-names — assignEmittedNames", () => {
+ test("a unique bare name in the closure emits the bare name", async () => {
+ const root = await loadMultiPackageRoot([
+ {
+ package: "acme::alpha",
+ children: [
+ { "object.value": { name: "Note", children: [{ "field.string": { name: "text", "@required": true } }] } },
+ ],
+ },
+ ]);
+ const closure = closureOf(root, ["acme::alpha::Note"]);
+ const nameMap = assignEmittedNames(closure);
+ expect(nameMap.get("acme::alpha::Note")).toBe("Note");
+ });
+
+ test("two same-bare-name FQNs both emit package-qualified names", async () => {
+ const root = await loadMultiPackageRoot([
+ {
+ package: "acme::alpha",
+ children: [
+ { "object.value": { name: "Note", children: [{ "field.string": { name: "alphaText", "@required": true } }] } },
+ ],
+ },
+ {
+ package: "acme::beta",
+ children: [
+ { "object.value": { name: "Note", children: [{ "field.string": { name: "betaText", "@required": true } }] } },
+ ],
+ },
+ ]);
+ const closure = closureOf(root, ["acme::alpha::Note", "acme::beta::Note"]);
+ const nameMap = assignEmittedNames(closure);
+ expect(nameMap.get("acme::alpha::Note")).toBe("AcmeAlphaNote");
+ expect(nameMap.get("acme::beta::Note")).toBe("AcmeBetaNote");
+ });
+
+ test("a still-colliding derived name FAILS LOUD with ERR_PAYLOAD_NAME_COLLISION (backstop)", async () => {
+ // Pathological: "acme::alpha::Note" and "acmeAlpha::Note" both PascalCase-fold
+ // to the SAME derived name "AcmeAlphaNote" — qualification cannot disambiguate.
+ const root = await loadMultiPackageRoot([
+ {
+ package: "acme::alpha",
+ children: [
+ { "object.value": { name: "Note", children: [{ "field.string": { name: "a", "@required": true } }] } },
+ ],
+ },
+ {
+ package: "acmeAlpha",
+ children: [
+ { "object.value": { name: "Note", children: [{ "field.string": { name: "b", "@required": true } }] } },
+ ],
+ },
+ ]);
+ const closure = closureOf(root, ["acme::alpha::Note", "acmeAlpha::Note"]);
+ expect(() => assignEmittedNames(closure)).toThrow(
+ /ERR_PAYLOAD_NAME_COLLISION.*"AcmeAlphaNote".*derives from both.*"acme::alpha::Note".*"acmeAlpha::Note"/,
+ );
+ });
+});
From 3b30ab73c1bd6e40ebec308e35027413657a1237 Mon Sep 17 00:00:00 2001
From: Doug Mealing
Date: Thu, 30 Jul 2026 15:37:52 -0400
Subject: [PATCH 04/19] fix(#228): TS entity tier emits collision-scoped
value-object names (Option A)
Co-Authored-By: Claude Opus 4.8
Claude-Session: https://claude.ai/code/session_01HLoJkFSyoticveo5ehMUAr
---
.../codegen-ts/src/generators/entity-file.ts | 8 +-
.../packages/codegen-ts/src/render-context.ts | 43 +++-
.../packages/codegen-ts/src/runner.ts | 33 ++-
.../src/templates/drizzle-schema.ts | 14 +-
.../src/templates/inferred-types.ts | 56 +++--
.../src/templates/value-object-file.ts | 6 +-
.../src/templates/zod-validators.ts | 64 ++++--
.../test/entity-tier-collision.test.ts | 201 ++++++++++++++++++
8 files changed, 376 insertions(+), 49 deletions(-)
create mode 100644 server/typescript/packages/codegen-ts/test/entity-tier-collision.test.ts
diff --git a/server/typescript/packages/codegen-ts/src/generators/entity-file.ts b/server/typescript/packages/codegen-ts/src/generators/entity-file.ts
index 55ea1df6c..68cd11a4d 100644
--- a/server/typescript/packages/codegen-ts/src/generators/entity-file.ts
+++ b/server/typescript/packages/codegen-ts/src/generators/entity-file.ts
@@ -38,8 +38,14 @@ export const entityFile = function entityFile(opts?: EntityFileOpts): Generator
if (isAbstract(entity) && !ctx.renderContext.emitAbstractShapes) {
return [];
}
+ // ADR-0044/#228 — a value object's output filename follows its EMITTED name
+ // (bare when unique in the run, package-qualified on a cross-package short-name
+ // collision) so two same-bare-named value objects don't collide on one path
+ // (flat layout) and the module resolves to the same emitted symbol every
+ // reference imports. Entities are never in the collision set → bare name.
+ const emittedName = ctx.renderContext.valueObjectEmittedName(entity);
return {
- path: entityOutputPath(ctx.config.outputLayout ?? "flat", entity.package, `${entity.name}.ts`),
+ path: entityOutputPath(ctx.config.outputLayout ?? "flat", entity.package, `${emittedName}.ts`),
content: await formatTs(renderEntityFile(entity, ctx.renderContext, { allowlists })),
};
});
diff --git a/server/typescript/packages/codegen-ts/src/render-context.ts b/server/typescript/packages/codegen-ts/src/render-context.ts
index 14438287b..059362578 100644
--- a/server/typescript/packages/codegen-ts/src/render-context.ts
+++ b/server/typescript/packages/codegen-ts/src/render-context.ts
@@ -1,6 +1,7 @@
// RenderContext — cross-cutting state passed to every template.
-import type { MetaRoot } from "@metaobjectsdev/metadata";
+import type { MetaRoot, MetaData } from "@metaobjectsdev/metadata";
+import { resolveObjectRef, stripPackage } from "@metaobjectsdev/metadata";
import type { Dialect } from "./column-mapper.js";
import type { PkInfo } from "./pk-resolver.js";
import type { RelationMap } from "./relation-resolver.js";
@@ -67,8 +68,28 @@ export interface RenderContext {
pkMap: Map;
/** Pre-pass relation map for FK + relations() block emission. */
relationMap: RelationMap;
- /** Entity name → its metadata package (undefined if the entity has no package). Built once per run. */
+ /** Object name → its metadata package (undefined if the object has no package).
+ * Built once per run. Value objects are keyed by their ADR-0044 EMITTED name
+ * (bare when unique, package-qualified on a cross-package short-name collision;
+ * #228) so `valueObjectModuleSpecifier` resolves the right module; entities and
+ * other objects are keyed by their bare name. */
packageOf: Map;
+ /** ADR-0044/#228 — `resolutionKey()` → emitted TS name for every emitted
+ * `object.value` in the run. A PURE function of the run's value-object set
+ * (collision-scoped): a bare short name unique in the set stays bare; a
+ * cross-package short-name collision qualifies EVERY member. Empty by default
+ * (bare names — byte-identical to pre-#228 output). */
+ valueObjectNames: ReadonlyMap;
+ /** The ADR-0044 emitted name for a value object being DECLARED (its interface,
+ * Zod schema, and module filename). Non-value objects (entities) are never in
+ * the collision set, so this returns their bare `name`. */
+ valueObjectEmittedName: (obj: MetaData) => string;
+ /** The ADR-0044 emitted name for a REFERENCE to a value object (`@objectRef`,
+ * bare or FQN), resolved package-locally (ADR-0042) from `referrerPkg`. Falls
+ * back to the bare (package-stripped) ref when it resolves to no emitted value
+ * object — which is also the byte-identical result whenever there is no
+ * collision. */
+ resolveValueObjectName: (ref: string, referrerPkg: string | undefined) => string;
/** FR-019: module specifier to import externally-PROVIDED shared enums from
* (`@provided: true` declarations). Undefined when unset — referencing a
* provided enum without it is a codegen-time error. */
@@ -76,7 +97,7 @@ export interface RenderContext {
}
/** Optional shape — `extStyle`, `omImport`, `columnNamingStrategy`, `apiPrefix`, `outputLayout`, and `packageOf` default if omitted. `packageOf` defaults to an empty Map (correct for flat layout; `runGen` always provides the real map). `collectionName` is built from `pluralizeCollections` + `collectionNameOverrides` (both default to always-pluralize). */
-export type RenderContextInput = Omit & {
+export type RenderContextInput = Omit & {
extStyle?: ExtStyle;
omImport?: string;
columnNamingStrategy?: ColumnNamingStrategy;
@@ -85,6 +106,10 @@ export type RenderContextInput = Omit;
+ /** ADR-0044/#228 value-object emitted-name map (resolutionKey → emitted name).
+ * Defaults to an empty Map — bare names, byte-identical to pre-#228 output.
+ * `runGen` always provides the real map. */
+ valueObjectNames?: ReadonlyMap;
selfTarget?: ResolvedTarget;
entityModuleTarget?: ResolvedTarget;
/** Auto-pluralize collection (table) variable names. Default true. */
@@ -129,6 +154,11 @@ export function makeRenderContext(opts: RenderContextInput): RenderContext {
pluralize: opts.pluralizeCollections ?? true,
overrides: opts.collectionNameOverrides ?? {},
};
+ // ADR-0044/#228 — the value-object emitted-name map + its two accessors. When
+ // absent (bare template unit-tests), the map is empty, so both accessors return
+ // bare names and every consumer is byte-identical to pre-#228 output.
+ const valueObjectNames = opts.valueObjectNames ?? new Map();
+ const loadedRoot = opts.loadedRoot;
return {
...opts,
extStyle: opts.extStyle ?? "js",
@@ -139,6 +169,13 @@ export function makeRenderContext(opts: RenderContextInput): RenderContext {
emitAbstractShapes: opts.emitAbstractShapes ?? true,
outputLayout,
packageOf: opts.packageOf ?? new Map(),
+ valueObjectNames,
+ valueObjectEmittedName: (obj: MetaData) => valueObjectNames.get(obj.resolutionKey()) ?? obj.name,
+ resolveValueObjectName: (ref: string, referrerPkg: string | undefined) => {
+ const { node } = resolveObjectRef(loadedRoot, ref, referrerPkg ?? "");
+ const emitted = node !== undefined ? valueObjectNames.get(node.resolutionKey()) : undefined;
+ return emitted ?? stripPackage(ref);
+ },
selfTarget: defaultTarget,
entityModuleTarget: opts.entityModuleTarget ?? defaultTarget,
collectionName: (entityName: string) => variableNameFromEntity(entityName, collectionNameOpts),
diff --git a/server/typescript/packages/codegen-ts/src/runner.ts b/server/typescript/packages/codegen-ts/src/runner.ts
index c5f2c3ab4..dcc869e18 100644
--- a/server/typescript/packages/codegen-ts/src/runner.ts
+++ b/server/typescript/packages/codegen-ts/src/runner.ts
@@ -1,7 +1,8 @@
import { join, relative, resolve, isAbsolute } from "node:path";
import { tmpdir } from "node:os";
import type { MetaData, MetaObject } from "@metaobjectsdev/metadata";
-import { MetaRoot } from "@metaobjectsdev/metadata";
+import { MetaRoot, OBJECT_SUBTYPE_VALUE } from "@metaobjectsdev/metadata";
+import { assignEmittedNames } from "./naming/collision-names.js";
import type { Generator, GenContext, EmittedFile } from "./generator.js";
import type { MetaobjectsGenConfig } from "./metaobjects-config.js";
import { normalizeConfig, DEFAULT_TARGET_NAME } from "./metaobjects-config.js";
@@ -143,9 +144,32 @@ export async function runGen(opts: RunGenOpts): Promise {
// 3. Build shared render state once.
const pkMap = buildPkMap(root);
const relationMap = buildRelationMap(root);
- const packageOf = new Map(
- root.objects().map((o) => [o.name, o.package]),
- );
+ // ADR-0044/#228 — the ENTITY-tier collision domain is the run's emitted
+ // `object.value` SET (NOT any per-payload closure): value-object module
+ // filenames + `packageOf` are per-run/global, so the emitted-name map is built
+ // ONCE over every top-level `object.value`, keyed by `resolutionKey()`. A bare
+ // short name unique across the set stays bare (byte-identical to pre-#228
+ // output); a cross-package short-name collision qualifies every member
+ // (`AcmeAlphaNote`), and a still-colliding derived name fails loud
+ // (ERR_PAYLOAD_NAME_COLLISION, thrown by assignEmittedNames).
+ const valueObjectClosure = new Map();
+ for (const o of root.objects()) {
+ if (o.subType === OBJECT_SUBTYPE_VALUE) valueObjectClosure.set(o.resolutionKey(), o);
+ }
+ const valueObjectNames = assignEmittedNames(valueObjectClosure);
+ // `packageOf` keys value objects by their EMITTED name (unique by construction
+ // via the backstop) so `valueObjectModuleSpecifier` resolves the right module
+ // even when two same-bare-named value objects live in different packages (the
+ // #244 misbinding disease). Non-value objects keep their bare name. With no
+ // collision, every emitted name equals its bare name, so this map is
+ // byte-identical to the pre-#228 `[o.name, o.package]` map.
+ const packageOf = new Map();
+ for (const o of root.objects()) {
+ const key = o.subType === OBJECT_SUBTYPE_VALUE
+ ? (valueObjectNames.get(o.resolutionKey()) ?? o.name)
+ : o.name;
+ packageOf.set(key, o.package);
+ }
// Auto-detect: is the OPT-IN Hono routes generator in the active suite? If so,
// surface it on every generator's ctx.config so api-docs documents the Hono
@@ -192,6 +216,7 @@ export async function runGen(opts: RunGenOpts): Promise {
pkMap,
relationMap,
packageOf,
+ valueObjectNames,
selfTarget,
entityModuleTarget,
...(config.providedEnumModule !== undefined && { providedEnumModule: config.providedEnumModule }),
diff --git a/server/typescript/packages/codegen-ts/src/templates/drizzle-schema.ts b/server/typescript/packages/codegen-ts/src/templates/drizzle-schema.ts
index c52c916ae..b1cb56d95 100644
--- a/server/typescript/packages/codegen-ts/src/templates/drizzle-schema.ts
+++ b/server/typescript/packages/codegen-ts/src/templates/drizzle-schema.ts
@@ -9,6 +9,7 @@ import {
IDENTITY_ATTR_FIELDS, IDENTITY_ATTR_GENERATION,
GENERATION_INCREMENT, GENERATION_UUID,
FIELD_ATTR_AUTO_SET,
+ FIELD_ATTR_OBJECT_REF,
} from "@metaobjectsdev/metadata";
import { type RenderContext } from "../render-context.js";
import { crossEntitySpecifier, valueObjectModuleSpecifier } from "../import-path.js";
@@ -386,8 +387,17 @@ function renderColumn(
// first."
// Resolve a VO name → an imported type symbol (shared layout/package/extStyle-aware
// helper, so the .$type import matches the field's TS type + Zod schema).
- const voSym = (name: string) =>
- imp(`${name}@${valueObjectModuleSpecifier(name, ctx.packageOf, entityPackage, ctx.outputLayout, ctx.extStyle)}`);
+ // ADR-0044/#228 — resolve the field's @objectRef to the value object's EMITTED
+ // name (bare when unique in the run, package-qualified on a cross-package
+ // short-name collision), resolved package-locally from the FIELD's declaring
+ // package. `name` (the bare dollarTypeRef name) is the byte-identical fallback
+ // when the ref doesn't resolve to an emitted value object.
+ const voSym = (name: string) => {
+ const refRaw = field.attr(FIELD_ATTR_OBJECT_REF);
+ const refPkg = field.parent?.package ?? field.parent?.fileDefaultPackage ?? entityPackage;
+ const emitted = typeof refRaw === "string" ? ctx.resolveValueObjectName(refRaw, refPkg) : name;
+ return imp(`${emitted}@${valueObjectModuleSpecifier(emitted, ctx.packageOf, entityPackage, ctx.outputLayout, ctx.extStyle)}`);
+ };
let dollarTypeSegment: Code | string = "";
if (spec.dollarTypeRef !== undefined) {
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 0384fd8cd..017a34241 100644
--- a/server/typescript/packages/codegen-ts/src/templates/inferred-types.ts
+++ b/server/typescript/packages/codegen-ts/src/templates/inferred-types.ts
@@ -132,6 +132,11 @@ export function renderEnumTypeAliases(entity: MetaObject, ctx?: RenderContext):
// De-duplicate by type-alias name — multiple fields can extend the same abstract enum.
const seen = new Set();
const lines: string[] = [];
+ // ADR-0044/#228 — an inline enum's alias is ``; `` is this
+ // object's EMITTED name so a collision-qualified value object declares (and its
+ // interface references) `AcmeAlphaNoteStatus`, not a bare `NoteStatus`. Entities
+ // and non-colliding value objects keep their bare name (byte-identical).
+ const ownerName = ctx ? ctx.valueObjectEmittedName(entity) : entity.name;
for (const field of entity.fields()) {
if (field.subType !== FIELD_SUBTYPE_ENUM) continue;
@@ -139,7 +144,7 @@ export function renderEnumTypeAliases(entity: MetaObject, ctx?: RenderContext):
const values = enumValues(field);
if (values === undefined) continue;
- const typeName = enumUnionAliasName(entity.name, field);
+ const typeName = enumUnionAliasName(ownerName, field);
if (seen.has(typeName)) continue;
seen.add(typeName);
@@ -235,12 +240,24 @@ export function fieldTsTypeString(ownerName: string, field: MetaField): string {
return field.resolvedIsArray() ? `${scalar}[]` : scalar;
}
+/** ADR-0042 — the package a field's `@objectRef` resolves in: the FIELD's OWN
+ * declaring package (which differs from the owner when the field is inherited via
+ * `extends` from an abstract value-object in another package), falling back to the
+ * owner's package. Mirrors payload-codegen's `collectClosure`. */
+function refPkg(field: MetaField, owner: MetaObject): string | undefined {
+ return field.parent?.package ?? field.parent?.fileDefaultPackage ?? owner.package;
+}
+
/**
* One-line TS type expression for a field on a value-only object.
* Returns a `Code` so cross-module `field.object` refs can be hoisted via
* ts-poet `imp(...)` — matching how the Zod emitter hoists `][InsertSchema`.
*/
function valueObjectFieldType(entity: MetaObject, field: MetaField, ctx?: RenderContext): Code {
+ // ADR-0044/#228 — the owning value-object's EMITTED name (bare when unique in
+ // the run, package-qualified on a cross-package short-name collision). Drives
+ // the inline enum-union alias so it matches the alias declared for this object.
+ const ownerName = ctx ? ctx.valueObjectEmittedName(entity) : entity.name;
// `@dbColumnType: jsonb` (open JSON bag) → `unknown`, in lock-step with
// fieldTsTypeString above and the `z.unknown()` Zod emission.
if (field.attr(FIELD_ATTR_DB_COLUMN_TYPE) === DB_COLUMN_TYPE_JSONB) {
@@ -252,16 +269,19 @@ function valueObjectFieldType(entity: MetaObject, field: MetaField, ctx?: Render
if (field.subType === FIELD_SUBTYPE_OBJECT) {
const ref = field.attr(FIELD_ATTR_OBJECT_REF);
if (typeof ref === "string" && ref.length > 0) {
- // @objectRef may be authored fully-qualified (acme::sales::Brief) or bare; the
- // referenced interface is named by the BARE short name. The import MODULE is
- // resolved through the shared layout/package/extStyle-aware helper (the SAME
- // one the Zod schema + Drizzle .$type<> use) so all three agree. Without a
- // ctx (bare unit-test calls) fall back to the flat same-dir specifier.
- const base = stripPackage(ref);
+ // @objectRef may be authored fully-qualified (acme::sales::Brief) or bare.
+ // ADR-0044/#228 — the referenced interface is named by its EMITTED name
+ // (bare when unique in the run, package-qualified on a cross-package
+ // short-name collision), resolved package-locally from the FIELD's declaring
+ // package. The import MODULE is resolved through the shared
+ // layout/package/extStyle-aware helper (the SAME one the Zod schema +
+ // Drizzle .$type<> use) so all three agree. Without a ctx (bare unit-test
+ // calls) fall back to the bare name + flat same-dir specifier.
+ const refName = ctx ? ctx.resolveValueObjectName(ref, refPkg(field, entity)) : stripPackage(ref);
const moduleSpec = ctx
- ? valueObjectModuleSpecifier(base, ctx.packageOf, entity.package, ctx.outputLayout, ctx.extStyle)
- : `./${base}.js`;
- const refImp = imp(`${base}@${moduleSpec}`);
+ ? valueObjectModuleSpecifier(refName, ctx.packageOf, entity.package, ctx.outputLayout, ctx.extStyle)
+ : `./${refName}.js`;
+ const refImp = imp(`${refName}@${moduleSpec}`);
return field.resolvedIsArray() ? code`${refImp}[]` : code`${refImp}`;
}
return field.resolvedIsArray() ? code`unknown[]` : code`unknown`;
@@ -271,11 +291,11 @@ function valueObjectFieldType(entity: MetaObject, field: MetaField, ctx?: Render
if (field.subType === FIELD_SUBTYPE_MAP) {
const ref = field.attr(FIELD_ATTR_OBJECT_REF);
if (typeof ref === "string" && ref.length > 0) {
- const base = stripPackage(ref);
+ const refName = ctx ? ctx.resolveValueObjectName(ref, refPkg(field, entity)) : stripPackage(ref);
const moduleSpec = ctx
- ? valueObjectModuleSpecifier(base, ctx.packageOf, entity.package, ctx.outputLayout, ctx.extStyle)
- : `./${base}.js`;
- const refImp = imp(`${base}@${moduleSpec}`);
+ ? valueObjectModuleSpecifier(refName, ctx.packageOf, entity.package, ctx.outputLayout, ctx.extStyle)
+ : `./${refName}.js`;
+ const refImp = imp(`${refName}@${moduleSpec}`);
return code`Record`;
}
const vt = field.attr(FIELD_ATTR_VALUE_TYPE);
@@ -287,7 +307,7 @@ function valueObjectFieldType(entity: MetaObject, field: MetaField, ctx?: Render
if (field.subType === FIELD_SUBTYPE_ENUM) {
const values = enumValues(field);
if (values !== undefined) {
- const alias = enumUnionAliasName(entity.name, field);
+ const alias = enumUnionAliasName(ownerName, field);
// FR-019: a shared/provided enum's type lives in another module (./enums or
// the provided module). Use imp() so ts-poet hoists `import { type E }` —
// the local interface can then reference E. Inline enums reference the
@@ -322,6 +342,10 @@ function valueObjectFieldType(entity: MetaObject, field: MetaField, ctx?: Render
export function renderValueObjectInterface(entity: MetaObject, ctx?: RenderContext): Code {
const docs = renderDocsFor(entity);
const docsPrefix = docs ? `${docs}\n` : "";
+ // ADR-0044/#228 — the declared interface name is this value object's EMITTED
+ // name (bare when unique in the run, package-qualified on a cross-package
+ // short-name collision). Byte-identical (bare) when there is no collision.
+ const objName = ctx ? ctx.valueObjectEmittedName(entity) : entity.name;
const lines: Code[] = [];
for (const field of entity.fields()) {
@@ -333,7 +357,7 @@ export function renderValueObjectInterface(entity: MetaObject, ctx?: RenderConte
// joinCode with "\n" interpolates each Code segment on its own line and
// keeps the imp() registrations intact so ts-poet hoists the imports.
- return code`${docsPrefix}export interface ${entity.name} {
+ return code`${docsPrefix}export interface ${objName} {
${joinCode(lines, { on: "\n" })}
}
`;
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 e020cf187..1eb417a4e 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
@@ -63,9 +63,13 @@ export function renderValueObjectFile(obj: MetaObject, apiPrefix = "", ctx?: Ren
...(tphFilterType !== null ? [tphFilterType] : []),
];
const body = joinCode(sections, { on: "\n" }).toString();
+ // ADR-0044/#228 — the hand-edit sidecar name follows this value object's EMITTED
+ // module name (== the generated filename), so the `.extra.ts` hint is correct
+ // even for a collision-qualified value object. Byte-identical (bare) otherwise.
+ const emittedName = ctx ? ctx.valueObjectEmittedName(obj) : obj.name;
const header =
`// ${GENERATED_HEADER} — DO NOT EDIT.\n` +
`// Source metadata: ${obj.name} (${obj.fqn()})\n` +
- `// Customize via ${obj.name}.extra.ts in this directory.\n`;
+ `// Customize via ${emittedName}.extra.ts in this directory.\n`;
return header + body;
}
diff --git a/server/typescript/packages/codegen-ts/src/templates/zod-validators.ts b/server/typescript/packages/codegen-ts/src/templates/zod-validators.ts
index 782d9013b..08ff6ce45 100644
--- a/server/typescript/packages/codegen-ts/src/templates/zod-validators.ts
+++ b/server/typescript/packages/codegen-ts/src/templates/zod-validators.ts
@@ -117,10 +117,11 @@ export function renderTphSubtypeReadSchema(obj: MetaObject, ctx?: RenderContext)
);
}
+ const objName = ctx ? ctx.valueObjectEmittedName(obj) : obj.name;
const docs = renderDocsFor(obj);
const docsPrefix = docs ? `${docs}\n` : "";
return code`
-${docsPrefix}export const ${obj.name}Schema = ${z}.object({
+${docsPrefix}export const ${objName}Schema = ${z}.object({
${joinCode(fieldLines, { on: ",\n" })}
});
`;
@@ -199,7 +200,11 @@ export function renderInsertSchemaOnly(obj: MetaObject, ctx?: RenderContext): Co
}
}
- const insertSchemaName = `${obj.name}InsertSchema`;
+ // ADR-0044/#228 — the schema name follows the value object's EMITTED name
+ // (bare when unique in the run, package-qualified on a cross-package short-name
+ // collision) so importers (entity/extract tiers) resolve the same symbol.
+ const objName = ctx ? ctx.valueObjectEmittedName(obj) : obj.name;
+ const insertSchemaName = `${objName}InsertSchema`;
const docs = renderDocsFor(obj);
const docsPrefix = docs ? `${docs}\n` : "";
@@ -369,9 +374,13 @@ export function renderZodValidators(obj: MetaObject, ctx?: RenderContext): Code
}
}
- const insertSchemaName = `${obj.name}InsertSchema`;
- const updateSchemaName = `${obj.name}UpdateSchema`;
- const preservingSchemaName = `${obj.name}InsertPreservingSchema`;
+ // ADR-0044/#228 — schema + type-alias names follow the object's EMITTED name.
+ // For entities (never in the value-object collision set) and non-colliding value
+ // objects this equals `obj.name` (byte-identical).
+ const objName = ctx ? ctx.valueObjectEmittedName(obj) : obj.name;
+ const insertSchemaName = `${objName}InsertSchema`;
+ const updateSchemaName = `${objName}UpdateSchema`;
+ const preservingSchemaName = `${objName}InsertPreservingSchema`;
const docs = renderDocsFor(obj);
const docsPrefix = docs ? `${docs}\n` : "";
@@ -381,7 +390,7 @@ export function renderZodValidators(obj: MetaObject, ctx?: RenderContext): Code
const preservingBlock = emitPreserving
? code`
-/** Insert-shape for import / restore / replication of ${obj.name}: identical to
+/** Insert-shape for import / restore / replication of ${objName}: identical to
* ${insertSchemaName}, but the @autoSet timestamp columns are written VERBATIM
* (no create-time now() stamp) so the caller's original values are preserved. */
export const ${preservingSchemaName} = ${z}.object({
@@ -398,9 +407,9 @@ ${docsPrefix}export const ${updateSchemaName} = ${z}.object({
${joinCode(updateFieldLines, { on: ",\n" })}
});
-/** Typed patch shape for ${obj.name}: every settable field, optional (FR-035 PATCH). A
- * renamed/dropped field is a compile error at every \`update${obj.name}\` call site. */
-export type ${obj.name}Patch = ${z}.input;${preservingBlock}
+/** Typed patch shape for ${objName}: every settable field, optional (FR-035 PATCH). A
+ * renamed/dropped field is a compile error at every \`update${objName}\` call site. */
+export type ${objName}Patch = ${z}.input;${preservingBlock}
`;
}
@@ -423,6 +432,14 @@ function zodScalarFor(subType: string): string {
return "z.string()"; // string/uuid/date/time/timestamp/decimal/enum on the wire
}
+/** ADR-0042 — the package a field's `@objectRef` resolves in: the FIELD's OWN
+ * declaring package (differs from the owner when the field is inherited via
+ * `extends` from an abstract value-object in another package), falling back to the
+ * owner's package. Mirrors payload-codegen's `collectClosure` + inferred-types. */
+function voRefPkg(field: MetaField, owner: MetaObject): string | undefined {
+ return field.parent?.package ?? field.parent?.fileDefaultPackage ?? owner.package;
+}
+
function zodFieldExpr(field: MetaField, owner?: MetaObject, ctx?: RenderContext): Code {
// `@dbColumnType: jsonb` on a scalar (legal only on field.string) is the
// sanctioned "open JSON bag" escape hatch — a genuinely untyped JSON column
@@ -446,16 +463,19 @@ function zodFieldExpr(field: MetaField, owner?: MetaObject, ctx?: RenderContext)
if (field.subType === FIELD_SUBTYPE_OBJECT) {
const ref = field.attr(FIELD_ATTR_OBJECT_REF);
if (typeof ref === "string" && ref.length > 0) {
- // @objectRef may be authored fully-qualified or bare — the referenced
- // ][InsertSchema is named by the BARE short name. The import MODULE is
- // resolved via the shared layout/package/extStyle-aware helper (the SAME
- // one the field's TS type + Drizzle .$type<> use) so all three agree.
- // Without owner/ctx (bare unit-test calls) fall back to the flat same-dir.
- const refBase = stripPackage(ref);
+ // @objectRef may be authored fully-qualified or bare. ADR-0044/#228 — the
+ // referenced ][InsertSchema is named by the value object's EMITTED name
+ // (bare when unique in the run, package-qualified on a cross-package
+ // short-name collision), resolved package-locally from the FIELD's declaring
+ // package. The import MODULE is resolved via the shared
+ // layout/package/extStyle-aware helper (the SAME one the field's TS type +
+ // Drizzle .$type<> use) so all three agree. Without owner/ctx (bare
+ // unit-test calls) fall back to the bare name + flat same-dir.
+ const refName = (ctx && owner) ? ctx.resolveValueObjectName(ref, voRefPkg(field, owner)) : stripPackage(ref);
const moduleSpec = (ctx && owner)
- ? valueObjectModuleSpecifier(refBase, ctx.packageOf, owner.package, ctx.outputLayout, ctx.extStyle)
- : `./${refBase}.js`;
- const refImp = imp(`${refBase}InsertSchema@${moduleSpec}`);
+ ? valueObjectModuleSpecifier(refName, ctx.packageOf, owner.package, ctx.outputLayout, ctx.extStyle)
+ : `./${refName}.js`;
+ const refImp = imp(`${refName}InsertSchema@${moduleSpec}`);
let base: Code = code`${refImp}`;
if (field.resolvedIsArray()) base = code`z.array(${base})`;
return appendValidatorChain(base, field);
@@ -472,11 +492,11 @@ function zodFieldExpr(field: MetaField, owner?: MetaObject, ctx?: RenderContext)
if (field.subType === FIELD_SUBTYPE_MAP) {
const ref = field.attr(FIELD_ATTR_OBJECT_REF);
if (typeof ref === "string" && ref.length > 0) {
- const refBase = stripPackage(ref);
+ const refName = (ctx && owner) ? ctx.resolveValueObjectName(ref, voRefPkg(field, owner)) : stripPackage(ref);
const moduleSpec = (ctx && owner)
- ? valueObjectModuleSpecifier(refBase, ctx.packageOf, owner.package, ctx.outputLayout, ctx.extStyle)
- : `./${refBase}.js`;
- const refImp = imp(`${refBase}InsertSchema@${moduleSpec}`);
+ ? valueObjectModuleSpecifier(refName, ctx.packageOf, owner.package, ctx.outputLayout, ctx.extStyle)
+ : `./${refName}.js`;
+ const refImp = imp(`${refName}InsertSchema@${moduleSpec}`);
return appendValidatorChain(code`z.record(z.string(), ${refImp})`, field);
}
const vt = field.attr(FIELD_ATTR_VALUE_TYPE);
diff --git a/server/typescript/packages/codegen-ts/test/entity-tier-collision.test.ts b/server/typescript/packages/codegen-ts/test/entity-tier-collision.test.ts
new file mode 100644
index 000000000..c230da01c
--- /dev/null
+++ b/server/typescript/packages/codegen-ts/test/entity-tier-collision.test.ts
@@ -0,0 +1,201 @@
+import { describe, test, expect, beforeEach, afterEach } from "bun:test";
+import { mkdtempSync, rmSync, readdirSync, readFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { runGen } from "../src/runner.js";
+import { defineConfig } from "../src/metaobjects-config.js";
+import { entityFile } from "../src/generators/entity-file.js";
+import { MetaDataLoader, InMemoryStringSource } from "@metaobjectsdev/metadata";
+
+// ADR-0044 / #228 — the ENTITY tier brings the per-value-object entity module
+// (interface name + output filename) into collision scope, keyed by the RUN's
+// emitted `object.value` SET (NOT a payload closure). Two same-short-name
+// `object.value`s across packages must emit DISTINCT interfaces to DISTINCT
+// module paths, and every value-object-routed reference (Zod `][InsertSchema`,
+// Drizzle `.$type<>()`, inferred-types field.object) must use the qualified name.
+
+async function loadMultiPackageRoot(files: { package: string; children: unknown[] }[]) {
+ const sources = files.map(
+ (f) => new InMemoryStringSource(JSON.stringify({ "metadata.root": { package: f.package, children: f.children } })),
+ );
+ const res = await new MetaDataLoader().load(sources);
+ expect(res.errors).toEqual([]);
+ return res.root;
+}
+
+let tmp: string;
+beforeEach(() => { tmp = mkdtempSync(join(tmpdir(), "entity-collision-")); });
+afterEach(() => { rmSync(tmp, { recursive: true, force: true }); });
+
+async function genFiles(root: Awaited>): Promise]
*/
protected void emitMapperMethods(StringBuilder src, MetaObject rootVo,
- MetaDataLoader loader, String rootPayloadClass) {
+ MetaDataLoader loader, String rootPayloadClass,
+ Map nameMap) {
Set emitted = new LinkedHashSet<>();
- emitMapper(src, rootVo, loader, rootPayloadClass, emitted);
+ emitMapper(src, rootVo, loader, rootPayloadClass, emitted, nameMap);
}
protected void emitMapper(StringBuilder src, MetaObject vo, MetaDataLoader loader,
- String payloadClass, Set emitted) {
+ String payloadClass, Set emitted, Map nameMap) {
if (!emitted.add(vo.getName())) {
- return; // already emitted (dedupe + cycle guard)
+ return; // already emitted (dedupe + cycle guard) — vo.getName() is already the
+ // FQN (Java's MetaObject.getName() is package-qualified), so this key
+ // is never bare — a cross-package same-short-name collision does NOT
+ // silently drop the second VO's mapper (unlike the #219/#244 bare-key
+ // dedupe bug other ports hit here).
}
// Discover nested mappers to emit AFTER this one (declaration order is irrelevant
@@ -285,7 +303,7 @@ protected void emitMapper(StringBuilder src, MetaObject vo, MetaDataLoader loade
List fields = new ArrayList<>(vo.getMetaFields());
for (int i = 0; i < fields.size(); i++) {
MetaField> field = fields.get(i);
- String arg = mapperArgForField(field, vo, payloadClass, loader, nestedVos);
+ String arg = mapperArgForField(field, vo, payloadClass, loader, nestedVos, nameMap);
body.append(" ").append(arg);
if (i < fields.size() - 1) body.append(',');
body.append('\n');
@@ -296,8 +314,8 @@ protected void emitMapper(StringBuilder src, MetaObject vo, MetaDataLoader loade
// Recurse into nested payloads (post-order, deduped).
for (MetaObject nested : nestedVos) {
- String nestedClass = nestedPayloadClass(nested);
- emitMapper(src, nested, loader, nestedClass, emitted);
+ String nestedClass = nestedPayloadClass(nested, nameMap);
+ emitMapper(src, nested, loader, nestedClass, emitted, nameMap);
}
}
@@ -309,7 +327,8 @@ protected void emitMapper(StringBuilder src, MetaObject vo, MetaDataLoader loade
*/
@SuppressWarnings("rawtypes")
protected String mapperArgForField(MetaField> field, MetaObject owner, String payloadClass,
- MetaDataLoader loader, List nestedVos) {
+ MetaDataLoader loader, List nestedVos,
+ Map nameMap) {
String name = field.getName();
// Nested object / array-of-objects (but NOT enum, which is a string-backed scalar).
@@ -318,7 +337,7 @@ protected String mapperArgForField(MetaField> field, MetaObject owner, String
MetaObject target = MetaDataUtil.getObjectRef(field);
if (target != null && MetaObject.SUBTYPE_VALUE.equals(target.getSubType())) {
nestedVos.add(target);
- String nestedClass = nestedPayloadClass(target);
+ String nestedClass = nestedPayloadClass(target, nameMap);
if (field.isArrayType()) {
// List: map each element Map; the assembled value is a List.
// from is a static method in scope within this generated parser class.
@@ -365,8 +384,22 @@ protected String mapperArgForField(MetaField> field, MetaObject owner, String
return "ExtractMap.asString(d, \"" + name + "\")";
}
- /** {@code Payload} — mirrors {@link SpringPayloadGenerator}'s nested naming. */
- protected static String nestedPayloadClass(MetaObject vo) {
+ /**
+ * {@code Payload} — consults the ADR-0044 collision-scoped
+ * {@code nameMap} ({@link SpringPayloadGenerator#computePayloadNameMap}) FIRST (#228):
+ * a nested VO whose bare short name is unique in the run's payload domain keeps its
+ * bare {@code Payload} derivation (byte-identical to pre-#228 output); a
+ * cross-package short-name collision resolves to the SAME package-qualified name
+ * {@link SpringPayloadGenerator} actually emitted (e.g. {@code AcmeAlphaNotePayload}) —
+ * otherwise this generator would reference a bare {@code NotePayload} class the payload
+ * generator never emits under collision (a compile error: two same-named
+ * {@code fromPayload} mapper methods would ALSO collide). Falls back to the bare
+ * derivation when {@code vo} isn't in the map (the primary VO — template-named, outside
+ * the map's domain — or a caller without a precomputed map).
+ */
+ protected static String nestedPayloadClass(MetaObject vo, Map nameMap) {
+ String mapped = nameMap.get(vo.getName());
+ if (mapped != null) return mapped;
return SpringNaming.payloadName(SpringNaming.splitFqn(vo.getName())[1]);
}
@@ -417,15 +450,16 @@ private static String escapeJava(String value) {
return sb.toString();
}
- /** Resolve {@code @payloadRef} to its {@code object.value} target (rejects entities). */
- protected static MetaObject resolveValueObject(MetaDataLoader loader, String ref) {
- for (MetaObject obj : loader.getMetaObjects()) {
- if (!MetaObject.SUBTYPE_VALUE.equals(obj.getSubType())) continue;
- if (obj.getName().equals(ref)) return obj;
- String[] split = SpringNaming.splitFqn(obj.getName());
- if (split[1].equals(ref)) return obj;
- }
- return null;
+ /**
+ * Resolve {@code @payloadRef} to its {@code object.value} target (rejects entities)
+ * under the ADR-0042 package-local contract (#228) — was a package-BLIND bare-name
+ * scan over every loaded {@code object.value} (first match wins, load-order-dependent);
+ * now delegates to the shared {@link SpringNaming#resolveValueObjectRef} so a bare
+ * {@code @payloadRef} binds the referrer's OWN package first, agreeing with the
+ * loader's own {@code ValidationPhase} validation of the same ref.
+ */
+ protected static MetaObject resolveValueObject(MetaDataLoader loader, String ref, String referrerPkg) {
+ return SpringNaming.resolveValueObjectRef(loader, ref, referrerPkg);
}
// === MultiFileDirectGeneratorBase abstract-method stubs ====================
diff --git a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringOutputPromptGenerator.java b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringOutputPromptGenerator.java
index e6ab7286d..b510f6ea3 100644
--- a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringOutputPromptGenerator.java
+++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringOutputPromptGenerator.java
@@ -113,14 +113,16 @@ public static boolean appliesTo(MetaData node, MetaDataLoader loader) {
if (!supported) return false;
String payloadRef = template.getPayloadRef();
if (payloadRef == null || payloadRef.isEmpty()) return false;
- return resolveValueObject(loader, payloadRef) != null;
+ return resolveValueObject(loader, payloadRef,
+ com.metaobjects.util.MetaDataUtil.findPackageForMetaData(template)) != null;
}
protected void emit(MetaTemplate template, MetaDataLoader loader, Path outRoot) {
if (!appliesTo(template, loader)) {
return; // unsupported @format, missing @payloadRef, or not a VO
}
- MetaObject payloadVo = resolveValueObject(loader, template.getPayloadRef());
+ MetaObject payloadVo = resolveValueObject(loader, template.getPayloadRef(),
+ com.metaobjects.util.MetaDataUtil.findPackageForMetaData(template));
String[] split = SpringNaming.splitFqn(template.getName());
String templatePkg = split[0];
@@ -170,15 +172,14 @@ protected void emit(MetaTemplate template, MetaDataLoader loader, Path outRoot)
}
}
- /** Resolve {@code @payloadRef} to its {@code object.value} target (rejects entities). */
- protected static MetaObject resolveValueObject(MetaDataLoader loader, String ref) {
- for (MetaObject obj : loader.getMetaObjects()) {
- if (!MetaObject.SUBTYPE_VALUE.equals(obj.getSubType())) continue;
- if (obj.getName().equals(ref)) return obj;
- String[] split = SpringNaming.splitFqn(obj.getName());
- if (split[1].equals(ref)) return obj;
- }
- return null;
+ /**
+ * Resolve {@code @payloadRef} to its {@code object.value} target (rejects entities)
+ * under the ADR-0042 package-local contract (#228) — was a package-BLIND bare-name
+ * scan (first match wins, load-order-dependent); now delegates to the shared
+ * {@link SpringNaming#resolveValueObjectRef}.
+ */
+ protected static MetaObject resolveValueObject(MetaDataLoader loader, String ref, String referrerPkg) {
+ return SpringNaming.resolveValueObjectRef(loader, ref, referrerPkg);
}
// === MultiFileDirectGeneratorBase abstract-method stubs ====================
diff --git a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringPayloadGenerator.java b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringPayloadGenerator.java
index 80a4e2d28..f63659001 100644
--- a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringPayloadGenerator.java
+++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringPayloadGenerator.java
@@ -155,14 +155,20 @@ public void execute(MetaDataLoader loader) {
* {@code AcmeAlphaNotePayload}). A still-colliding derived name fails loud with
* {@link #ERR_PAYLOAD_NAME_COLLISION}. Pure function of the templates — never of
* emission order.
+ *
+ * public static (promoted from {@code protected} instance) so
+ * {@link SpringOutputParserGenerator} — a sibling generator consuming the SAME
+ * {@code @payloadRef} closure — reuses this ONE name map rather than re-deriving
+ * naming (#228: extract/output-parser tier collision-scoped naming).
*/
- protected Map computePayloadNameMap(List templates, MetaDataLoader loader) {
+ public static Map computePayloadNameMap(List templates, MetaDataLoader loader) {
// FQN -> output package (first reaching template in sorted order wins, matching
// the run-wide dedupe below). The primary VO is template-named, so excluded.
Map voOutPkg = new LinkedHashMap<>();
List orderedFqns = new ArrayList<>();
for (MetaTemplate tmpl : templates) {
- MetaObject vo = resolveValueObject(loader, tmpl.getPayloadRef());
+ MetaObject vo = resolveValueObject(loader, tmpl.getPayloadRef(),
+ com.metaobjects.util.MetaDataUtil.findPackageForMetaData(tmpl));
if (vo == null) continue;
String nestedPkg = SpringNaming.promptsPackage(SpringNaming.splitFqn(tmpl.getName())[0]);
Set seen = new HashSet<>();
@@ -212,8 +218,11 @@ protected Map computePayloadNameMap(List templates
* assigning each not-yet-seen target VO to {@code outPkg} (first reaching
* template wins) and recording it in {@code orderedFqns}. {@code seen} is seeded
* with the primary VO's FQN and doubles as the cycle guard.
+ *
+ * public static (promoted from {@code protected} instance, #228) — see
+ * {@link #computePayloadNameMap}.
*/
- protected void collectNestedClosure(MetaObject vo,
+ public static void collectNestedClosure(MetaObject vo,
MetaDataLoader loader,
String outPkg,
Map voOutPkg,
@@ -239,8 +248,11 @@ protected void collectNestedClosure(MetaObject vo,
* {@link #resolveCollectionType} ({@code origin.collection @via}) EXACTLY, so the
* closure walk and the emission walk agree on the target set. Passthrough /
* aggregate origins yield scalar types (no nested record).
+ *
+ * public static (promoted from {@code protected} instance, #228) — see
+ * {@link #computePayloadNameMap}.
*/
- protected MetaObject nestedTargetOf(MetaField> field, MetaDataLoader loader) {
+ public static MetaObject nestedTargetOf(MetaField> field, MetaDataLoader loader) {
MetaOrigin origin = firstOriginChild(field);
if (origin instanceof CollectionOrigin co) {
String via = co.getVia();
@@ -273,8 +285,11 @@ protected MetaObject nestedTargetOf(MetaField> field, MetaDataLoader loader) {
* {@code ::}->{@code .} converted by {@link SpringNaming#splitFqn}), concatenate,
* append the bare {@code shortName} ({@code "acme.alpha"} + {@code "Note"} ->
* {@code "AcmeAlphaNote"}). A root-level (empty-package) node keeps its bare name.
+ *
+ *
public static (widened from package-private-visible {@code protected
+ * static}, #228) — see {@link #computePayloadNameMap}.
*/
- protected static String packageQualifiedName(String javaPkg, String shortName) {
+ public static String packageQualifiedName(String javaPkg, String shortName) {
if (javaPkg == null || javaPkg.isEmpty()) return shortName;
StringBuilder sb = new StringBuilder();
for (String seg : javaPkg.split("\\.")) {
@@ -295,7 +310,8 @@ public static boolean appliesTo(MetaData node, MetaDataLoader loader) {
if (!(node instanceof MetaTemplate template)) return false;
String payloadRef = template.getPayloadRef();
if (payloadRef == null || payloadRef.isEmpty()) return false;
- return resolveValueObject(loader, payloadRef) != null;
+ return resolveValueObject(loader, payloadRef,
+ com.metaobjects.util.MetaDataUtil.findPackageForMetaData(template)) != null;
}
protected void emit(MetaTemplate template, MetaDataLoader loader, Path outRoot,
@@ -303,7 +319,8 @@ protected void emit(MetaTemplate template, MetaDataLoader loader, Path outRoot,
if (!appliesTo(template, loader)) {
return; // missing @payloadRef, or not a VO — same contract as Kotlin / C# / Python
}
- MetaObject payloadVo = resolveValueObject(loader, template.getPayloadRef());
+ MetaObject payloadVo = resolveValueObject(loader, template.getPayloadRef(),
+ com.metaobjects.util.MetaDataUtil.findPackageForMetaData(template));
String[] split = SpringNaming.splitFqn(template.getName());
String templatePkg = split[0];
@@ -673,11 +690,17 @@ protected static MetaObject resolveObjectByShortOrFqn(MetaDataLoader loader, Str
return null;
}
- /** Resolve {@code @payloadRef} to its {@code object.value} target (rejects entities). */
- public static MetaObject resolveValueObject(MetaDataLoader loader, String ref) {
- MetaObject obj = resolveObjectByShortOrFqn(loader, ref);
- if (obj == null) return null;
- return MetaObject.SUBTYPE_VALUE.equals(obj.getSubType()) ? obj : null;
+ /**
+ * Resolve {@code @payloadRef} to its {@code object.value} target (rejects entities)
+ * under the ADR-0042 package-local contract (#228): a bare ref resolves in
+ * {@code referrerPkg} first, else root-level; an FQN ref matches exactly. Distinct
+ * from {@link #resolveObjectByShortOrFqn} (used only by the {@code origin.@from}/
+ * {@code @of}/{@code @via} dotted-ref walk above, a different ref kind out of this
+ * fix's scope) — {@code @payloadRef} is the one every port's canonical resolver
+ * gates, matching the loader's own {@code ValidationPhase} validation of the same ref.
+ */
+ public static MetaObject resolveValueObject(MetaDataLoader loader, String ref, String referrerPkg) {
+ return SpringNaming.resolveValueObjectRef(loader, ref, referrerPkg);
}
/**
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 48aaade2a..e3ead4ee3 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
@@ -134,7 +134,8 @@ public static boolean appliesTo(MetaData node, MetaDataLoader loader) {
if (!TemplateConstants.SUBTYPE_OUTPUT.equals(template.getSubType())) return false;
String payloadRef = template.getPayloadRef();
if (payloadRef == null || payloadRef.isEmpty()) return false;
- return resolveValueObject(loader, payloadRef) != null;
+ return resolveValueObject(loader, payloadRef,
+ com.metaobjects.util.MetaDataUtil.findPackageForMetaData(template)) != null;
}
protected void emit(MetaTemplate template, MetaDataLoader loader, Path outRoot,
@@ -142,7 +143,8 @@ protected void emit(MetaTemplate template, MetaDataLoader loader, Path outRoot,
if (!appliesTo(template, loader)) {
return; // missing @payloadRef, or not a VO — same contract as SpringPayloadGenerator
}
- MetaObject payloadVo = resolveValueObject(loader, template.getPayloadRef());
+ MetaObject payloadVo = resolveValueObject(loader, template.getPayloadRef(),
+ com.metaobjects.util.MetaDataUtil.findPackageForMetaData(template));
String[] split = SpringNaming.splitFqn(template.getName());
String templatePkg = split[0];
@@ -408,15 +410,16 @@ private static String attr(MetaTemplate template, String attr) {
return template.getMetaAttr(attr).getValueAsString();
}
- /** Resolve {@code @payloadRef} to its {@code object.value} target (rejects entities). */
- protected static MetaObject resolveValueObject(MetaDataLoader loader, String ref) {
- for (MetaObject obj : loader.getMetaObjects()) {
- if (!MetaObject.SUBTYPE_VALUE.equals(obj.getSubType())) continue;
- if (obj.getName().equals(ref)) return obj;
- String[] split = SpringNaming.splitFqn(obj.getName());
- if (split[1].equals(ref)) return obj;
- }
- return null;
+ /**
+ * Resolve {@code @payloadRef} to its {@code object.value} target (rejects entities)
+ * under the ADR-0042 package-local contract (#228) — was a package-BLIND bare-name
+ * scan (first match wins, load-order-dependent); now delegates to the shared
+ * {@link SpringNaming#resolveValueObjectRef}. Distinct from this file's OWN
+ * {@link #resolveNestedObjectRef} (the {@code @objectRef} field-tree walk), which was
+ * ALREADY package-local-correct.
+ */
+ protected static MetaObject resolveValueObject(MetaDataLoader loader, String ref, String referrerPkg) {
+ return SpringNaming.resolveValueObjectRef(loader, ref, referrerPkg);
}
/** Java string-literal quoting with the common escapes. */
diff --git a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/GeneratedTraceHelperCompileRunTest.java b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/GeneratedTraceHelperCompileRunTest.java
index d28b1a7a2..af00479e3 100644
--- a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/GeneratedTraceHelperCompileRunTest.java
+++ b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/GeneratedTraceHelperCompileRunTest.java
@@ -209,6 +209,64 @@ public void skipsEntityNotDerivedFromLlmCallBase() throws Exception {
Files.exists(gen.resolve("acme/ai/PlainEntityTraceHelper.java")));
}
+ /**
+ * #228 checkpoint 4 — {@code resolveValueObject}'s pre-fix bare-tail-fallback bug
+ * (the #219/#244 "wrong node despite a VALID FQN target" pattern): the old
+ * implementation checked, PER CANDIDATE in loader iteration order, "does this
+ * object's bare short name equal the ref's bare tail?" — so a same-bare-named
+ * DECOY {@code object.value} visited BEFORE the true FQN target would win
+ * immediately, even though the correctly-FQN-qualified target also exists and
+ * loads later. Package {@code acme::other} declares a decoy {@code GreetResponse}
+ * (loaded FIRST); {@code acme::ai} declares its OWN {@code GreetResponse} and an
+ * FQN {@code @responseRef: "acme::ai::GreetResponse"} that unambiguously names it.
+ * Asserts the generated helper derives its typed result record from {@code acme::ai}'s
+ * shape ({@code greeting}/{@code score}) — never the decoy's ({@code otherField}).
+ */
+ @Test
+ public void responseRefFqnBindsOwnPackageNotABareTailDecoyLoadedFirst() throws Exception {
+ String decoyMeta = "{ \"metadata.root\": {"
+ + " \"package\": \"acme::other\","
+ + " \"children\": ["
+ + " { \"object.value\": { \"name\": \"GreetResponse\", \"children\": ["
+ + " { \"field.string\": { \"name\": \"otherField\", \"@required\": true } }"
+ + " ]}}"
+ + " ]"
+ + "}}";
+
+ MetaDataLoader loader = new MetaDataLoader(
+ LoaderOptions.create(false, false, true),
+ MetaDataLoader.SUBTYPE_MANUAL, "trace-responseref-fqn");
+ loader.init();
+ // Decoy loads FIRST — under the pre-fix bare-tail-fallback bug this would win.
+ loader.load(List.of(
+ new InMemoryStringSource(decoyMeta, "trace-responseref-fqn/meta.other.json"),
+ new InMemoryStringSource(META, "trace-responseref-fqn/meta.ai.json")));
+
+ Path gen = tmp.newFolder("gen-responseref-fqn").toPath();
+ LlmTraceHelperGenerator generator = new LlmTraceHelperGenerator();
+ Map args = new HashMap<>();
+ args.put("outputDir", gen.toString());
+ generator.setArgs(args);
+ generator.execute(loader);
+
+ Path helper = gen.resolve("acme/ai/GreetingCallTraceHelper.java");
+ assertTrue("GreetingCallTraceHelper.java must be emitted at " + helper, Files.exists(helper));
+ String src = Files.readString(helper);
+
+ // The baked FQN string is the load-bearing proof: LlmTraceHelperGenerator bakes
+ // the RESOLVED responseVo's OWN name (not the raw @responseRef attr verbatim), so
+ // a pre-fix bare-tail-fallback mis-resolution to the decoy would have baked
+ // "acme::other::GreetResponse" here instead.
+ assertTrue("must resolve + bake acme::ai's OWN GreetResponse FQN; saw:\n" + src,
+ src.contains("getMetaObjectByName(\"acme::ai::GreetResponse\")"));
+ assertFalse("must NEVER bind/bake the decoy acme::other::GreetResponse; saw:\n" + src,
+ src.contains("acme::other") || src.contains("otherField"));
+
+ // Compile it too — proves the resolved MetaObject is a real, loadable node
+ // (not just a text match), same rigor as the other tests in this file.
+ compileGenerated(gen);
+ }
+
// -----------------------------------------------------------------------------------------
// helpers
// -----------------------------------------------------------------------------------------
diff --git a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/OutputParserExtractTierCollisionTest.java b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/OutputParserExtractTierCollisionTest.java
new file mode 100644
index 000000000..bb47ad0fc
--- /dev/null
+++ b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/OutputParserExtractTierCollisionTest.java
@@ -0,0 +1,266 @@
+package com.metaobjects.generator.spring;
+
+import com.metaobjects.loader.LoaderOptions;
+import com.metaobjects.loader.MetaDataLoader;
+import com.metaobjects.loader.uri.URIHelper;
+import com.metaobjects.registry.SharedRegistryTestBase;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+
+import java.net.URI;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * #228 — Java port of the extract/output-parser tier collision-scoped naming fix.
+ * {@link SpringOutputParserGenerator} now consumes {@link SpringPayloadGenerator}'s
+ * OWN ADR-0044 name map (never re-derives naming) so a cross-package short-name
+ * collision on a NESTED {@code field.object} target VO gets the SAME
+ * package-qualified record name the payload tier emits — a bare {@code NotePayload}
+ * reference would either be a dangling class reference or (worse) a duplicate-method
+ * compile error when two colliding VOs both derive {@code fromNotePayload(...)}.
+ *
+ * Also covers the ADR-0042 build-time {@code @payloadRef} resolver fix
+ * (checkpoint 3): {@code resolveValueObject} was previously a package-BLIND
+ * bare-name-anywhere scan (first match in load order wins); it now resolves in the
+ * referring template's OWN package first, matching the loader's own
+ * {@code ValidationPhase} validation of the same ref.
+ */
+public class OutputParserExtractTierCollisionTest extends SharedRegistryTestBase {
+
+ @Rule
+ public TemporaryFolder tempFolder = new TemporaryFolder();
+
+ // -------------------------------------------------------------------------
+ // Step 1 (brief) — shared xpkg-collision-json corpus: nested field.object
+ // collision (acme::alpha::Note / acme::beta::Note), both reachable from one
+ // payload (Digest) via FQN @objectRef.
+ // -------------------------------------------------------------------------
+
+ @Test
+ public void xpkgCollisionJsonEmitsDistinctMappersForBothCollidingNestedVos() throws Exception {
+ Path corpus = findCorpus();
+ assertTrue("shared corpus fixtures/template-output-render-conformance must be reachable",
+ corpus != null && Files.exists(corpus.resolve("xpkg-collision-json/meta.app.json")));
+ Path xpkg = corpus.resolve("xpkg-collision-json");
+
+ Path outDir = tempFolder.newFolder("outputparser-xpkg").toPath();
+ MetaDataLoader loader = loadMultiFile("xpkg-op",
+ xpkg.resolve("meta.alpha.json"),
+ xpkg.resolve("meta.beta.json"),
+ xpkg.resolve("meta.app.json"));
+
+ SpringOutputParserGenerator gen = new SpringOutputParserGenerator();
+ Map args = new HashMap<>();
+ args.put("outputDir", outDir.toString());
+ gen.setArgs(args);
+ gen.execute(loader);
+
+ Path parser = outDir.resolve("acme/app/prompts/DigestDocParser.java");
+ assertTrue("expected DigestDocParser.java at " + parser, Files.exists(parser));
+ String src = Files.readString(parser);
+
+ // Both colliding nested VOs get their OWN distinct, collision-scoped mapper —
+ // never the bare `NotePayload` the payload generator no longer emits under
+ // collision, and never a dropped/clobbered second mapper.
+ assertTrue("expected a fromAcmeAlphaNotePayload mapper; saw:\n" + src,
+ src.contains("private static AcmeAlphaNotePayload fromAcmeAlphaNotePayload(java.util.Map d)"));
+ assertTrue("expected a fromAcmeBetaNotePayload mapper; saw:\n" + src,
+ src.contains("private static AcmeBetaNotePayload fromAcmeBetaNotePayload(java.util.Map d)"));
+ assertFalse("must NEVER reference/emit the shadowed bare fromNotePayload mapper; saw:\n" + src,
+ src.contains("fromNotePayload("));
+ assertFalse("must NEVER reference the shadowed bare NotePayload type; saw:\n" + src,
+ src.contains("NotePayload fromNotePayload") || src.contains(" NotePayload)"));
+
+ // The root mapper's fromAlpha/fromBeta fields route to their OWN qualified mapper.
+ assertTrue("fromAlpha field must recurse into fromAcmeAlphaNotePayload; saw:\n" + src,
+ src.contains("fromAcmeAlphaNotePayload(asMap(d.get(\"fromAlpha\")))"));
+ assertTrue("fromBeta field must recurse into fromAcmeBetaNotePayload; saw:\n" + src,
+ src.contains("fromAcmeBetaNotePayload(asMap(d.get(\"fromBeta\")))"));
+ }
+
+ // -------------------------------------------------------------------------
+ // No-churn: a non-colliding nested VO keeps its bare mapper name/type — proves
+ // the nameMap consultation is a no-op absent a collision (byte-identical to
+ // pre-#228 output).
+ // -------------------------------------------------------------------------
+
+ private static final String NO_CHURN_FIXTURE = """
+ {
+ "metadata.root": { "package": "acme::ai", "children": [
+ { "object.value": { "name": "Detail", "children": [
+ { "field.string": { "name": "note", "@required": true } }
+ ] } },
+ { "object.value": { "name": "WidgetOut", "children": [
+ { "field.string": { "name": "title", "@required": true } },
+ { "field.object": { "name": "detail", "@objectRef": "Detail" } }
+ ] } },
+ { "template.output": {
+ "name": "WidgetDoc",
+ "@payloadRef": "WidgetOut",
+ "@textRef": "widget/doc",
+ "@format": "json"
+ } }
+ ] }
+ }
+ """;
+
+ @Test
+ public void noChurnNonCollidingNestedVoKeepsBareMapperName() throws Exception {
+ Path outDir = tempFolder.newFolder("outputparser-nochurn").toPath();
+ Path workspace = tempFolder.newFolder("outputparser-nochurn-fx").toPath();
+ MetaDataLoader loader = SpringTestFixtures.loadFixture(workspace, "nochurn", NO_CHURN_FIXTURE);
+
+ SpringOutputParserGenerator gen = new SpringOutputParserGenerator();
+ Map args = new HashMap<>();
+ args.put("outputDir", outDir.toString());
+ gen.setArgs(args);
+ gen.execute(loader);
+
+ Path parser = outDir.resolve("acme/ai/prompts/WidgetDocParser.java");
+ assertTrue("expected WidgetDocParser.java at " + parser, Files.exists(parser));
+ String src = Files.readString(parser);
+
+ assertTrue("non-colliding nested VO must keep its BARE mapper; saw:\n" + src,
+ src.contains("private static DetailPayload fromDetailPayload(java.util.Map d)"));
+ assertTrue("detail field must recurse into the bare fromDetailPayload; saw:\n" + src,
+ src.contains("fromDetailPayload(asMap(d.get(\"detail\")))"));
+ assertFalse("must NOT package-qualify a non-colliding VO", src.contains("AcmeAiDetailPayload"));
+ }
+
+ // -------------------------------------------------------------------------
+ // Checkpoint 3 — build-time @payloadRef resolver: a BARE @payloadRef that
+ // cross-package-collides on its OWN name must bind the referring template's
+ // OWN package, regardless of load order (was package-blind, first-match-wins).
+ // -------------------------------------------------------------------------
+
+ private static String alphaReportJson() {
+ return """
+ { "metadata.root": { "package": "acme::alpha", "children": [
+ { "object.value": { "name": "Report", "children": [
+ { "field.string": { "name": "alphaVal", "@required": true } }
+ ] } },
+ { "template.output": {
+ "name": "ReportDocAlpha",
+ "@payloadRef": "Report",
+ "@textRef": "report/alpha",
+ "@format": "json"
+ } }
+ ] } }
+ """;
+ }
+
+ private static String betaReportJson() {
+ return """
+ { "metadata.root": { "package": "acme::beta", "children": [
+ { "object.value": { "name": "Report", "children": [
+ { "field.string": { "name": "betaVal", "@required": true } }
+ ] } },
+ { "template.output": {
+ "name": "ReportDocBeta",
+ "@payloadRef": "Report",
+ "@textRef": "report/beta",
+ "@format": "json"
+ } }
+ ] } }
+ """;
+ }
+
+ @Test
+ public void barePayloadRefCollisionBindsOwnPackage_alphaLoadedFirst() throws Exception {
+ assertBarePayloadRefBindsOwnPackage(true);
+ }
+
+ @Test
+ public void barePayloadRefCollisionBindsOwnPackage_betaLoadedFirst() throws Exception {
+ assertBarePayloadRefBindsOwnPackage(false);
+ }
+
+ private void assertBarePayloadRefBindsOwnPackage(boolean alphaFirst) throws Exception {
+ Path workspace = tempFolder.newFolder("bare-payloadref-" + alphaFirst).toPath();
+ Path alphaFile = workspace.resolve("meta.alpha.json");
+ Path betaFile = workspace.resolve("meta.beta.json");
+ Files.writeString(alphaFile, alphaReportJson());
+ Files.writeString(betaFile, betaReportJson());
+
+ MetaDataLoader loader = alphaFirst
+ ? loadMultiFile("bare-" + alphaFirst, alphaFile, betaFile)
+ : loadMultiFile("bare-" + alphaFirst, betaFile, alphaFile);
+
+ Path outDir = tempFolder.newFolder("bare-payloadref-out-" + alphaFirst).toPath();
+
+ // SpringPayloadGenerator: each template's record must carry its OWN
+ // package's field, never the other's, regardless of load order.
+ SpringPayloadGenerator payloadGen = new SpringPayloadGenerator();
+ Map args = new HashMap<>();
+ args.put("outputDir", outDir.toString());
+ payloadGen.setArgs(args);
+ payloadGen.execute(loader);
+
+ String alphaPayloadSrc = Files.readString(outDir.resolve("acme/alpha/prompts/ReportDocAlphaPayload.java"));
+ String betaPayloadSrc = Files.readString(outDir.resolve("acme/beta/prompts/ReportDocBetaPayload.java"));
+ assertTrue("ReportDocAlphaPayload must carry alphaVal (own package); saw:\n" + alphaPayloadSrc,
+ alphaPayloadSrc.contains("String alphaVal"));
+ assertFalse("ReportDocAlphaPayload must NOT carry betaVal (wrong package); saw:\n" + alphaPayloadSrc,
+ alphaPayloadSrc.contains("betaVal"));
+ assertTrue("ReportDocBetaPayload must carry betaVal (own package); saw:\n" + betaPayloadSrc,
+ betaPayloadSrc.contains("String betaVal"));
+ assertFalse("ReportDocBetaPayload must NOT carry alphaVal (wrong package); saw:\n" + betaPayloadSrc,
+ betaPayloadSrc.contains("alphaVal"));
+
+ // SpringOutputParserGenerator: same resolver, same guarantee — the generated
+ // mapper for each template's OWN root payload must read its OWN field name.
+ SpringOutputParserGenerator parserGen = new SpringOutputParserGenerator();
+ parserGen.setArgs(args);
+ parserGen.execute(loader);
+
+ String alphaParserSrc = Files.readString(outDir.resolve("acme/alpha/prompts/ReportDocAlphaParser.java"));
+ String betaParserSrc = Files.readString(outDir.resolve("acme/beta/prompts/ReportDocBetaParser.java"));
+ assertTrue("ReportDocAlphaParser's mapper must read alphaVal; saw:\n" + alphaParserSrc,
+ alphaParserSrc.contains("ExtractMap.asString(d, \"alphaVal\")"));
+ assertFalse("ReportDocAlphaParser's mapper must NOT read betaVal; saw:\n" + alphaParserSrc,
+ alphaParserSrc.contains("betaVal"));
+ assertTrue("ReportDocBetaParser's mapper must read betaVal; saw:\n" + betaParserSrc,
+ betaParserSrc.contains("ExtractMap.asString(d, \"betaVal\")"));
+ assertFalse("ReportDocBetaParser's mapper must NOT read alphaVal; saw:\n" + betaParserSrc,
+ betaParserSrc.contains("alphaVal"));
+ }
+
+ // -------------------------------------------------------------------------
+ // Helpers (mirrors SpringPayloadGeneratorTest's private helpers of the same name).
+ // -------------------------------------------------------------------------
+
+ /** Walk up from {@code user.dir} to the repo-root shared corpus, or {@code null}. */
+ private static Path findCorpus() {
+ Path p = Paths.get(System.getProperty("user.dir")).toAbsolutePath();
+ while (p != null && !Files.exists(p.resolve("fixtures/template-output-render-conformance"))) {
+ p = p.getParent();
+ }
+ return p != null ? p.resolve("fixtures/template-output-render-conformance") : null;
+ }
+
+ /** Load several metadata files into one merged loader (multi-package fixtures), in the
+ * EXACT order given (MetaDataLoader does not re-sort an explicit URI list). */
+ private MetaDataLoader loadMultiFile(String baseName, Path... files) throws Exception {
+ List uris = new ArrayList<>();
+ for (Path f : files) {
+ uris.add(URIHelper.toURI("model:file:" + f.toAbsolutePath().toString().replace('\\', '/')));
+ }
+ MetaDataLoader loader = new MetaDataLoader(
+ LoaderOptions.create(false, false, true),
+ MetaDataLoader.SUBTYPE_MANUAL,
+ "spring-test-" + baseName);
+ loader.setSourceURIs(uris);
+ loader.init();
+ return loader;
+ }
+}
From ae6a99afe05aa682b1108ef4285668fceaf7b5ad Mon Sep 17 00:00:00 2001
From: Doug Mealing
Date: Thu, 30 Jul 2026 19:52:47 -0400
Subject: [PATCH 13/19] fix(#228): Kotlin extract/output-parser tier
collision-scoped naming
Co-Authored-By: Claude Opus 4.8
Claude-Session: https://claude.ai/code
---
.../generator/verify/TemplateVerify.java | 28 ++-
.../generator/verify/TemplateVerifyTest.java | 84 ++++++++
.../kotlin/KotlinExtractMapperEmitter.kt | 24 ++-
.../kotlin/KotlinExtractSchemaEmitter.kt | 39 ++--
.../kotlin/KotlinExtractorGenerator.kt | 72 +++++--
.../generator/kotlin/KotlinGenUtil.kt | 195 ++++++++++++++++++
.../generator/kotlin/KotlinNaming.kt | 8 +
.../kotlin/KotlinOutputParserGenerator.kt | 35 ++--
.../kotlin/KotlinPayloadGenerator.kt | 142 +------------
.../kotlin/KotlinExtractTierCollisionTest.kt | 177 ++++++++++++++++
10 files changed, 610 insertions(+), 194 deletions(-)
create mode 100644 server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinExtractTierCollisionTest.kt
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 9b230811c..2e4e38543 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
@@ -12,6 +12,7 @@
import com.metaobjects.template.MetaTemplate;
import com.metaobjects.template.PromptTemplate;
import com.metaobjects.template.TemplateConstants;
+import com.metaobjects.validation.SymbolTable;
import java.nio.file.Path;
import java.util.ArrayList;
@@ -95,6 +96,14 @@ public static Outcome run(MetaDataLoader loader, Path templateRoot) {
List warnings = new ArrayList<>();
List unresolved = new ArrayList<>();
+ // ADR-0042 — resolve @payloadRef through the loader's OWN package-local symbol table
+ // (#228), so a bare ref binds the template's own package (else root-level) and an FQN
+ // binds exactly — the same contract the loader validated the ref under. The prior
+ // package-blind bare-tail scan bound a same-short-named value-object in the WRONG
+ // package under a cross-package collision (load-order-dependent), deriving the wrong
+ // field tree and mis-reporting {{field}} drift.
+ SymbolTable symbols = SymbolTable.build(loader.getRoot());
+
for (com.metaobjects.MetaData child : loader.getRoot().getChildren()) {
if (!(child instanceof MetaTemplate tmpl)) continue;
@@ -107,7 +116,7 @@ public static Outcome run(MetaDataLoader loader, Path templateRoot) {
// Both subtypes: @payloadRef must resolve to a loaded object.value (a
// non-empty derived field tree). Catches a renamed VO before codegen.
- MetaObject payloadVo = resolveValueObject(loader, payloadRef);
+ MetaObject payloadVo = resolveValueObject(symbols, payloadRef, tmpl.getPackage());
List fields = payloadVo == null
? List.of()
: derivePayloadFieldTree(loader, payloadVo, new LinkedHashSet<>());
@@ -237,14 +246,15 @@ private static void addIfPresent(List refs, String ref) {
if (ref != null && !ref.isEmpty()) refs.add(ref);
}
- /** Resolve {@code @payloadRef} to its {@code object.value} target (rejects entities). */
- private static MetaObject resolveValueObject(MetaDataLoader loader, String ref) {
- for (MetaObject obj : loader.getMetaObjects()) {
- if (!MetaObject.SUBTYPE_VALUE.equals(obj.getSubType())) continue;
- if (obj.getName().equals(ref)) return obj;
- if (shortName(obj.getName()).equals(ref)) return obj;
- }
- return null;
+ /**
+ * Resolve {@code @payloadRef} to its {@code object.value} target under the loader's ADR-0042
+ * package-local contract (rejects entities). {@code referrerPkg} is the template's own package
+ * ({@code ""} for a root-level template): a bare ref binds {@code ::[} first,
+ * else a root-level object; an FQN binds exactly — no cross-package bare-name fallback.
+ */
+ private static MetaObject resolveValueObject(SymbolTable symbols, String ref, String referrerPkg) {
+ MetaObject obj = symbols.resolveObject(ref, referrerPkg == null ? "" : referrerPkg);
+ return (obj != null && MetaObject.SUBTYPE_VALUE.equals(obj.getSubType())) ? obj : null;
}
/** Last {@code ::} segment of a (possibly packaged) metadata name. */
diff --git a/server/java/codegen-base/src/test/java/com/metaobjects/generator/verify/TemplateVerifyTest.java b/server/java/codegen-base/src/test/java/com/metaobjects/generator/verify/TemplateVerifyTest.java
index 374ce0566..97f69ae59 100644
--- a/server/java/codegen-base/src/test/java/com/metaobjects/generator/verify/TemplateVerifyTest.java
+++ b/server/java/codegen-base/src/test/java/com/metaobjects/generator/verify/TemplateVerifyTest.java
@@ -279,6 +279,90 @@ public void nestedFqnObjectRefRejectsFieldFromTheCollidingRow() throws Exception
assertEquals("somethingElse", out.errors().get(0).path());
}
+ // === #228 — the @payloadRef resolver is package-local (ADR-0042). Two packages each
+ // === declare their OWN payload VO `Report` (distinct field) AND a template.prompt with a
+ // === BARE @payloadRef "Report". The prior package-blind bare-tail scan bound whichever
+ // === Report loaded first, deriving the WRONG package's field tree and mis-reporting
+ // === {{field}} drift. The fix binds each template's OWN package's Report — in BOTH orders.
+
+ /** Package pv::alpha — its OWN Report (field alphaField) + a bare-@payloadRef prompt. */
+ private static final String PAYLOAD_ALPHA_FIXTURE = """
+ {
+ "metadata.root": { "package": "pv::alpha", "children": [
+ { "object.value": { "name": "Report", "children": [
+ { "field.string": { "name": "alphaField" } }
+ ] } },
+ { "template.prompt": {
+ "name": "ReportPrompt",
+ "@payloadRef": "Report",
+ "@textRef": "alpha/tmpl"
+ } }
+ ] }
+ }
+ """;
+
+ /** Package pv::beta — a DIFFERENT Report (same short name, field betaField) + its own prompt. */
+ private static final String PAYLOAD_BETA_FIXTURE = """
+ {
+ "metadata.root": { "package": "pv::beta", "children": [
+ { "object.value": { "name": "Report", "children": [
+ { "field.string": { "name": "betaField" } }
+ ] } },
+ { "template.prompt": {
+ "name": "ReportPrompt",
+ "@payloadRef": "Report",
+ "@textRef": "beta/tmpl"
+ } }
+ ] }
+ }
+ """;
+
+ private void assertBarePayloadRefBindsOwnPackage(String baseName, String first, String second)
+ throws Exception {
+ Path templateRoot = Files.createTempDirectory("tv-payloadref-" + baseName);
+ // Each template references ONLY its own package's field: clean iff each @payloadRef
+ // binds its own package's Report (a wrong-package bind would drift on the other field).
+ writeTemplate(templateRoot, "alpha/tmpl", "{{alphaField}}");
+ writeTemplate(templateRoot, "beta/tmpl", "{{betaField}}");
+
+ MetaDataLoader loader = loadFixtures(baseName, first, second);
+
+ TemplateVerify.Outcome out = TemplateVerify.run(loader, templateRoot);
+
+ assertTrue("bare @payloadRef must bind each template's own package's Report; got: " + out,
+ out.ok());
+ }
+
+ @Test
+ public void barePayloadRefBindsOwnPackageAcrossCollision_alphaFirst() throws Exception {
+ assertBarePayloadRefBindsOwnPackage("alpha-first", PAYLOAD_ALPHA_FIXTURE, PAYLOAD_BETA_FIXTURE);
+ }
+
+ @Test
+ public void barePayloadRefBindsOwnPackageAcrossCollision_betaFirst() throws Exception {
+ assertBarePayloadRefBindsOwnPackage("beta-first", PAYLOAD_BETA_FIXTURE, PAYLOAD_ALPHA_FIXTURE);
+ }
+
+ @Test
+ public void barePayloadRefRejectsOtherPackagesField() throws Exception {
+ Path templateRoot = Files.createTempDirectory("tv-payloadref-reject");
+ // pv::alpha's prompt references betaField (only on pv::beta::Report) — must drift, proving
+ // the alpha prompt bound pv::alpha::Report, not the colliding pv::beta::Report.
+ writeTemplate(templateRoot, "alpha/tmpl", "{{betaField}}");
+ writeTemplate(templateRoot, "beta/tmpl", "{{betaField}}");
+
+ MetaDataLoader loader = loadFixtures("reject", PAYLOAD_ALPHA_FIXTURE, PAYLOAD_BETA_FIXTURE);
+
+ TemplateVerify.Outcome out = TemplateVerify.run(loader, templateRoot);
+
+ assertFalse("a field from the colliding package's Report must NOT resolve", out.ok());
+ assertTrue("expected an ERR_VAR_NOT_ON_PAYLOAD for betaField on the alpha prompt; got: " + out,
+ out.errors().stream().anyMatch(d ->
+ "pv::alpha::ReportPrompt".equals(d.template())
+ && "ERR_VAR_NOT_ON_PAYLOAD".equals(d.code())
+ && "betaField".equals(d.path())));
+ }
+
// === helpers ============================================================
private static void writeTemplate(Path root, String ref, String body) throws IOException {
diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinExtractMapperEmitter.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinExtractMapperEmitter.kt
index 7e548a969..9504de752 100644
--- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinExtractMapperEmitter.kt
+++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinExtractMapperEmitter.kt
@@ -38,10 +38,14 @@ internal object KotlinExtractMapperEmitter {
* [rootExtractedClass]) plus the shared `asMap` / `mapObjectList` helpers. Returns the
* concatenated Kotlin source, each member prefixed with a blank line for readability.
*/
- fun mapperMethods(rootVo: MetaObject, rootExtractedClass: String): String {
+ fun mapperMethods(
+ rootVo: MetaObject,
+ rootExtractedClass: String,
+ nameMap: Map,
+ ): String {
val out = StringBuilder()
val emitted = LinkedHashSet()
- emitMapper(rootVo, rootExtractedClass, out, emitted)
+ emitMapper(rootVo, rootExtractedClass, out, emitted, nameMap)
appendHelpers(out)
return out.toString()
}
@@ -51,12 +55,13 @@ internal object KotlinExtractMapperEmitter {
extractedClass: String,
out: StringBuilder,
emitted: LinkedHashSet,
+ nameMap: Map,
) {
if (!emitted.add(vo.name)) return // dedupe + cycle guard
val nested = mutableListOf()
val args = vo.metaFields.joinToString(",\n") { field ->
- " ${mapperArgForField(field, nested)}"
+ " ${mapperArgForField(field, nested, nameMap)}"
}
out.append("\n")
@@ -71,7 +76,10 @@ internal object KotlinExtractMapperEmitter {
// Recurse into nested mappers (post-order, deduped).
for (nestedVo in nested) {
- emitMapper(nestedVo, KotlinExtractSchemaEmitter.nestedExtractedClass(nestedVo), out, emitted)
+ emitMapper(
+ nestedVo, KotlinExtractSchemaEmitter.nestedExtractedClass(nestedVo, nameMap),
+ out, emitted, nameMap
+ )
}
}
@@ -81,14 +89,18 @@ internal object KotlinExtractMapperEmitter {
* object recurses into its generated mapper; an array-of-objects maps each element.
* Records the discovered nested VO into [nested] so the caller emits its mapper.
*/
- private fun mapperArgForField(field: MetaField<*>, nested: MutableList): String {
+ private fun mapperArgForField(
+ field: MetaField<*>,
+ nested: MutableList,
+ nameMap: Map,
+ ): String {
val name = KotlinExtractSchemaEmitter.kotlinStringLiteral(field.name)
// Nested object / array-of-objects (NOT enum — that is a string-backed scalar).
val target = KotlinExtractSchemaEmitter.objectRefValueObject(field)
if (target != null) {
nested.add(target)
- val nestedClass = KotlinExtractSchemaEmitter.nestedExtractedClass(target)
+ val nestedClass = KotlinExtractSchemaEmitter.nestedExtractedClass(target, nameMap)
return if (field.isArrayType()) {
// List?: map each element Map; the assembled value is a List.
// `it` is a non-null Map here, so from never returns null — `!!` keeps
diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinExtractSchemaEmitter.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinExtractSchemaEmitter.kt
index e78630076..649d9f7dc 100644
--- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinExtractSchemaEmitter.kt
+++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinExtractSchemaEmitter.kt
@@ -47,41 +47,51 @@ internal object KotlinExtractSchemaEmitter {
* ]Cycle/depth bounding is handled upstream by `MetaObjectExtractor`; the per-FQN
* dedupe set here also stops the emitter from recursing forever on a cyclic graph.
*
+ * @param nameMap ADR-0044 collision-scoped nested-mirror name map (VO FQN ->
+ * `Extracted`, or `AcmeAlphaNoteExtracted` on a cross-package short-name
+ * collision) from [KotlinGenUtil.computeExtractedNameMap] (#228).
* @return Kotlin source: the root mirror declaration followed by the nested ones,
* separated by blank lines. Returns the same shape as [extractedClassDecl]
* when [rootVo] has no nested object fields.
*/
- fun extractedClassDeclsNested(rootVo: MetaObject, rootClassName: String): String {
+ fun extractedClassDeclsNested(
+ rootVo: MetaObject,
+ rootClassName: String,
+ nameMap: Map,
+ ): String {
val out = StringBuilder()
val emitted = LinkedHashSet()
- emitMirror(rootVo, rootClassName, out, emitted)
+ emitMirror(rootVo, rootClassName, out, emitted, nameMap)
return out.toString().trimEnd()
}
/**
- * The nested mirror class name for a value-object: `Extracted`. Mirrors
- * [KotlinPayloadGenerator]'s nested payload naming (`Payload`) but for the
- * extracted (all-nullable) mirror. Public so the parser generator names mappers consistently.
+ * The nested mirror class name for a value-object: the ADR-0044 collision-scoped name from
+ * [nameMap] (`AcmeAlphaNoteExtracted` on a cross-package short-name collision), else the bare
+ * `Extracted`. Mirrors [KotlinPayloadGenerator]'s nested payload naming
+ * (`Payload`) but for the extracted (all-nullable) mirror. Public so the parser
+ * generator names mappers consistently.
*/
- fun nestedExtractedClass(vo: MetaObject): String =
- PackageMapping.splitFqn(vo.name).second + "Extracted"
+ fun nestedExtractedClass(vo: MetaObject, nameMap: Map): String =
+ nameMap[vo.name] ?: KotlinNaming.extractedName(PackageMapping.splitFqn(vo.name).second)
private fun emitMirror(
vo: MetaObject,
className: String,
out: StringBuilder,
emitted: LinkedHashSet,
+ nameMap: Map,
) {
if (!emitted.add(vo.name)) return // dedupe + cycle guard
val nested = mutableListOf()
val props = vo.metaFields.joinToString(",\n") { field ->
- " val ${field.name}: ${nestedNullableTypeName(field, nested)} = null"
+ " val ${field.name}: ${nestedNullableTypeName(field, nested, nameMap)} = null"
}
out.append("data class $className(\n$props,\n)\n\n")
for (nestedVo in nested) {
- emitMirror(nestedVo, nestedExtractedClass(nestedVo), out, emitted)
+ emitMirror(nestedVo, nestedExtractedClass(nestedVo, nameMap), out, emitted, nameMap)
}
}
@@ -90,12 +100,17 @@ internal object KotlinExtractSchemaEmitter {
* `@objectRef` resolves to a value-object become the nested mirror type (single) or
* `List<Extracted>?` (array-of-objects); the discovered nested VO is
* recorded into [nested] so the caller emits its mirror. All other fields fall back
- * to the scalar mapping in [nullableTypeName].
+ * to the scalar mapping in [nullableTypeName]. The nested mirror name is resolved through
+ * the collision-scoped [nameMap] (#228).
*/
- private fun nestedNullableTypeName(field: MetaField<*>, nested: MutableList): String {
+ private fun nestedNullableTypeName(
+ field: MetaField<*>,
+ nested: MutableList,
+ nameMap: Map,
+ ): String {
val target = objectRefValueObject(field)
if (target != null) {
- val nestedClass = nestedExtractedClass(target)
+ val nestedClass = nestedExtractedClass(target, nameMap)
nested.add(target)
return if (field.isArrayType()) "List<$nestedClass>?" else "$nestedClass?"
}
diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinExtractorGenerator.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinExtractorGenerator.kt
index 61a8817a9..f25cbc60a 100644
--- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinExtractorGenerator.kt
+++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinExtractorGenerator.kt
@@ -70,23 +70,40 @@ open class KotlinExtractorGenerator : MultiFileDirectGeneratorBase()
parseArgs()
val outRoot = Paths.get(outDir.absolutePath)
- // Stable name order — matches the sibling generators' deterministic emission.
+ // ADR-0044 (#228) — the extractor references BOTH the strict payload records (`toStrict`)
+ // and the `...Extracted` mirrors, so it consults BOTH collision-scoped name maps. Both are
+ // computed over ALL templates (not just outputs) so their domain / package assignment match
+ // the payload + parser generators, keeping all three tiers' nested names in lockstep.
// ADR-0039: root-scan discipline — resolving children accessor.
+ val allTemplates = loader.root.getChildren(MetaTemplate::class.java, true)
+ .sortedBy { it.name }
+ val payloadNameMap = KotlinGenUtil.computePayloadNameMap(allTemplates, loader)
+ val extractedNameMap = KotlinGenUtil.computeExtractedNameMap(allTemplates, loader)
+
+ // Only template.output gets an extractor file. Stable name order — matches the sibling
+ // generators' deterministic emission.
val outputs = loader.root.getChildren(OutputTemplate::class.java, true)
.sortedBy { it.name }
for (tmpl in outputs) {
- emit(tmpl, loader, outRoot)
+ emit(tmpl, loader, outRoot, payloadNameMap, extractedNameMap)
}
}
- protected open fun emit(template: MetaTemplate, loader: MetaDataLoader, outRoot: Path) {
+ protected open fun emit(
+ template: MetaTemplate,
+ loader: MetaDataLoader,
+ outRoot: Path,
+ payloadNameMap: Map,
+ extractedNameMap: Map,
+ ) {
val payloadRef = template.payloadRef
if (payloadRef.isNullOrEmpty()) {
LOG.warn("skipping extractor for {} — missing @payloadRef", template.name)
return
}
- val payloadVo = resolveViewObject(loader, payloadRef)
+ // ADR-0042 — resolve @payloadRef under the loader's package-local contract (#228).
+ val payloadVo = KotlinGenUtil.resolveValueObjectRef(loader, payloadRef, template.getPackage())
if (payloadVo == null) {
LOG.warn(
"skipping extractor for {} — @payloadRef '{}' does not resolve to an object.value",
@@ -112,7 +129,9 @@ open class KotlinExtractorGenerator : MultiFileDirectGeneratorBase()
// nested mirrors/payloads are keyed on the value-object short name.
val extractorClass = KotlinNaming.extractorName(templateShort)
val parserClass = KotlinNaming.parserName(templateShort)
- val rootMirror = templateShort + "Extracted"
+ // Root mirror + strict payload are template-named (unique — never collision-scoped);
+ // nested targets consult the collision-scoped name maps (#228).
+ val rootMirror = KotlinNaming.extractedName(templateShort)
val rootStrict = KotlinNaming.payloadName(templateShort)
val src = buildString {
@@ -178,7 +197,7 @@ open class KotlinExtractorGenerator : MultiFileDirectGeneratorBase()
append(parserClass)
append(".extractLenient(loader, text, opts)\n")
// Recursive mirror->strict mappers (root + nested, deduped, cycle-safe).
- appendMappers(payloadVo, rootMirror, rootStrict)
+ appendMappers(payloadVo, rootMirror, rootStrict, payloadNameMap, extractedNameMap)
append("}\n")
}
@@ -196,9 +215,15 @@ open class KotlinExtractorGenerator : MultiFileDirectGeneratorBase()
* mapper is named on its value-object short name (`Extracted`/`Payload`),
* matching the nested classes those generators emit.
*/
- private fun StringBuilder.appendMappers(rootVo: MetaObject, rootMirror: String, rootStrict: String) {
+ private fun StringBuilder.appendMappers(
+ rootVo: MetaObject,
+ rootMirror: String,
+ rootStrict: String,
+ payloadNameMap: Map,
+ extractedNameMap: Map,
+ ) {
val emitted = LinkedHashSet()
- appendMapper(rootVo, rootMirror, rootStrict, emitted)
+ appendMapper(rootVo, rootMirror, rootStrict, emitted, payloadNameMap, extractedNameMap)
}
private fun StringBuilder.appendMapper(
@@ -206,13 +231,15 @@ open class KotlinExtractorGenerator : MultiFileDirectGeneratorBase()
mirror: String,
strict: String,
emitted: LinkedHashSet,
+ payloadNameMap: Map,
+ extractedNameMap: Map,
) {
if (!emitted.add(vo.name)) return // dedupe + cycle guard
val nested = mutableListOf()
val args = vo.metaFields.joinToString(",\n") { field ->
- " ${field.name} = ${strictArg(field, vo, nested)}"
+ " ${field.name} = ${strictArg(field, vo, nested, payloadNameMap)}"
}
append("\n")
@@ -235,10 +262,15 @@ open class KotlinExtractorGenerator : MultiFileDirectGeneratorBase()
append(" )\n")
// Recurse into nested-object targets (single + array) for their mappers (post-order).
- // Nested mappers are keyed on the value-object short name.
+ // Nested mapper names are collision-scoped: the `...Extracted` mirror from
+ // [extractedNameMap] and the strict `...Payload` from [payloadNameMap] (#228), falling
+ // back to the bare `Extracted`/`Payload` when a target is not in the map
+ // (non-colliding — byte-identical to pre-#228 output).
for (nestedVo in nested) {
val nestedShort = PackageMapping.splitFqn(nestedVo.name).second
- appendMapper(nestedVo, nestedShort + "Extracted", nestedShort + "Payload", emitted)
+ val nestedMirror = extractedNameMap[nestedVo.name] ?: KotlinNaming.extractedName(nestedShort)
+ val nestedStrict = payloadNameMap[nestedVo.name] ?: KotlinNaming.payloadName(nestedShort)
+ appendMapper(nestedVo, nestedMirror, nestedStrict, emitted, payloadNameMap, extractedNameMap)
}
}
@@ -261,14 +293,23 @@ open class KotlinExtractorGenerator : MultiFileDirectGeneratorBase()
* scalar (single) → `m.f!!`.
*
*/
- private fun strictArg(field: MetaField<*>, owner: MetaObject, nested: MutableList): String {
+ private fun strictArg(
+ field: MetaField<*>,
+ owner: MetaObject,
+ nested: MutableList,
+ payloadNameMap: Map,
+ ): String {
val name = field.name
// Object BEFORE array: array-of-objects maps element-wise (checked before isArray).
val target = KotlinExtractSchemaEmitter.objectRefValueObject(field)
if (target != null) {
nested.add(target)
- val nestedStrict = PackageMapping.splitFqn(target.name).second + "Payload"
+ // ADR-0044 (#228) — the strict `toStrict` target is collision-scoped via
+ // [payloadNameMap] (the SAME map the payload generator + the nested recursion use),
+ // so the call and the emitted `toStrict` definition agree under a collision.
+ val nestedStrict = payloadNameMap[target.name]
+ ?: KotlinNaming.payloadName(PackageMapping.splitFqn(target.name).second)
return if (field.isArrayType()) {
// The mirror element type for an array-of-objects is the NON-NULL nested mirror
// (KotlinExtractSchemaEmitter.nestedNullableTypeName emits `List<Extracted>?`),
@@ -373,11 +414,6 @@ open class KotlinExtractorGenerator : MultiFileDirectGeneratorBase()
}
}
- /** Resolve a `@payloadRef` to its `object.value` (rejects entities — payloads must be VOs). */
- private fun resolveViewObject(loader: MetaDataLoader, ref: String): MetaObject? =
- KotlinGenUtil.resolveObjectByShortOrFqn(loader, ref)
- ?.takeIf { it.subType == MetaObject.SUBTYPE_VALUE }
-
// === MultiFileDirectGeneratorBase abstract-method stubs ====================
override fun writeSingleFile(md: MetaObject, writer: GeneratorIOWriter<*>?) { /* unused */ }
override fun ?> getSingleWriter(
diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinGenUtil.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinGenUtil.kt
index 1e2d023de..03795a4d0 100644
--- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinGenUtil.kt
+++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinGenUtil.kt
@@ -3,13 +3,18 @@ package com.metaobjects.generator.kotlin
import com.metaobjects.MetaData
import com.metaobjects.field.DateField
import com.metaobjects.field.MetaField
+import com.metaobjects.field.ObjectField
import com.metaobjects.field.TimeField
import com.metaobjects.field.TimestampField
+import com.metaobjects.generator.GeneratorException
import com.metaobjects.loader.MetaDataLoader
import com.metaobjects.`object`.MetaObject
import com.metaobjects.origin.AggregateOrigin
+import com.metaobjects.origin.CollectionOrigin
import com.metaobjects.origin.MetaOrigin
import com.metaobjects.source.RdbSource
+import com.metaobjects.template.MetaTemplate
+import com.metaobjects.validation.SymbolTable
/**
* Helpers shared by the codegen-kotlin generators. Extracted to keep
@@ -44,6 +49,38 @@ public object KotlinGenUtil {
return null
}
+ // =========================================================================
+ // ADR-0042 — canonical package-local object-ref resolution (@payloadRef).
+ //
+ // The loader validates a template's @payloadRef via the SAME package-local
+ // contract (ValidationPhase.resolveRootObject, backed by SymbolTable): an FQN ref
+ // binds EXACTLY; a bare ref binds the referrer's own package, else a root-level
+ // object; NO cross-package bare-name / bare-tail fallback. Reusing the loader's own
+ // public [SymbolTable] (rather than a divergent codegen copy) keeps codegen's
+ // @payloadRef resolution identical to the loader's, so under a cross-package
+ // short-name collision codegen binds the SAME value-object the loader validated —
+ // never a load-order-dependent decoy (the #244 class). The bare-tail/first-match
+ // [resolveObjectByShortOrFqn] above is deliberately left as-is: it backs the
+ // @from/@of/@via dotted-ref navigation (a different ref kind, #244's own domain).
+ // =========================================================================
+
+ /**
+ * Resolve a metadata OBJECT reference (bare or FQN) under the loader's ADR-0042
+ * package-local contract, or null. [referrerPkg] is the effective package of the node
+ * carrying the ref (a template's own `getPackage()` for a @payloadRef); "" for root-level.
+ */
+ fun resolveObjectRef(loader: MetaDataLoader, ref: String?, referrerPkg: String?): MetaObject? {
+ if (ref == null) return null
+ return SymbolTable.build(loader.root).resolveObject(ref, referrerPkg ?: "")
+ }
+
+ /**
+ * Resolve [ref] to its `object.value` target under the same ADR-0042 package-local
+ * contract as [resolveObjectRef] (rejects entities — a @payloadRef must be a VO).
+ */
+ fun resolveValueObjectRef(loader: MetaDataLoader, ref: String?, referrerPkg: String?): MetaObject? =
+ resolveObjectRef(loader, ref, referrerPkg)?.takeIf { it.subType == MetaObject.SUBTYPE_VALUE }
+
/**
* The first `source.rdb` child of [obj], RESOLVED through the `extends` super chain
* (ADR-0039). `MetaObject.getSources(true)` walks the inheritance chain, so an entity
@@ -230,4 +267,162 @@ public object KotlinGenUtil {
}
return sb.toString()
}
+
+ // =========================================================================
+ // ADR-0044 — collision-scoped payload / extracted-mirror naming.
+ //
+ // Lifted here (was private on [KotlinPayloadGenerator]) so the strict payload
+ // record, the `...Extracted` mirror family, and the extractor all share ONE
+ // name-map algorithm. Kotlin `protected` is NOT same-package-visible, so the
+ // extract-tier emitters ([KotlinExtractSchemaEmitter] / [KotlinExtractMapperEmitter] /
+ // [KotlinExtractorGenerator], all in this package) reach these public helpers here.
+ // =========================================================================
+
+ /**
+ * ADR-0044 — the run's nested-PAYLOAD name map (VO FQN -> `Payload`, or the
+ * package-qualified `AcmeAlphaNotePayload` on a same-output-package short-name collision).
+ * See [computeNameMap]. Consumed by [KotlinPayloadGenerator] (the strict record files) and
+ * the extractor's `toStrict` / mapper-return references.
+ */
+ fun computePayloadNameMap(templates: List, loader: MetaDataLoader): Map =
+ computeNameMap(templates, loader) { KotlinNaming.payloadName(it) }
+
+ /**
+ * ADR-0044 — the run's nested-EXTRACTED-mirror name map (VO FQN -> `Extracted`, or the
+ * package-qualified `AcmeAlphaNoteExtracted` on a collision). Uses the SAME [computeNameMap]
+ * closure + collision grouping as [computePayloadNameMap] (differing only in the leaf suffix),
+ * so the `...Extracted` mirror and the `...Payload` strict record qualify in lockstep.
+ */
+ fun computeExtractedNameMap(templates: List, loader: MetaDataLoader): Map =
+ computeNameMap(templates, loader) { KotlinNaming.extractedName(it) }
+
+ /**
+ * ADR-0044 pass 1/2 — the run's nested-class name map, keyed by value-object FQN
+ * (`MetaObject.name`), scoped per OUTPUT PACKAGE. Kotlin is a one-class-per-file emitter,
+ * so its collision domain is the output prompts package: two value-objects sharing a bare
+ * short name written into the same package would clobber one `NotePayload.kt` /
+ * `NoteExtracted` declaration. A nested VO whose bare short name is UNIQUE in its output
+ * package is named `nameOf()` (byte-identical to pre-ADR-0044 output); a COLLISION
+ * names every member `nameOf()` (`acme::alpha::Note` -> `AcmeAlphaNote...`).
+ * A still-colliding derived name fails loud with [KotlinPayloadGenerator.ERR_PAYLOAD_NAME_COLLISION].
+ * Pure function of the templates — never of emission order.
+ */
+ private fun computeNameMap(
+ templates: List,
+ loader: MetaDataLoader,
+ nameOf: (String) -> String,
+ ): Map {
+ // FQN -> output package (first reaching template in caller-sorted order wins, matching
+ // the run-wide dedupe). The primary VO is template-named, so excluded.
+ val voOutPkg = LinkedHashMap()
+ val orderedFqns = ArrayList()
+ for (tmpl in templates) {
+ val payloadRef = tmpl.payloadRef ?: continue
+ // ADR-0042 — resolve @payloadRef under the loader's own package-local contract.
+ val vo = resolveValueObjectRef(loader, payloadRef, tmpl.getPackage()) ?: continue
+ val nestedPkg = KotlinNaming.promptsPackage(PackageMapping.splitFqn(tmpl.name).first)
+ collectNestedClosure(vo, loader, nestedPkg, voOutPkg, orderedFqns, mutableSetOf(vo.name))
+ }
+ // Group by (output package, bare short name).
+ val byPkgShort = LinkedHashMap>()
+ for (fqn in orderedFqns) {
+ val key = voOutPkg[fqn] + " " + PackageMapping.splitFqn(fqn).second
+ byPkgShort.getOrPut(key) { ArrayList() }.add(fqn)
+ }
+ val nameMap = LinkedHashMap()
+ for (fqns in byPkgShort.values) {
+ if (fqns.size == 1) {
+ val fqn = fqns[0]
+ nameMap[fqn] = nameOf(PackageMapping.splitFqn(fqn).second)
+ } else {
+ for (fqn in fqns) {
+ val (pkg, short) = PackageMapping.splitFqn(fqn)
+ nameMap[fqn] = nameOf(packageQualifiedName(pkg, short))
+ }
+ }
+ }
+ // Backstop — per output package, two DISTINCT FQNs deriving the same class name.
+ // Sorted so the named pair (and whether any collision fires) is order-independent.
+ val ownerByPkgName = HashMap()
+ for (fqn in nameMap.keys.sorted()) {
+ val pkgName = voOutPkg[fqn] + " " + nameMap[fqn]
+ val prev = ownerByPkgName.putIfAbsent(pkgName, fqn)
+ if (prev != null && prev != fqn) {
+ throw GeneratorException(
+ "${KotlinPayloadGenerator.ERR_PAYLOAD_NAME_COLLISION}: payload record name collision: \"${nameMap[fqn]}\" " +
+ "derives from both \"$prev\" and \"$fqn\" — rename one value-object or move " +
+ "it to a package that derives a distinct name"
+ )
+ }
+ }
+ return nameMap
+ }
+
+ /**
+ * ADR-0044 pass 1 — walk [vo]'s transitive nested-payload closure (plain
+ * `field.object @objectRef` + `origin.collection @via` edges), assigning each
+ * not-yet-seen target VO to [outPkg] (first reaching template wins) and recording it
+ * in [orderedFqns]. [seen] is seeded with the primary VO's FQN and is the cycle guard.
+ */
+ private fun collectNestedClosure(
+ vo: MetaObject,
+ loader: MetaDataLoader,
+ outPkg: String,
+ voOutPkg: MutableMap,
+ orderedFqns: MutableList,
+ seen: MutableSet,
+ ) {
+ for (field in vo.metaFields) {
+ val target = nestedTargetOf(field, loader) ?: continue
+ val fqn = target.name
+ if (!seen.add(fqn)) continue
+ if (!voOutPkg.containsKey(fqn)) {
+ voOutPkg[fqn] = outPkg
+ orderedFqns.add(fqn)
+ }
+ collectNestedClosure(target, loader, outPkg, voOutPkg, orderedFqns, seen)
+ }
+ }
+
+ /**
+ * The nested-payload target VO a [field] contributes to the closure, or `null` when it
+ * contributes no nested class. Passthrough / aggregate / computed / first origins yield
+ * scalar types (no nested class). NOTE: the `origin.collection @via` and `field.objectRef`
+ * navigation here uses [resolveObjectByShortOrFqn] / the loader-bound `objectRef` — the
+ * origin-navigation ref kind (#244's domain), intentionally NOT the ADR-0042 @payloadRef
+ * resolver (which is only for the template's own @payloadRef).
+ */
+ private fun nestedTargetOf(field: MetaField<*>, loader: MetaDataLoader): MetaObject? {
+ val origin = field.children.filterIsInstance().firstOrNull()
+ if (origin is CollectionOrigin) {
+ val via = origin.via ?: return null
+ val (parentName, relName) = splitDottedRef(via) ?: return null
+ val parent = resolveObjectByShortOrFqn(loader, parentName) ?: return null
+ val rel = parent.relationships
+ .firstOrNull { it.name == relName || it.name.substringAfterLast("::") == relName }
+ ?: return null
+ val targetRef = rel.objectRef ?: return null
+ return resolveObjectByShortOrFqn(loader, targetRef)
+ }
+ if (origin != null) return null // passthrough / aggregate / computed / first -> scalar
+ if (field is ObjectField) {
+ val target = try { field.objectRef } catch (e: RuntimeException) { null } ?: return null
+ if (target.subType != MetaObject.SUBTYPE_VALUE) return null
+ return target
+ }
+ return null
+ }
+
+ /**
+ * ADR-0044 — PascalCase each dotted segment of [kotlinPkg] (already `::`->`.`
+ * converted by [PackageMapping.splitFqn]), concatenate, append the bare [shortName]
+ * (`"acme.alpha"` + `"Note"` -> `"AcmeAlphaNote"`). A root-level (empty-package) node
+ * keeps its bare short name.
+ */
+ fun packageQualifiedName(kotlinPkg: String, shortName: String): String {
+ if (kotlinPkg.isEmpty()) return shortName
+ return kotlinPkg.split(".")
+ .filter { it.isNotEmpty() }
+ .joinToString("") { it.replaceFirstChar { c -> c.uppercaseChar() } } + shortName
+ }
}
diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinNaming.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinNaming.kt
index ceb081769..870a9fa1c 100644
--- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinNaming.kt
+++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinNaming.kt
@@ -102,6 +102,14 @@ object KotlinNaming {
/** [KotlinPayloadGenerator]: `templateShort + "Payload"`. */
fun payloadName(templateShort: String): String = templateShort + "Payload"
+ /**
+ * [KotlinExtractSchemaEmitter] / [KotlinOutputParserGenerator] / [KotlinExtractorGenerator]:
+ * `templateShort + "Extracted"` — the all-nullable extract mirror class name. The peer of
+ * [payloadName] for the lenient `...Extracted` mirror family; the SSOT so the root mirror,
+ * the nested mirrors, and the extractor's mirror references stay in lockstep.
+ */
+ fun extractedName(templateShort: String): String = templateShort + "Extracted"
+
/** [KotlinRenderHelperGenerator]: `capitalizeFirst(templateShort) + "RenderHelper"`. */
fun renderHelperName(templateShort: String): String = capitalizeFirst(templateShort) + "RenderHelper"
diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinOutputParserGenerator.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinOutputParserGenerator.kt
index 8a2766562..cac67394b 100644
--- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinOutputParserGenerator.kt
+++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinOutputParserGenerator.kt
@@ -80,17 +80,30 @@ open class KotlinOutputParserGenerator : MultiFileDirectGeneratorBase,
+ ) {
val payloadRef = template.payloadRef
if (payloadRef.isNullOrEmpty()) {
// Loader validation normally catches this first; defensive only.
@@ -100,7 +113,8 @@ open class KotlinOutputParserGenerator : MultiFileDirectGeneratorBase? (array-of-objects) so the runtime-delegating
// extractLenient(loader, ...) overload can populate the full graph (FR-010 nested gap).
- append(KotlinExtractSchemaEmitter.extractedClassDeclsNested(payloadVo, extractedClass))
+ append(KotlinExtractSchemaEmitter.extractedClassDeclsNested(payloadVo, extractedClass, extractedNameMap))
append("\n\n")
}
append("/** Parser for LLM responses matching the `")
@@ -219,7 +235,7 @@ open class KotlinOutputParserGenerator : MultiFileDirectGeneratorBase typed Extracted-mirror mappers (root + nested, deduped) ----
- append(KotlinExtractMapperEmitter.mapperMethods(payloadVo, extractedClass))
+ append(KotlinExtractMapperEmitter.mapperMethods(payloadVo, extractedClass, extractedNameMap))
}
append("}\n")
}
@@ -229,11 +245,6 @@ open class KotlinOutputParserGenerator : MultiFileDirectGeneratorBase?) { /* unused */ }
override fun ?> getSingleWriter(
diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinPayloadGenerator.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinPayloadGenerator.kt
index 42e8940f0..8782620c6 100644
--- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinPayloadGenerator.kt
+++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinPayloadGenerator.kt
@@ -3,7 +3,6 @@ package com.metaobjects.generator.kotlin
import com.metaobjects.field.EnumField
import com.metaobjects.field.MetaField
import com.metaobjects.field.ObjectField
-import com.metaobjects.generator.GeneratorException
import com.metaobjects.generator.GeneratorIOWriter
import com.metaobjects.generator.direct.MultiFileDirectGeneratorBase
import com.metaobjects.loader.MetaDataLoader
@@ -85,141 +84,14 @@ open class KotlinPayloadGenerator : MultiFileDirectGeneratorBase() {
val templates = loader.root.getChildren(MetaTemplate::class.java, true)
.sortedBy { it.name }
// ADR-0044 — collision-scoped nested-payload class names, a pure function of the
- // loaded templates (order-independent). Keyed by VO FQN.
- val nameMap = computePayloadNameMap(templates, loader)
+ // loaded templates (order-independent). Keyed by VO FQN. Lifted to [KotlinGenUtil]
+ // so the extract-tier emitters reuse the SAME name-map algorithm (#228).
+ val nameMap = KotlinGenUtil.computePayloadNameMap(templates, loader)
for (md in templates) {
emit(md, loader, outRoot, emittedNestedFqns, emittedEnumFqns, nameMap)
}
}
- /**
- * ADR-0044 pass 1/2 — the run's nested-payload name map, keyed by value-object FQN
- * (`MetaObject.name`), scoped per OUTPUT PACKAGE. Kotlin is a one-class-per-file
- * emitter (KotlinPoet `FileSpec(outPkg, className)`), so its collision domain is the
- * output prompts package: two value-objects sharing a bare short name written into
- * the same package would clobber one `NotePayload.kt`. A nested VO whose bare short
- * name is UNIQUE in its output package emits `Payload` (byte-identical to
- * pre-ADR-0044 output); a COLLISION emits every member under its package-qualified
- * derived name (`acme::alpha::Note` -> `AcmeAlphaNotePayload`). A still-colliding
- * derived name fails loud with [ERR_PAYLOAD_NAME_COLLISION]. Pure function of the
- * templates — never of emission order.
- */
- protected open fun computePayloadNameMap(
- templates: List,
- loader: MetaDataLoader,
- ): Map {
- // FQN -> output package (first reaching template in sorted order wins, matching
- // the run-wide dedupe). The primary VO is template-named, so excluded.
- val voOutPkg = LinkedHashMap()
- val orderedFqns = ArrayList()
- for (tmpl in templates) {
- val payloadRef = tmpl.payloadRef ?: continue
- val vo = resolveViewObject(loader, payloadRef) ?: continue
- val nestedPkg = KotlinNaming.promptsPackage(PackageMapping.splitFqn(tmpl.name).first)
- collectNestedClosure(vo, loader, nestedPkg, voOutPkg, orderedFqns, mutableSetOf(vo.name))
- }
- // Group by (output package, bare short name).
- val byPkgShort = LinkedHashMap>()
- for (fqn in orderedFqns) {
- val key = voOutPkg[fqn] + " " + PackageMapping.splitFqn(fqn).second
- byPkgShort.getOrPut(key) { ArrayList() }.add(fqn)
- }
- val nameMap = LinkedHashMap()
- for (fqns in byPkgShort.values) {
- if (fqns.size == 1) {
- val fqn = fqns[0]
- nameMap[fqn] = KotlinNaming.payloadName(PackageMapping.splitFqn(fqn).second)
- } else {
- for (fqn in fqns) {
- val (pkg, short) = PackageMapping.splitFqn(fqn)
- nameMap[fqn] = KotlinNaming.payloadName(packageQualifiedName(pkg, short))
- }
- }
- }
- // Backstop — per output package, two DISTINCT FQNs deriving the same class name.
- // Sorted so the named pair (and whether any collision fires) is order-independent.
- val ownerByPkgName = HashMap()
- for (fqn in nameMap.keys.sorted()) {
- val pkgName = voOutPkg[fqn] + " " + nameMap[fqn]
- val prev = ownerByPkgName.putIfAbsent(pkgName, fqn)
- if (prev != null && prev != fqn) {
- throw GeneratorException(
- "$ERR_PAYLOAD_NAME_COLLISION: payload record name collision: \"${nameMap[fqn]}\" " +
- "derives from both \"$prev\" and \"$fqn\" — rename one value-object or move " +
- "it to a package that derives a distinct name"
- )
- }
- }
- return nameMap
- }
-
- /**
- * ADR-0044 pass 1 — walk [vo]'s transitive nested-payload closure (plain
- * `field.object @objectRef` + `origin.collection @via` edges), assigning each
- * not-yet-seen target VO to [outPkg] (first reaching template wins) and recording it
- * in [orderedFqns]. [seen] is seeded with the primary VO's FQN and is the cycle guard.
- */
- protected fun collectNestedClosure(
- vo: MetaObject,
- loader: MetaDataLoader,
- outPkg: String,
- voOutPkg: MutableMap,
- orderedFqns: MutableList,
- seen: MutableSet,
- ) {
- for (field in vo.metaFields) {
- val target = nestedTargetOf(field, loader) ?: continue
- val fqn = target.name
- if (!seen.add(fqn)) continue
- if (!voOutPkg.containsKey(fqn)) {
- voOutPkg[fqn] = outPkg
- orderedFqns.add(fqn)
- }
- collectNestedClosure(target, loader, outPkg, voOutPkg, orderedFqns, seen)
- }
- }
-
- /**
- * The nested-payload target VO a [field] contributes to the closure, or `null` when
- * it contributes no nested class. Mirrors the resolution in [resolveObjectFieldType]
- * (plain `field.object @objectRef`) and [resolveCollectionType] (`origin.collection
- * @via`) EXACTLY, so the closure walk and the emission walk agree. Passthrough /
- * aggregate / computed / first origins yield scalar types (no nested class).
- */
- protected fun nestedTargetOf(field: MetaField<*>, loader: MetaDataLoader): MetaObject? {
- val origin = field.children.filterIsInstance().firstOrNull()
- if (origin is CollectionOrigin) {
- val via = origin.via ?: return null
- val (parentName, relName) = KotlinGenUtil.splitDottedRef(via) ?: return null
- val parent = KotlinGenUtil.resolveObjectByShortOrFqn(loader, parentName) ?: return null
- val rel = parent.relationships
- .firstOrNull { it.name == relName || it.name.substringAfterLast("::") == relName }
- ?: return null
- val targetRef = rel.objectRef ?: return null
- return KotlinGenUtil.resolveObjectByShortOrFqn(loader, targetRef)
- }
- if (origin != null) return null // passthrough / aggregate / computed / first -> scalar
- if (field is ObjectField) {
- val target = try { field.objectRef } catch (e: RuntimeException) { null } ?: return null
- if (target.subType != MetaObject.SUBTYPE_VALUE) return null
- return target
- }
- return null
- }
-
- /**
- * ADR-0044 — PascalCase each dotted segment of [kotlinPkg] (already `::`->`.`
- * converted by [PackageMapping.splitFqn]), concatenate, append the bare [shortName]
- * (`"acme.alpha"` + `"Note"` -> `"AcmeAlphaNote"`). A root-level (empty-package) node
- * keeps its bare short name.
- */
- protected fun packageQualifiedName(kotlinPkg: String, shortName: String): String {
- if (kotlinPkg.isEmpty()) return shortName
- return kotlinPkg.split(".")
- .filter { it.isNotEmpty() }
- .joinToString("") { it.replaceFirstChar { c -> c.uppercaseChar() } } + shortName
- }
-
protected open fun emit(
template: MetaTemplate,
loader: MetaDataLoader,
@@ -229,7 +101,8 @@ open class KotlinPayloadGenerator : MultiFileDirectGeneratorBase() {
nameMap: Map,
) {
val payloadRef = template.payloadRef ?: return
- val payloadVo = resolveViewObject(loader, payloadRef) ?: return
+ // ADR-0042 — resolve @payloadRef under the loader's package-local contract (#228).
+ val payloadVo = KotlinGenUtil.resolveValueObjectRef(loader, payloadRef, template.getPackage()) ?: return
val (templatePkg, templateShort) = PackageMapping.splitFqn(template.name)
val outPkg = KotlinNaming.promptsPackage(templatePkg)
@@ -544,11 +417,6 @@ open class KotlinPayloadGenerator : MultiFileDirectGeneratorBase() {
}
}
- /** Resolve a `@payloadRef` to its `object.value` (rejects entities — payloads must be VOs). */
- private fun resolveViewObject(loader: MetaDataLoader, ref: String): MetaObject? =
- KotlinGenUtil.resolveObjectByShortOrFqn(loader, ref)
- ?.takeIf { it.subType == MetaObject.SUBTYPE_VALUE }
-
// === MultiFileDirectGeneratorBase abstract-method stubs ====================
override fun writeSingleFile(md: MetaObject, writer: GeneratorIOWriter<*>?) { /* unused */ }
override fun ?> getSingleWriter(
diff --git a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinExtractTierCollisionTest.kt b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinExtractTierCollisionTest.kt
new file mode 100644
index 000000000..f56b4d640
--- /dev/null
+++ b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinExtractTierCollisionTest.kt
@@ -0,0 +1,177 @@
+package com.metaobjects.generator.kotlin
+
+import com.metaobjects.loader.InMemoryStringSource
+import com.metaobjects.loader.MetaDataLoader
+import com.metaobjects.metadata.ktx.loadDirectory
+import com.tschuchort.compiletesting.KotlinCompilation
+import com.tschuchort.compiletesting.SourceFile
+import java.nio.file.Files
+import java.nio.file.Path
+import kotlin.io.path.isRegularFile
+import kotlin.io.path.readText
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+/**
+ * #228 — the extract / output-parser tier must collision-scope its THREE naming tiers when two
+ * value-objects share a bare short name across packages but are both reachable from ONE payload:
+ *
+ * 1. the strict `Payload` record ([KotlinPayloadGenerator]),
+ * 2. the `Extracted` all-nullable mirror family ([KotlinExtractSchemaEmitter] — its OWN
+ * second naming scheme, emitted into the parser file),
+ * 3. the extractor's strict-payload + mirror references ([KotlinExtractorGenerator]:
+ * `toStrict` over `Extracted`).
+ *
+ * ADR-0044 already scoped tier 1 (shipped 0.19.3). This test proves tiers 2 + 3 are scoped in
+ * lockstep with tier 1, so the generated parser + extractor compile (no duplicate `NoteExtracted`
+ * class / `fromNoteExtracted` / `toStrictNotePayload` function) and reference BOTH the
+ * package-qualified strict records AND the package-qualified mirrors.
+ *
+ * Loads the shared `fixtures/template-output-render-conformance/xpkg-collision-json/` corpus
+ * (`@format: json`, so the extract tier fires) — two `Note` VOs (`acme::alpha` / `acme::beta`)
+ * reached by FQN `@objectRef` from `acme::app::Digest`, the `DigestDoc` output's `@payloadRef`.
+ */
+@OptIn(org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi::class)
+class KotlinExtractTierCollisionTest {
+
+ private val corpus: Path = run {
+ var p: Path? = Path.of(System.getProperty("user.dir")).toAbsolutePath()
+ while (p != null && !Files.exists(p.resolve("fixtures/template-output-render-conformance"))) {
+ p = p.parent
+ }
+ assertTrue(p != null, "could not locate fixtures/template-output-render-conformance from user.dir")
+ p!!.resolve("fixtures/template-output-render-conformance")
+ }
+
+ 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()) }
+ return KotlinCompilation().apply {
+ this.sources = sources
+ inheritClassPath = true
+ messageOutputStream = System.out
+ }.compile()
+ }
+
+ @Test fun `extract tier collision-scopes payload, mirror and extractor refs across a cross-package Note collision`() {
+ val outDir = Files.createTempDirectory("kext-xpkg-")
+ try {
+ val loader = loadDirectory("kext-xpkg", corpus.resolve("xpkg-collision-json"))
+
+ // All three extract-tier generators run for one payload graph.
+ for (gen in listOf(
+ KotlinPayloadGenerator(),
+ KotlinOutputParserGenerator(),
+ KotlinExtractorGenerator(),
+ )) {
+ gen.setArgs(mapOf("outputDir" to outDir.toString()))
+ gen.execute(loader)
+ }
+
+ val produced = Files.walk(outDir).filter { it.isRegularFile() }.toList()
+ val names = produced.map { it.fileName.toString() }.toSet()
+
+ // ---- Tier 1: strict payload records (ADR-0044, the existing guarantee) ----
+ assertTrue("AcmeAlphaNotePayload.kt" in names, "expected AcmeAlphaNotePayload.kt; files=$names")
+ assertTrue("AcmeBetaNotePayload.kt" in names, "expected AcmeBetaNotePayload.kt; files=$names")
+ assertTrue("NotePayload.kt" !in names, "must NOT emit a clobbered bare NotePayload.kt; files=$names")
+
+ // ---- Tier 2 + 3: the parser file (mirror family) ----
+ val parserSrc = produced.first { it.fileName.toString() == "DigestDocParser.kt" }.readText()
+ // Collision-scoped nested mirror declarations (their OWN naming scheme).
+ assertTrue("data class AcmeAlphaNoteExtracted(" in parserSrc,
+ "parser must declare AcmeAlphaNoteExtracted; saw:\n$parserSrc")
+ assertTrue("data class AcmeBetaNoteExtracted(" in parserSrc,
+ "parser must declare AcmeBetaNoteExtracted; saw:\n$parserSrc")
+ // The bare mirror name must NOT be emitted twice (the pre-fix duplicate-class compile error).
+ assertTrue("data class NoteExtracted(" !in parserSrc,
+ "must NOT emit a bare (colliding) NoteExtracted; saw:\n$parserSrc")
+ // The root mirror types its object fields as the collision-scoped nested mirrors.
+ assertTrue("AcmeAlphaNoteExtracted?" in parserSrc && "AcmeBetaNoteExtracted?" in parserSrc,
+ "root DigestDocExtracted must type fromAlpha/fromBeta as the scoped mirrors; saw:\n$parserSrc")
+ // Collision-scoped mappers — never a bare (duplicated) fromNoteExtracted.
+ assertTrue("fun fromAcmeAlphaNoteExtracted(" in parserSrc, parserSrc)
+ assertTrue("fun fromAcmeBetaNoteExtracted(" in parserSrc, parserSrc)
+ assertTrue("fun fromNoteExtracted(" !in parserSrc,
+ "must NOT emit a bare (colliding) fromNoteExtracted mapper; saw:\n$parserSrc")
+
+ // ---- Tier 3: the extractor file references BOTH strict records AND mirrors ----
+ val extractorSrc = produced.first { it.fileName.toString() == "DigestDocExtractor.kt" }.readText()
+ // Strict payload references (mapper name + return type).
+ assertTrue("toStrictAcmeAlphaNotePayload" in extractorSrc, extractorSrc)
+ assertTrue("toStrictAcmeBetaNotePayload" in extractorSrc, extractorSrc)
+ assertTrue("toStrictNotePayload(" !in extractorSrc,
+ "must NOT emit a bare (colliding) toStrictNotePayload; saw:\n$extractorSrc")
+ // Mirror references (the mapper parameter type).
+ assertTrue("AcmeAlphaNoteExtracted" in extractorSrc, extractorSrc)
+ assertTrue("AcmeBetaNoteExtracted" in extractorSrc, extractorSrc)
+
+ // ---- The whole graph COMPILES — proves the scoped classes/functions are real, distinct,
+ // and the cross-file references (payload <-> parser <-> extractor) resolve. ----
+ val result = compile(outDir)
+ assertEquals(KotlinCompilation.ExitCode.OK, result.exitCode, result.messages)
+ } finally {
+ outDir.toFile().deleteRecursively()
+ }
+ }
+
+ // === Checkpoint #1 — the build-time @payloadRef resolver is package-local (ADR-0042). ===
+ // Two packages each declare their OWN payload VO `Report` (distinct field) AND a template
+ // with a BARE @payloadRef "Report". The prior first-match/bare-tail resolver bound whichever
+ // Report loaded first, so a template could emit the OTHER package's payload shape. The
+ // canonical resolver binds each template's OWN package's Report — in BOTH load orders.
+
+ private val alphaFixture = """{
+ "metadata.root": { "package": "pkg::alpha", "children": [
+ { "object.value": { "name": "Report", "children": [
+ { "field.string": { "name": "alphaVal" } }
+ ] } },
+ { "template.prompt": { "name": "ReportPrompt",
+ "@payloadRef": "Report", "@textRef": "alpha/x" } }
+ ] }
+ }""".trimIndent()
+
+ private val betaFixture = """{
+ "metadata.root": { "package": "pkg::beta", "children": [
+ { "object.value": { "name": "Report", "children": [
+ { "field.string": { "name": "betaVal" } }
+ ] } },
+ { "template.prompt": { "name": "ReportPrompt",
+ "@payloadRef": "Report", "@textRef": "beta/x" } }
+ ] }
+ }""".trimIndent()
+
+ private fun assertBarePayloadRefBindsOwnPackage(firstAlpha: Boolean) {
+ val outDir = Files.createTempDirectory("kpay-bareref-")
+ try {
+ val loader = MetaDataLoader.createManual(false, "bareref-${firstAlpha}")
+ loader.init()
+ val sources = if (firstAlpha)
+ listOf(InMemoryStringSource(alphaFixture, "alpha"), InMemoryStringSource(betaFixture, "beta"))
+ else
+ listOf(InMemoryStringSource(betaFixture, "beta"), InMemoryStringSource(alphaFixture, "alpha"))
+ loader.load(sources)
+ loader.register()
+
+ KotlinPayloadGenerator().apply { setArgs(mapOf("outputDir" to outDir.toString())) }.execute(loader)
+
+ val alphaPayload = outDir.resolve("pkg/alpha/prompts/ReportPromptPayload.kt").readText()
+ val betaPayload = outDir.resolve("pkg/beta/prompts/ReportPromptPayload.kt").readText()
+
+ // Each template's payload record must carry its OWN package's field — never the other's.
+ assertTrue("alphaVal" in alphaPayload && "betaVal" !in alphaPayload,
+ "pkg::alpha ReportPrompt must bind pkg::alpha::Report (alphaVal); saw:\n$alphaPayload")
+ assertTrue("betaVal" in betaPayload && "alphaVal" !in betaPayload,
+ "pkg::beta ReportPrompt must bind pkg::beta::Report (betaVal); saw:\n$betaPayload")
+ } finally {
+ outDir.toFile().deleteRecursively()
+ }
+ }
+
+ @Test fun `bare payloadRef binds own package's payload — alpha loaded first`() =
+ assertBarePayloadRefBindsOwnPackage(firstAlpha = true)
+
+ @Test fun `bare payloadRef binds own package's payload — beta loaded first`() =
+ assertBarePayloadRefBindsOwnPackage(firstAlpha = false)
+}
From d4399a06d5a2c942111a2acacfdffc9f5ca4347d Mon Sep 17 00:00:00 2001
From: Doug Mealing
Date: Thu, 30 Jul 2026 20:11:47 -0400
Subject: [PATCH 14/19] fix(#228): route render-helper + output-prompt +
apidocs @payloadRef through the package-local resolver
Co-Authored-By: Claude Opus 4.8
Claude-Session: https://claude.ai/code
---
.../kotlin/KotlinOutputPromptGenerator.kt | 7 +-
.../kotlin/KotlinRenderHelperGenerator.kt | 9 +-
.../kotlin/apidocs/KotlinApiModelBuilder.kt | 4 +-
.../kotlin/KotlinExtractTierCollisionTest.kt | 83 +++++++++++++++++++
4 files changed, 90 insertions(+), 13 deletions(-)
diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinOutputPromptGenerator.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinOutputPromptGenerator.kt
index 771842064..23568a41d 100644
--- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinOutputPromptGenerator.kt
+++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinOutputPromptGenerator.kt
@@ -101,7 +101,8 @@ open class KotlinOutputPromptGenerator : MultiFileDirectGeneratorBase?) { /* unused */ }
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 3f3bda92a..75dabd9a5 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
@@ -94,7 +94,9 @@ open class KotlinRenderHelperGenerator : MultiFileDirectGeneratorBase
Date: Thu, 30 Jul 2026 20:18:36 -0400
Subject: [PATCH 15/19] docs(#228): CHANGELOG + ADR-0044 note for the
extract-tier collision-naming fix
---
CHANGELOG.md | 56 +++++++++++++++++++
...d-record-naming-cross-package-collision.md | 2 +-
2 files changed, 57 insertions(+), 1 deletion(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b977a8710..f9acf8b29 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,62 @@ here. The format follows [Keep a Changelog](https://keepachangelog.com/), and
this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
(pre-1.0; MINOR bumps may introduce breaking changes with notice).
+## [Unreleased]
+
+**Coordinated PATCH (all 5 ports)** — will release across npm / PyPI / Maven Central / NuGet
+together when cut; #228 is a cross-port fix. Existing `meta gen` output is byte-identical for
+every model without a cross-package short-name collision.
+
+### Fixed — extract/output-parser tier and build-time `@payloadRef`/`@responseRef` resolution under a cross-package payload collision (#228)
+
+ADR-0044 (#219/#220) gave every port's *payload-record* emitter collision-scoped naming: two
+cross-package `object.value`s sharing a bare short name (`acme::alpha::Note` /
+`acme::beta::Note`) now each emit a distinct, package-qualified type instead of silently
+colliding. The **extract tier** — the `template.output`/`template.toolcall` parser generator
+that reads a rendered payload back off an LLM response — was a sibling generator ADR-0044
+flagged as a recurrence risk but did not fix: it named and imported nested value-object
+classes by bare short name, so under a real collision it referenced a class the payload
+generator no longer emits. This half is **latent** — the only shipped collision fixture
+(`fixtures/template-output-render-conformance/xpkg-collision/`) used `@format: html`, which the
+extract tier never runs against (it only engages for `@format: json | xml`) — closed by a new
+`xpkg-collision-json` fixture plus a hardcoded per-port collision test, all five ports.
+
+A second, **reachable** bug surfaced auditing the fix: several build-time
+`@payloadRef`/`@responseRef` resolvers — feeding the extract tier, the render-helper /
+output-prompt generators, and (JVM ports) the `meta:verify` template-drift check — resolved a
+bare ref package-blind (first-match by load order, or a bare-tail fallback), while the loader
+validates the same ref package-local per ADR-0042. Under a genuine cross-package bare
+collision this meant the loader accepted object A while codegen silently emitted against
+object B. Fixed by routing every one of these resolvers through each port's canonical
+package-local resolver, threading the referring template's package: Python
+(`resolve_payload_vo` + `render_helper_generator` + `@responseRef`), C# (`VerifyCommand` /
+`BuildPayloadFieldTree`), Java (five call sites consolidated into a new `SpringNaming` helper,
+plus a `LlmTraceHelperGenerator` bare-tail fix), Kotlin (`KotlinGenUtil` — reusing the loader's
+own `SymbolTable` — plus the render-helper, output-prompt, and api-docs generators, and the
+shared `codegen-base/TemplateVerify.java` used by both JVM ports).
+
+A third fix closes a **generated-runtime** analog: TS, Python, and C#'s generated
+output-parser code resolved its *own* `@payloadRef` payload by a bare runtime lookup — wrong
+under a cross-package bare collision between two `template.output`s (or a payload bare-name
+colliding with another root object). All three now bake the fully-qualified name and resolve
+package-locally only when the bare name is genuinely ambiguous; byte-identical when it isn't.
+Java and Kotlin already baked the FQN in generated code and needed no change here.
+
+TS additionally extended its entity-tier collision-scoped naming (previously payload-record
+only) to cover every value-object *reference* site reachable from an entity module —
+write-through read-views, projection declarations, and view declarations — plus a `runner.ts`
+load-order package-binding misbind found in the same pass (the same class of bug #244 fixed
+elsewhere), and fixed a reachable **runtime** wrong-data bug in `runtime-ts`'s
+`extract-object.ts` (a bare-tail fallback resolver that extracted a nested colliding
+value-object using the wrong package's shape).
+
+Byte-identical for every non-colliding model, all ports. Gated by the new
+`xpkg-collision-json` fixture plus a per-port collision test (compile-and-run proof where the
+port's toolchain supports it). Reuses `ERR_PAYLOAD_NAME_COLLISION` — no new error code, no new
+metamodel vocabulary (ADR-0023 unaffected). See
+[ADR-0044](spec/decisions/ADR-0044-payload-record-naming-cross-package-collision.md), whose
+Consequences section now marks this recurrence closed.
+
## [0.20.9] — 2026-07-28
**npm-only** — `migrate-ts` + `codegen-ts` (schema migrations and projection-view codegen
diff --git a/spec/decisions/ADR-0044-payload-record-naming-cross-package-collision.md b/spec/decisions/ADR-0044-payload-record-naming-cross-package-collision.md
index 33f10af3c..99b5fd270 100644
--- a/spec/decisions/ADR-0044-payload-record-naming-cross-package-collision.md
+++ b/spec/decisions/ADR-0044-payload-record-naming-cross-package-collision.md
@@ -48,4 +48,4 @@ This ADR extends, rather than introduces, house doctrine: ADR-0041/0042 already
- **Error code `ERR_PAYLOAD_NAME_COLLISION`** joins the payload/template build-time-error family (peer of `ERR_VAR_NOT_ON_PAYLOAD`). Stage 1 (TS + C#) declares it **locally** in each port's payload-codegen module rather than immediately in the shared cross-port error-code ledger (`packages/metadata/src/errors.ts` `ERROR_CODES` / `fixtures/conformance/ERROR-CODES.json`): that ledger is coverage-checked per port (TS asserts full bidirectional agreement; Python asserts its `ErrorCode` enum is a superset of the corpus), and registering the code before every port implements the corresponding fix would turn ports that haven't shipped it yet red for a codegen path they don't run. The code moves into the shared ledger, mirrored per port, once the Java/Kotlin/Python follow-up lands alongside its implementation.
- **Corpus contract strengthened, not weakened.** `fixtures/template-output-render-conformance/xpkg-collision/README.md` now states plainly: a port's conformance runner must construct its render-time payload from **generator-emitted** types — never hand-author or otherwise reconcile the payload record. The corpus's render-output pins (`"Alpha=AA Beta=BB"`) are untouched: the render engine and the build-time verify field-tree resolve against **metadata**, never against record names, so this ADR does not touch them.
- **Stage 1 (this decision):** the TS reference implementation (`codegen-ts/src/payload-codegen.ts`) and the C# port (`MetaObjects.Codegen/PayloadCodegen.cs`, fixing #219's wrong-node resolution as part of the same pass) ship together. **Stage 2/3 (tracked separately, not in this change):** Python, then Java + Kotlin (the heavier lift — per-output-package closure plumbing plus #220's new collision-conformance suite), followed by a coordinated bug-fix release across all four package registries.
-- **Follow-up, explicitly out of scope here:** #219's own note that the C# port's `CSharpNaming.StripPkg` + bare-short-name-match convention recurs in roughly seven other C# generators (routes, entity, M:N navigation, output-parser, DbContext, reverse-finders, package-binding-resolver) — the same disease, a different blast radius (entity/route naming rather than payload naming), deserving its own audit and ruling.
+- **Follow-up, explicitly out of scope here:** #219's own note that the C# port's `CSharpNaming.StripPkg` + bare-short-name-match convention recurs in roughly seven other C# generators (routes, entity, M:N navigation, output-parser, DbContext, reverse-finders, package-binding-resolver) — the same disease, a different blast radius (entity/route naming rather than payload naming), deserving its own audit and ruling. **Update (#228):** the *output-parser* member of that list — the extract/output-parser tier's own collision-scoped payload naming, plus the package-blind build-time `@payloadRef`/`@responseRef` resolvers it shares with the render-helper, output-prompt, and (JVM) `meta:verify` template-drift generators — is now fixed, cross-port (all five ports, not scoped to C#). The other six C#-specific generators in that list (routes, entity, M:N navigation, DbContext, reverse-finders, package-binding-resolver) remain open, tracked separately.
From b9422c2c3c5ac857da41ecf42c0de1932b03676f Mon Sep 17 00:00:00 2001
From: Doug Mealing
Date: Thu, 30 Jul 2026 20:33:15 -0400
Subject: [PATCH 16/19] refactor(#228): source ERR_PAYLOAD_NAME_COLLISION from
the shared ledger in collision-names
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The local re-declaration in collision-names.ts carried a stale comment
claiming the code was declared locally pending a Java/Kotlin/Python
follow-up before promotion to the shared error-code ledger
(packages/metadata/src/errors.ts). That promotion already happened in
0.19.3 (also present in fixtures/conformance/ERROR-CODES.json and
server/python/src/metaobjects/errors.py). The ledger only exports the
ERROR_CODES array + ErrorCode union type, not per-code named consts, so
the literal stays local but is now bound to the shared ErrorCode type
via `satisfies` — a future rename/removal in the ledger fails this
file's typecheck instead of silently drifting. String value and export
name are unchanged (byte-identical generated output).
---
.../codegen-ts/src/naming/collision-names.ts | 18 +++++++++---------
1 file changed, 9 insertions(+), 9 deletions(-)
diff --git a/server/typescript/packages/codegen-ts/src/naming/collision-names.ts b/server/typescript/packages/codegen-ts/src/naming/collision-names.ts
index d15e8eabe..b2000db3b 100644
--- a/server/typescript/packages/codegen-ts/src/naming/collision-names.ts
+++ b/server/typescript/packages/codegen-ts/src/naming/collision-names.ts
@@ -7,17 +7,17 @@
// package-qualified derived name (PascalCase each package segment + short
// name). A still-colliding derived name fails loud (ERR_PAYLOAD_NAME_COLLISION).
-import { type MetaData, PACKAGE_SEPARATOR } from "@metaobjectsdev/metadata";
+import { type ErrorCode, type MetaData, PACKAGE_SEPARATOR } from "@metaobjectsdev/metadata";
// ADR-0044 backstop error code — a codegen-time (not loader) error, peer of
-// @metaobjectsdev/render's ERR_VAR_NOT_ON_PAYLOAD. Declared LOCALLY rather than
-// added to packages/metadata/src/errors.ts's ERROR_CODES ledger: that ledger is
-// checked for FULL cross-port agreement against fixtures/conformance/ERROR-CODES.json
-// (packages/metadata/test/errors.test.ts) and, on the Python side, for corpus-code
-// coverage — registering it there before every port implements the ADR-0044 fix
-// would turn those OTHER ports' tests red. It moves into the shared ledger once the
-// Java/Kotlin/Python follow-up (ADR-0044 §4, items 3-4) lands alongside this code.
-export const ERR_PAYLOAD_NAME_COLLISION = "ERR_PAYLOAD_NAME_COLLISION";
+// @metaobjectsdev/render's ERR_VAR_NOT_ON_PAYLOAD. Already promoted to the shared
+// cross-language error-code ledger in 0.19.3 (packages/metadata/src/errors.ts's
+// ERROR_CODES, fixtures/conformance/ERROR-CODES.json, server/python/src/metaobjects/errors.py).
+// That ledger has no per-code named export (only the ERROR_CODES array + the ErrorCode
+// union type), so this stays a local literal — but `satisfies ErrorCode` binds it to the
+// shared ledger's type: a future rename/removal there fails this file's typecheck instead
+// of silently drifting.
+export const ERR_PAYLOAD_NAME_COLLISION = "ERR_PAYLOAD_NAME_COLLISION" satisfies ErrorCode;
function pascalSegment(s: string): string {
return s.length > 0 ? s[0]!.toUpperCase() + s.slice(1) : s;
From 672c8ac84d4b902d43ead5fd7e8986f2c4c18e11 Mon Sep 17 00:00:00 2001
From: Doug Mealing
Date: Thu, 30 Jul 2026 20:58:44 -0400
Subject: [PATCH 17/19] no-mistakes(review): fix(#228): bring TS barrel into
collision scope
---
.../codegen-ts/src/generators/barrel.ts | 2 +-
.../test/entity-tier-collision.test.ts | 21 ++++++++++++++++++-
2 files changed, 21 insertions(+), 2 deletions(-)
diff --git a/server/typescript/packages/codegen-ts/src/generators/barrel.ts b/server/typescript/packages/codegen-ts/src/generators/barrel.ts
index cb97d6488..94c45ab83 100644
--- a/server/typescript/packages/codegen-ts/src/generators/barrel.ts
+++ b/server/typescript/packages/codegen-ts/src/generators/barrel.ts
@@ -13,7 +13,7 @@ export const barrel = function barrel(opts?: BarrelOpts): Generator {
path: "index.ts",
content: await formatTs(
renderBarrel(
- entities.map((e) => ({ name: e.name, package: e.package })),
+ entities.map((e) => ({ name: ctx.renderContext!.valueObjectEmittedName(e), package: e.package })),
ctx.renderContext!.extStyle,
ctx.renderContext!.selfTarget,
ctx.renderContext!.entityModuleTarget,
diff --git a/server/typescript/packages/codegen-ts/test/entity-tier-collision.test.ts b/server/typescript/packages/codegen-ts/test/entity-tier-collision.test.ts
index 78680c3c0..baed0cfdf 100644
--- a/server/typescript/packages/codegen-ts/test/entity-tier-collision.test.ts
+++ b/server/typescript/packages/codegen-ts/test/entity-tier-collision.test.ts
@@ -5,6 +5,7 @@ import { join } from "node:path";
import { runGen } from "../src/runner.js";
import { defineConfig } from "../src/metaobjects-config.js";
import { entityFile } from "../src/generators/entity-file.js";
+import { barrel } from "../src/generators/barrel.js";
import { MetaDataLoader, InMemoryStringSource } from "@metaobjectsdev/metadata";
// ADR-0044 / #228 — the ENTITY tier brings the per-value-object entity module
@@ -36,7 +37,7 @@ async function genFiles(root: Awaited>):
// postgres: a single (non-array) field.object jsonb column gets a
// Drizzle `.$type()` — the value-object reference site under test.
dialect: "postgres",
- generators: [entityFile()],
+ generators: [entityFile(), barrel()],
}),
metadata: root,
});
@@ -144,6 +145,17 @@ describe("entity tier — ADR-0044 cross-package value-object short-name collisi
expect(betaHost).toContain("AcmeBetaNoteInsertSchema");
expect(betaHost).toMatch(/\.\$type/);
expect(betaHost).toContain("./AcmeBetaNote.js");
+
+ // Barrel re-exports the COLLISION-SCOPED value-object modules (AcmeAlphaNote /
+ // AcmeBetaNote) — never a bare (duplicated, dangling) `./Note.js`. Entities keep
+ // their bare names. This is the entity-tier collision-scoping closure (#228).
+ const index = files.get("index.ts")!;
+ expect(index).toContain("./AcmeAlphaNote.js");
+ expect(index).toContain("./AcmeBetaNote.js");
+ expect(index).toContain("./AlphaHost.js");
+ expect(index).toContain("./BetaHost.js");
+ expect(index).not.toMatch(/\.\/Note\.js/);
+ expect(index.match(/export \* from/g)?.length ?? 0).toBeGreaterThan(0);
});
test("write-through read-view, projection, and field.map references all use the qualified name", async () => {
@@ -278,5 +290,12 @@ describe("entity tier — ADR-0044 cross-package value-object short-name collisi
expect(host).toContain("WidgetInsertSchema");
expect(host).toMatch(/\.\$type/);
expect(host).toContain("./Widget.js");
+
+ // Barrel no-churn: with no collision the value-object module is re-exported by
+ // its BARE name (qualification never fires) — byte-identical to pre-#228 output.
+ const index = files.get("index.ts")!;
+ expect(index).toContain("./Widget.js");
+ expect(index).toContain("./Host.js");
+ expect(index).not.toMatch(/DemoWidget/);
});
});
From 3c1d4bc4fc1e0e38c671a8e3dd55e6e229d588ff Mon Sep 17 00:00:00 2001
From: Doug Mealing
Date: Thu, 30 Jul 2026 21:23:26 -0400
Subject: [PATCH 18/19] no-mistakes(review): sync reference barrel collision
scope
---
server/typescript/packages/codegen-ts/src/reference/barrel.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/server/typescript/packages/codegen-ts/src/reference/barrel.ts b/server/typescript/packages/codegen-ts/src/reference/barrel.ts
index ca4ac7b7a..74e080c0f 100644
--- a/server/typescript/packages/codegen-ts/src/reference/barrel.ts
+++ b/server/typescript/packages/codegen-ts/src/reference/barrel.ts
@@ -51,7 +51,7 @@ export const barrel = function barrel(opts?: BarrelOpts): Generator {
path: "index.ts",
content: await formatTs(
renderBarrel(
- entities.map((e) => ({ name: e.name, package: e.package })),
+ entities.map((e) => ({ name: ctx.renderContext!.valueObjectEmittedName(e), package: e.package })),
ctx.renderContext!.extStyle,
ctx.renderContext!.selfTarget,
ctx.renderContext!.entityModuleTarget,
From 3f03af76ca108203000790252d47ab1e7a10d6df Mon Sep 17 00:00:00 2001
From: Doug Mealing
Date: Thu, 30 Jul 2026 21:48:46 -0400
Subject: [PATCH 19/19] no-mistakes(document): docs(#228): reconcile stale
error-code ledger note in ADR-0044
---
.../ADR-0044-payload-record-naming-cross-package-collision.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/spec/decisions/ADR-0044-payload-record-naming-cross-package-collision.md b/spec/decisions/ADR-0044-payload-record-naming-cross-package-collision.md
index 99b5fd270..37643e505 100644
--- a/spec/decisions/ADR-0044-payload-record-naming-cross-package-collision.md
+++ b/spec/decisions/ADR-0044-payload-record-naming-cross-package-collision.md
@@ -45,7 +45,7 @@ This ADR extends, rather than introduces, house doctrine: ADR-0041/0042 already
- **Non-colliding common case: byte-identical output, all ports.** No consumer sees any change unless their own metadata actually contains a same-short-name collision reachable from one payload artifact.
- **A newly-introduced collision is a visible, compile-time rename**, not a silent behavior change — the qualified name propagates to the declaration, the file/module (where applicable), and every reference in one coordinated regen.
-- **Error code `ERR_PAYLOAD_NAME_COLLISION`** joins the payload/template build-time-error family (peer of `ERR_VAR_NOT_ON_PAYLOAD`). Stage 1 (TS + C#) declares it **locally** in each port's payload-codegen module rather than immediately in the shared cross-port error-code ledger (`packages/metadata/src/errors.ts` `ERROR_CODES` / `fixtures/conformance/ERROR-CODES.json`): that ledger is coverage-checked per port (TS asserts full bidirectional agreement; Python asserts its `ErrorCode` enum is a superset of the corpus), and registering the code before every port implements the corresponding fix would turn ports that haven't shipped it yet red for a codegen path they don't run. The code moves into the shared ledger, mirrored per port, once the Java/Kotlin/Python follow-up lands alongside its implementation.
+- **Error code `ERR_PAYLOAD_NAME_COLLISION`** joins the payload/template build-time-error family (peer of `ERR_VAR_NOT_ON_PAYLOAD`). Stage 1 (TS + C#) declares it **locally** in each port's payload-codegen module rather than immediately in the shared cross-port error-code ledger (`packages/metadata/src/errors.ts` `ERROR_CODES` / `fixtures/conformance/ERROR-CODES.json`): that ledger is coverage-checked per port (TS asserts full bidirectional agreement; Python asserts its `ErrorCode` enum is a superset of the corpus), and registering the code before every port implements the corresponding fix would turn ports that haven't shipped it yet red for a codegen path they don't run. That promotion landed in 0.19.3 with the Java/Kotlin/Python payload-record follow-up: the code now lives in the shared cross-port error-code ledger — `fixtures/conformance/ERROR-CODES.json` plus TypeScript `errors.ts`, Python `errors.py`, and Java `ErrorCode.java` (the registries that gate against the corpus) — with C# and Kotlin retaining a port-local constant of the same value, so #228's shared TS/Python collision-naming modules source it from the ledger rather than redeclaring it.
- **Corpus contract strengthened, not weakened.** `fixtures/template-output-render-conformance/xpkg-collision/README.md` now states plainly: a port's conformance runner must construct its render-time payload from **generator-emitted** types — never hand-author or otherwise reconcile the payload record. The corpus's render-output pins (`"Alpha=AA Beta=BB"`) are untouched: the render engine and the build-time verify field-tree resolve against **metadata**, never against record names, so this ADR does not touch them.
- **Stage 1 (this decision):** the TS reference implementation (`codegen-ts/src/payload-codegen.ts`) and the C# port (`MetaObjects.Codegen/PayloadCodegen.cs`, fixing #219's wrong-node resolution as part of the same pass) ship together. **Stage 2/3 (tracked separately, not in this change):** Python, then Java + Kotlin (the heavier lift — per-output-package closure plumbing plus #220's new collision-conformance suite), followed by a coordinated bug-fix release across all four package registries.
- **Follow-up, explicitly out of scope here:** #219's own note that the C# port's `CSharpNaming.StripPkg` + bare-short-name-match convention recurs in roughly seven other C# generators (routes, entity, M:N navigation, output-parser, DbContext, reverse-finders, package-binding-resolver) — the same disease, a different blast radius (entity/route naming rather than payload naming), deserving its own audit and ruling. **Update (#228):** the *output-parser* member of that list — the extract/output-parser tier's own collision-scoped payload naming, plus the package-blind build-time `@payloadRef`/`@responseRef` resolvers it shares with the render-helper, output-prompt, and (JVM) `meta:verify` template-drift generators — is now fixed, cross-port (all five ports, not scoped to C#). The other six C#-specific generators in that list (routes, entity, M:N navigation, DbContext, reverse-finders, package-binding-resolver) remain open, tracked separately.