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> { + const result = await runGen({ + config: defineConfig({ + outDir: tmp, + extStyle: "js", + dbImport: "../db", + // postgres: a single (non-array) field.object jsonb column gets a + // Drizzle `.$type()` — the value-object reference site under test. + dialect: "postgres", + generators: [entityFile()], + }), + metadata: root, + }); + expect(result.conflicts).toEqual([]); + const out = new Map(); + for (const name of readdirSync(tmp)) { + out.set(name, readFileSync(join(tmp, name), "utf8")); + } + return out; +} + +describe("entity tier — ADR-0044 cross-package value-object short-name collision (#228)", () => { + test("two same-short-name object.values emit distinct qualified interfaces/modules and every reference uses the qualified name", async () => { + const root = await loadMultiPackageRoot([ + { + package: "acme::alpha", + children: [ + { + "object.value": { + name: "Note", + children: [{ "field.string": { name: "alphaText", "@required": true } }], + }, + }, + { + // A value object nesting a colliding value object — exercises the + // inferred-types field.object reference branch. + "object.value": { + name: "AlphaWrap", + children: [{ "field.object": { name: "inner", "@objectRef": "acme::alpha::Note" } }], + }, + }, + { + // A writable entity referencing a colliding value object via a jsonb + // field.object — exercises Zod InsertSchema + Drizzle .$type<>(). + "object.entity": { + name: "AlphaHost", + children: [ + { "source.rdb": { "@table": "alpha_hosts" } }, + { "field.long": { name: "id" } }, + { "field.object": { name: "note", "@objectRef": "acme::alpha::Note", "@storage": "jsonb" } }, + { "identity.primary": { name: "primary", "@fields": ["id"], "@generation": "increment" } }, + ], + }, + }, + ], + }, + { + package: "acme::beta", + children: [ + { + "object.value": { + name: "Note", + children: [{ "field.string": { name: "betaText", "@required": true } }], + }, + }, + { + "object.entity": { + name: "BetaHost", + children: [ + { "source.rdb": { "@table": "beta_hosts" } }, + { "field.long": { name: "id" } }, + { "field.object": { name: "note", "@objectRef": "acme::beta::Note", "@storage": "jsonb" } }, + { "identity.primary": { name: "primary", "@fields": ["id"], "@generation": "increment" } }, + ], + }, + }, + ], + }, + ]); + + const files = await genFiles(root); + const names = [...files.keys()].sort(); + + // Distinct qualified module files — NEVER a single collision-losing Note.ts. + expect(names).toContain("AcmeAlphaNote.ts"); + expect(names).toContain("AcmeBetaNote.ts"); + expect(names).not.toContain("Note.ts"); + + const alphaNote = files.get("AcmeAlphaNote.ts")!; + expect(alphaNote).toContain("export interface AcmeAlphaNote {"); + expect(alphaNote).toContain("export const AcmeAlphaNoteInsertSchema"); + expect(alphaNote).toMatch(/alphaText:\s*string;/); + + const betaNote = files.get("AcmeBetaNote.ts")!; + expect(betaNote).toContain("export interface AcmeBetaNote {"); + expect(betaNote).toContain("export const AcmeBetaNoteInsertSchema"); + expect(betaNote).toMatch(/betaText:\s*string;/); + + // inferred-types field.object reference (VO -> VO) uses the qualified name + + // imports the qualified module — NOT the bare `Note`. + const alphaWrap = files.get("AlphaWrap.ts")!; + expect(alphaWrap).toMatch(/inner\?:\s*AcmeAlphaNote;/); + expect(alphaWrap).not.toMatch(/inner\?:\s*Note;/); + expect(alphaWrap).toContain("./AcmeAlphaNote.js"); + + // Zod InsertSchema + Drizzle .$type<>() on the entity use the qualified name. + const alphaHost = files.get("AlphaHost.ts")!; + expect(alphaHost).toContain("AcmeAlphaNoteInsertSchema"); + expect(alphaHost).toMatch(/\.\$type/); + expect(alphaHost).toContain("./AcmeAlphaNote.js"); + expect(alphaHost).not.toMatch(/[^a-zA-Z]NoteInsertSchema/); + expect(alphaHost).not.toMatch(/\.\$type/); + + const betaHost = files.get("BetaHost.ts")!; + expect(betaHost).toContain("AcmeBetaNoteInsertSchema"); + expect(betaHost).toMatch(/\.\$type/); + expect(betaHost).toContain("./AcmeBetaNote.js"); + }); + + test("no-churn — a non-colliding value object keeps its BARE name/module (qualification never fires)", async () => { + const root = await loadMultiPackageRoot([ + { + package: "demo", + children: [ + { + "object.value": { + name: "Widget", + children: [{ "field.string": { name: "label", "@required": true } }], + }, + }, + { + "object.value": { + name: "Wrap", + children: [{ "field.object": { name: "w", "@objectRef": "Widget" } }], + }, + }, + { + "object.entity": { + name: "Host", + children: [ + { "source.rdb": { "@table": "hosts" } }, + { "field.long": { name: "id" } }, + { "field.object": { name: "w", "@objectRef": "Widget", "@storage": "jsonb" } }, + { "identity.primary": { name: "primary", "@fields": ["id"], "@generation": "increment" } }, + ], + }, + }, + ], + }, + ]); + + const files = await genFiles(root); + const names = [...files.keys()]; + + // Bare, unqualified names — no package qualification when there is no collision. + expect(names).toContain("Widget.ts"); + expect(names).not.toContain("DemoWidget.ts"); + + const widget = files.get("Widget.ts")!; + expect(widget).toContain("export interface Widget {"); + expect(widget).toContain("export const WidgetInsertSchema"); + + const wrap = files.get("Wrap.ts")!; + expect(wrap).toMatch(/w\?:\s*Widget;/); + expect(wrap).toContain("./Widget.js"); + + const host = files.get("Host.ts")!; + expect(host).toContain("WidgetInsertSchema"); + expect(host).toMatch(/\.\$type/); + expect(host).toContain("./Widget.js"); + }); +}); From 8918b3a3f8c70544b23fc077b45d4131e39f04e9 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Thu, 30 Jul 2026 15:58:47 -0400 Subject: [PATCH 05/19] fix(#228): collision-scope write-through/projection/view VO refs; share field-package helper Review round 1: route the write-through read-view, projection, and view-decl value-object reference sites through resolveValueObjectName for BOTH the imported symbol and its module (were bare, misbinding under a cross-package collision); extract the field-declaring-package expression into one shared fieldDeclaringPackage helper used by all six ref sites; tighten the runner collision domain to the EMITTED value-object set (exclude non-emitted abstract VOs). Docs-tier fieldTsTypeString left bare with a #228 note (deprecated meta docs, no ctx in scope). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01HLoJkFSyoticveo5ehMUAr --- .../packages/codegen-ts/src/render-context.ts | 13 ++- .../packages/codegen-ts/src/runner.ts | 21 +++-- .../src/templates/drizzle-schema.ts | 7 +- .../codegen-ts/src/templates/entity-file.ts | 20 +++-- .../src/templates/inferred-types.ts | 20 +++-- .../src/templates/projection-decl.ts | 30 ++++--- .../codegen-ts/src/templates/view-decl.ts | 48 ++++++----- .../src/templates/zod-validators.ts | 14 +--- .../test/entity-tier-collision.test.ts | 81 +++++++++++++++++++ 9 files changed, 187 insertions(+), 67 deletions(-) diff --git a/server/typescript/packages/codegen-ts/src/render-context.ts b/server/typescript/packages/codegen-ts/src/render-context.ts index 059362578..2400f2753 100644 --- a/server/typescript/packages/codegen-ts/src/render-context.ts +++ b/server/typescript/packages/codegen-ts/src/render-context.ts @@ -1,6 +1,6 @@ // RenderContext — cross-cutting state passed to every template. -import type { MetaRoot, MetaData } from "@metaobjectsdev/metadata"; +import type { MetaRoot, MetaData, MetaField } from "@metaobjectsdev/metadata"; import { resolveObjectRef, stripPackage } from "@metaobjectsdev/metadata"; import type { Dialect } from "./column-mapper.js"; import type { PkInfo } from "./pk-resolver.js"; @@ -118,6 +118,17 @@ export type RenderContextInput = Omit; }; +/** ADR-0042/#228 — the package a field's `@objectRef` resolves in: the FIELD's OWN + * declaring package (which differs from the referring object's when the field is + * inherited via `extends` from an abstract node in another package), falling back + * to `fallbackPkg` (the referring object's package). THE single source of truth for + * the referrer package passed to `RenderContext.resolveValueObjectName`, so every + * value-object reference site resolves a cross-package short-name collision + * identically (they cannot drift). Mirrors payload-codegen's `collectClosure`. */ +export function fieldDeclaringPackage(field: MetaField, fallbackPkg: string | undefined): string | undefined { + return field.parent?.package ?? field.parent?.fileDefaultPackage ?? fallbackPkg; +} + /** Append the configured extension to a cross-entity module specifier (which is * always a bare, extension-less relative path like `./Foo`). */ export function withExt(spec: string, style: ExtStyle): string { diff --git a/server/typescript/packages/codegen-ts/src/runner.ts b/server/typescript/packages/codegen-ts/src/runner.ts index dcc869e18..415eb5c23 100644 --- a/server/typescript/packages/codegen-ts/src/runner.ts +++ b/server/typescript/packages/codegen-ts/src/runner.ts @@ -3,6 +3,7 @@ import { tmpdir } from "node:os"; import type { MetaData, MetaObject } from "@metaobjectsdev/metadata"; import { MetaRoot, OBJECT_SUBTYPE_VALUE } from "@metaobjectsdev/metadata"; import { assignEmittedNames } from "./naming/collision-names.js"; +import { isAbstract } from "./instance-artifacts.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"; @@ -144,17 +145,23 @@ export async function runGen(opts: RunGenOpts): Promise { // 3. Build shared render state once. const pkMap = buildPkMap(root); const relationMap = buildRelationMap(root); - // ADR-0044/#228 — the ENTITY-tier collision domain is the run's emitted + // 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). + // ONCE over every top-level `object.value` that actually produces a file, 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). A + // NON-emitted abstract value object (abstract + emitAbstractShapes off) produces + // no file/reference and is excluded — the entity-file generator's own emit gate + // (`isAbstract && !emitAbstractShapes` ⇒ skip) — so it can't over-qualify a + // concrete value object that merely shares its bare name in another package. + const isEmittedValueObject = (o: MetaObject): boolean => + o.subType === OBJECT_SUBTYPE_VALUE && (!isAbstract(o) || config.emitAbstractShapes); const valueObjectClosure = new Map(); for (const o of root.objects()) { - if (o.subType === OBJECT_SUBTYPE_VALUE) valueObjectClosure.set(o.resolutionKey(), o); + if (isEmittedValueObject(o)) valueObjectClosure.set(o.resolutionKey(), o); } const valueObjectNames = assignEmittedNames(valueObjectClosure); // `packageOf` keys value objects by their EMITTED name (unique by construction 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 b1cb56d95..52fced31c 100644 --- a/server/typescript/packages/codegen-ts/src/templates/drizzle-schema.ts +++ b/server/typescript/packages/codegen-ts/src/templates/drizzle-schema.ts @@ -11,7 +11,7 @@ import { FIELD_ATTR_AUTO_SET, FIELD_ATTR_OBJECT_REF, } from "@metaobjectsdev/metadata"; -import { type RenderContext } from "../render-context.js"; +import { fieldDeclaringPackage, type RenderContext } from "../render-context.js"; import { crossEntitySpecifier, valueObjectModuleSpecifier } from "../import-path.js"; import { mapColumnType, type ColumnSpec } from "../column-mapper.js"; import { tableNameFromEntity, columnNameFromField } from "../naming.js"; @@ -394,8 +394,9 @@ function renderColumn( // 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; + const emitted = typeof refRaw === "string" + ? ctx.resolveValueObjectName(refRaw, fieldDeclaringPackage(field, entityPackage)) + : name; return imp(`${emitted}@${valueObjectModuleSpecifier(emitted, ctx.packageOf, entityPackage, ctx.outputLayout, ctx.extStyle)}`); }; diff --git a/server/typescript/packages/codegen-ts/src/templates/entity-file.ts b/server/typescript/packages/codegen-ts/src/templates/entity-file.ts index 0fea0f7eb..5a34b8fdc 100644 --- a/server/typescript/packages/codegen-ts/src/templates/entity-file.ts +++ b/server/typescript/packages/codegen-ts/src/templates/entity-file.ts @@ -7,8 +7,9 @@ // vanilla / write-through entity → Drizzle table path import { code, imp, joinCode, type Code } from "ts-poet"; -import type { MetaObject } from "@metaobjectsdev/metadata"; -import type { RenderContext } from "../render-context.js"; +import type { MetaObject, MetaField } from "@metaobjectsdev/metadata"; +import { FIELD_ATTR_OBJECT_REF } from "@metaobjectsdev/metadata"; +import { fieldDeclaringPackage, type RenderContext } from "../render-context.js"; import { renderDrizzleSchema } from "./drizzle-schema.js"; import { renderInferredTypes, renderEnumTypeAliases } from "./inferred-types.js"; import { renderZodValidators, isTphSubtype } from "./zod-validators.js"; @@ -23,7 +24,6 @@ import { projectionViewName } from "../projection/extract-view-spec.js"; import { renderExistingViewDecl, renderViewReadZodObject } from "./view-decl.js"; import { renderDocsFor } from "./jsdoc.js"; import { valueObjectModuleSpecifier } from "../import-path.js"; -import { stripPackage } from "@metaobjectsdev/metadata"; import { hasWritableRdbSource } from "../source-detect.js"; import { renderValueObjectFile } from "./value-object-file.js"; import { isAbstract } from "../instance-artifacts.js"; @@ -131,10 +131,18 @@ export function renderEntityFile( if (writeThrough) { const camel = entity.name.charAt(0).toLowerCase() + entity.name.slice(1); const fields = entity.fields(); - const voModule = (refBase: string): string => - valueObjectModuleSpecifier(stripPackage(refBase), ctx.packageOf, entity.package, ctx.outputLayout, ctx.extStyle); + // ADR-0044/#228 — resolve a view column's `@objectRef` to the value object's + // EMITTED name + module TOGETHER (lock-step), so the read-view artifact imports + // `AcmeAlphaNote` from `./AcmeAlphaNote.js` (not a bare `Note` → `./Note.js`) + // under a cross-package short-name collision. + const voRef = (field: MetaField): { name: string; module: string } => { + const ref = field.attr(FIELD_ATTR_OBJECT_REF); + const name = ctx.resolveValueObjectName(typeof ref === "string" ? ref : "", fieldDeclaringPackage(field, entity.package)); + const module = valueObjectModuleSpecifier(name, ctx.packageOf, entity.package, ctx.outputLayout, ctx.extStyle); + return { name, module }; + }; const viewOpts = { - dialect: ctx.dialect, columnNamingStrategy: ctx.columnNamingStrategy, timestampMode: ctx.timestampMode, voModule, + dialect: ctx.dialect, columnNamingStrategy: ctx.columnNamingStrategy, timestampMode: ctx.timestampMode, voRef, }; const z = imp("z@zod"); const docs = renderDocsFor(entity); 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 017a34241..d15dc481b 100644 --- a/server/typescript/packages/codegen-ts/src/templates/inferred-types.ts +++ b/server/typescript/packages/codegen-ts/src/templates/inferred-types.ts @@ -40,7 +40,7 @@ import { enumValues } from "../enum-meta.js"; import { renderDocsFor } from "./jsdoc.js"; import { sharedEnumForField } from "../enum-shared.js"; import { sharedEnumImportSpecifier, providedEnumImportSpecifier } from "../enum-import.js"; -import type { RenderContext } from "../render-context.js"; +import { fieldDeclaringPackage, type RenderContext } from "../render-context.js"; /** * Emit Drizzle's InferSelectModel / InferInsertModel aliases for an entity. @@ -219,6 +219,12 @@ export function fieldTsTypeString(ownerName: string, field: MetaField): string { if (field.subType === FIELD_SUBTYPE_OBJECT) { const ref = field.attr(FIELD_ATTR_OBJECT_REF); if (typeof ref === "string" && ref.length > 0) { + // #228: docs-tier bare name under collision — this is the deprecated `meta docs` + // TEXT-shape helper (no ctx/root in scope; callers api-field-shape run under + // api-model's `{ pkMap } as RenderContext` shim), so it can't resolve the ADR-0044 + // emitted name. Byte-identical to codegen in every non-colliding model; on a + // cross-package collision it documents the bare `Note` while codegen emits + // `AcmeAlphaNote`. Threading a real RenderContext into api-docs is out of scope. const base = stripPackage(ref); return field.resolvedIsArray() ? `${base}[]` : base; } @@ -240,14 +246,6 @@ 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 @@ -277,7 +275,7 @@ function valueObjectFieldType(entity: MetaObject, field: MetaField, ctx?: Render // 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 refName = ctx ? ctx.resolveValueObjectName(ref, fieldDeclaringPackage(field, entity.package)) : stripPackage(ref); const moduleSpec = ctx ? valueObjectModuleSpecifier(refName, ctx.packageOf, entity.package, ctx.outputLayout, ctx.extStyle) : `./${refName}.js`; @@ -291,7 +289,7 @@ 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 refName = ctx ? ctx.resolveValueObjectName(ref, refPkg(field, entity)) : stripPackage(ref); + const refName = ctx ? ctx.resolveValueObjectName(ref, fieldDeclaringPackage(field, entity.package)) : stripPackage(ref); const moduleSpec = ctx ? valueObjectModuleSpecifier(refName, ctx.packageOf, entity.package, ctx.outputLayout, ctx.extStyle) : `./${refName}.js`; diff --git a/server/typescript/packages/codegen-ts/src/templates/projection-decl.ts b/server/typescript/packages/codegen-ts/src/templates/projection-decl.ts index 9ab89f8e1..86c331264 100644 --- a/server/typescript/packages/codegen-ts/src/templates/projection-decl.ts +++ b/server/typescript/packages/codegen-ts/src/templates/projection-decl.ts @@ -10,12 +10,13 @@ import { code, imp, joinCode, type Code } from "ts-poet"; import { MetaField, MetaObject, type MetaRoot, + FIELD_ATTR_OBJECT_REF, stripPackage, } from "@metaobjectsdev/metadata"; import { projectionViewName } from "../projection/extract-view-spec.js"; import { columnNameFromField, toSnakeCase, pluralize } from "../naming.js"; import { GENERATED_HEADER } from "../constants.js"; import type { ColumnNamingStrategy } from "../metaobjects-config.js"; -import type { RenderContext } from "../render-context.js"; +import { fieldDeclaringPackage, type RenderContext } from "../render-context.js"; import { valueObjectModuleSpecifier } from "../import-path.js"; import { renderFilterAllowlist, renderSortAllowlist } from "./filter-allowlist.js"; import { renderFilterType } from "./filter-type.js"; @@ -92,13 +93,22 @@ export function renderProjectionDecl( ): string { const { dialect, columnNamingStrategy, apiPrefix = "", timestampMode = "string", allowlists = true, ctx, includeViewDecl = true } = opts; - // Resolve a value-object name → its import module. Layout/package/extStyle-aware - // when a render context is present (so the projection's VO imports match the - // entity's), else a flat same-dir import — identical to zodFieldExpr's fallback. - const voModule = (refBase: string): string => - ctx - ? valueObjectModuleSpecifier(refBase, ctx.packageOf, projection.package, ctx.outputLayout, ctx.extStyle) - : `./${refBase}.js`; + // ADR-0044/#228 — resolve a projection field's `@objectRef` to the value object's + // EMITTED name + module TOGETHER (lock-step): bare when unique in the run, + // package-qualified on a cross-package short-name collision, so the projection's + // VO import matches the entity's. Layout/package/extStyle-aware when a render + // context is present, else a flat same-dir import (zodFieldExpr's fallback). + const voRef = (field: MetaField): { name: string; module: string } => { + const ref = field.attr(FIELD_ATTR_OBJECT_REF); + const rawRef = typeof ref === "string" ? ref : ""; + const name = ctx + ? ctx.resolveValueObjectName(rawRef, fieldDeclaringPackage(field, projection.package)) + : stripPackage(rawRef); + const module = ctx + ? valueObjectModuleSpecifier(name, ctx.packageOf, projection.package, ctx.outputLayout, ctx.extStyle) + : `./${name}.js`; + return { name, module }; + }; const z = imp("z@zod"); @@ -145,12 +155,12 @@ export function renderProjectionDecl( const sections: Code[] = [ ...(includeViewDecl ? [renderExistingViewDecl(allFields, viewName, `${camelName}View`, { - dialect, columnNamingStrategy, timestampMode, voModule, + dialect, columnNamingStrategy, timestampMode, voRef, })] : []), code` export const ${projName}Schema = ${renderViewReadZodObject(allFields, { - dialect, columnNamingStrategy, timestampMode, voModule, + dialect, columnNamingStrategy, timestampMode, voRef, })}; `, code` diff --git a/server/typescript/packages/codegen-ts/src/templates/view-decl.ts b/server/typescript/packages/codegen-ts/src/templates/view-decl.ts index abec2a635..254974cbb 100644 --- a/server/typescript/packages/codegen-ts/src/templates/view-decl.ts +++ b/server/typescript/packages/codegen-ts/src/templates/view-decl.ts @@ -9,7 +9,7 @@ import { code, imp, joinCode, type Code } from "ts-poet"; import { - type MetaField, FIELD_SUBTYPE_OBJECT, FIELD_ATTR_OBJECT_REF, stripPackage, + type MetaField, FIELD_SUBTYPE_OBJECT, FIELD_ATTR_OBJECT_REF, } from "@metaobjectsdev/metadata"; import type { ColumnNamingStrategy } from "../metaobjects-config.js"; import { mapColumnType } from "../column-mapper.js"; @@ -20,8 +20,15 @@ export interface ViewDeclOpts { readonly columnNamingStrategy: ColumnNamingStrategy; /** Drives the timestamp column TS type (Date vs string) in the view declaration. */ readonly timestampMode: "date" | "string"; - /** Resolve a value-object short name → its import module specifier. */ - readonly voModule: (refBase: string) => string; + /** + * ADR-0044/#228 — resolve a `field.object` / `field.map`'s `@objectRef` to the + * value object's EMITTED name (bare when unique in the run, package-qualified on + * a cross-package short-name collision) AND its import module, TOGETHER, so the + * imported symbol and its module can never diverge (a bare `Note` symbol pointing + * at an `./AcmeAlphaNote.js` module, or vice-versa). Callers build this from + * `RenderContext.resolveValueObjectName` + `valueObjectModuleSpecifier`. + */ + readonly voRef: (field: MetaField) => { name: string; module: string }; } /** @@ -31,7 +38,7 @@ export interface ViewDeclOpts { * views carry type + physical name only (no PK/default/notNull DDL modifiers). */ function viewColumnLine(f: MetaField, opts: ViewDeclOpts): Code { - const { dialect, columnNamingStrategy, timestampMode, voModule } = opts; + const { dialect, columnNamingStrategy, timestampMode } = opts; const spec = mapColumnType(f, dialect, columnNamingStrategy, timestampMode); const colSym = imp(`${spec.fnName}@${spec.importModule}`); const optsArg = @@ -53,12 +60,17 @@ function viewColumnLine(f: MetaField, opts: ViewDeclOpts): Code { if (dtr?.kind === "scalar") { dollarType = `.$type<${dtr.tsType}${dtr.array ? "[]" : ""}>()`; } else if (dtr?.kind === "objectRef") { - const voTypeSym = imp(`${dtr.name}@${voModule(dtr.name)}`); + // #228 — emitted name + module resolved together (lock-step) from the field's ref. + const vo = opts.voRef(f); + const voTypeSym = imp(`${vo.name}@${vo.module}`); dollarType = dtr.array ? code`.$type<${voTypeSym}[]>()` : code`.$type<${voTypeSym}>()`; } else if (dtr?.kind === "map") { - dollarType = "scalar" in dtr.value - ? `.$type>()` - : code`.$type>()`; + if ("scalar" in dtr.value) { + dollarType = `.$type>()`; + } else { + const vo = opts.voRef(f); + dollarType = code`.$type>()`; + } } return code` ${f.name}: ${colSym}(${JSON.stringify(spec.dbName)}${optsArg})${dollarType}${viewModifiers}`; } @@ -98,22 +110,22 @@ ${joinCode(viewColumnLines, { on: ",\n" })} * `voModule` resolves a value-object short name → its import module. */ export function renderViewReadZodObject(fields: readonly MetaField[], opts: ViewDeclOpts): Code { - const { dialect, columnNamingStrategy, timestampMode, voModule } = opts; + const { dialect, columnNamingStrategy, timestampMode } = opts; const z = imp("z@zod"); const lines: Code[] = fields.map((f) => { const nullable = mapColumnType(f, dialect, columnNamingStrategy, timestampMode).modifiers.includes(".notNull()") ? "" : ".nullable()"; - const refBase = - f.subType === FIELD_SUBTYPE_OBJECT - ? (() => { - const ref = f.attr(FIELD_ATTR_OBJECT_REF); - return typeof ref === "string" && ref.length > 0 ? stripPackage(ref) : undefined; - })() - : undefined; - if (refBase) { - const schemaSym = imp(`${refBase}InsertSchema@${voModule(refBase)}`); + const hasObjectRef = + f.subType === FIELD_SUBTYPE_OBJECT && + typeof f.attr(FIELD_ATTR_OBJECT_REF) === "string" && + (f.attr(FIELD_ATTR_OBJECT_REF) as string).length > 0; + if (hasObjectRef) { + // #228 — the InsertSchema symbol + its module resolved together from the + // field's ref, so a cross-package collision qualifies both consistently. + const vo = opts.voRef(f); + const schemaSym = imp(`${vo.name}InsertSchema@${vo.module}`); const base = f.resolvedIsArray() ? code`${z}.array(${schemaSym})` : code`${schemaSym}`; return code` ${f.name}: ${base}${nullable}`; } 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 08ff6ce45..9b89a4795 100644 --- a/server/typescript/packages/codegen-ts/src/templates/zod-validators.ts +++ b/server/typescript/packages/codegen-ts/src/templates/zod-validators.ts @@ -35,7 +35,7 @@ import { renderDocsFor } from "./jsdoc.js"; import { sharedEnumForField } from "../enum-shared.js"; import { sharedEnumImportSpecifier } from "../enum-import.js"; import { sharedEnumZodConstName } from "./enums-file.js"; -import type { RenderContext } from "../render-context.js"; +import { fieldDeclaringPackage, type RenderContext } from "../render-context.js"; import { valueObjectModuleSpecifier } from "../import-path.js"; // FR-035: the SAME required-predicate that drives the Drizzle column's .notNull() // drives the UpdateSchema's .nullable() exclusion — shared so they cannot drift. @@ -432,14 +432,6 @@ 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 @@ -471,7 +463,7 @@ function zodFieldExpr(field: MetaField, owner?: MetaObject, ctx?: RenderContext) // 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 refName = (ctx && owner) ? ctx.resolveValueObjectName(ref, fieldDeclaringPackage(field, owner.package)) : stripPackage(ref); const moduleSpec = (ctx && owner) ? valueObjectModuleSpecifier(refName, ctx.packageOf, owner.package, ctx.outputLayout, ctx.extStyle) : `./${refName}.js`; @@ -492,7 +484,7 @@ 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 refName = (ctx && owner) ? ctx.resolveValueObjectName(ref, voRefPkg(field, owner)) : stripPackage(ref); + const refName = (ctx && owner) ? ctx.resolveValueObjectName(ref, fieldDeclaringPackage(field, owner.package)) : stripPackage(ref); const moduleSpec = (ctx && owner) ? valueObjectModuleSpecifier(refName, ctx.packageOf, owner.package, ctx.outputLayout, ctx.extStyle) : `./${refName}.js`; 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 c230da01c..78680c3c0 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 @@ -146,6 +146,87 @@ describe("entity tier — ADR-0044 cross-package value-object short-name collisi expect(betaHost).toContain("./AcmeBetaNote.js"); }); + test("write-through read-view, projection, and field.map references all use the qualified name", async () => { + const root = await loadMultiPackageRoot([ + { + package: "acme::alpha", + children: [ + { + "object.value": { + name: "Note", + children: [{ "field.string": { name: "alphaText", "@required": true } }], + }, + }, + { + // Write-through entity (writable table + read-only replica view) carrying + // a jsonb field.object AND a field.map of the colliding VO. Exercises the + // entity-file.ts write-through `voRef` (fix #1) + view-decl.ts (fix #3) + + // the field.map reference branch. + "object.entity": { + name: "AlphaReport", + children: [ + { "source.rdb": { "@role": "primary", "@table": "alpha_reports" } }, + { "source.rdb": { "@role": "replica", "@kind": "view", "@table": "v_alpha_reports" } }, + { "field.long": { name: "id" } }, + { "field.object": { name: "note", "@objectRef": "acme::alpha::Note", "@storage": "jsonb" } }, + { "field.map": { name: "tags", "@objectRef": "acme::alpha::Note" } }, + { "identity.primary": { name: "pk", "@fields": "id" } }, + ], + }, + }, + { + // Projection extends-binding the write-through entity's VO column. + // Exercises the projection-decl.ts `voRef` (fix #2) + view-decl.ts. + "object.projection": { + name: "AlphaNoteView", + children: [ + { "source.rdb": { "@kind": "view", "@table": "v_alpha_note" } }, + { "field.long": { name: "id", extends: "AlphaReport.id" } }, + { "field.object": { name: "note", extends: "AlphaReport.note" } }, + { "identity.primary": { extends: "AlphaReport.pk" } }, + ], + }, + }, + ], + }, + { + // The collision partner — forces acme::alpha::Note to qualify to AcmeAlphaNote. + package: "acme::beta", + children: [ + { + "object.value": { + name: "Note", + children: [{ "field.string": { name: "betaText", "@required": true } }], + }, + }, + ], + }, + ]); + + const files = await genFiles(root); + + // Write-through entity: BOTH the read-view Zod schema (renderViewReadZodObject) + // and the `.existing()` view decl (renderExistingViewDecl) must qualify — no bare + // `Note` symbol/type may leak into the shipped write-through artifact. + const alphaReport = files.get("AlphaReport.ts")!; + expect(alphaReport).toContain("export const AlphaReportSchema"); // read-view zod + expect(alphaReport).toContain("alphaReportView"); // .existing() view var + expect(alphaReport).toContain("AcmeAlphaNoteInsertSchema"); + expect(alphaReport).toMatch(/\.\$type/); + expect(alphaReport).toContain("./AcmeAlphaNote.js"); + expect(alphaReport).not.toMatch(/[^a-zA-Z]NoteInsertSchema/); + expect(alphaReport).not.toMatch(/\.\$type/); + // field.map reference (Record) qualifies too. + expect(alphaReport).toMatch(/Record/); + expect(alphaReport).not.toMatch(/Record/); + + // Projection read model imports the qualified VO schema/module (not bare `Note`). + const alphaNoteView = files.get("AlphaNoteView.ts")!; + expect(alphaNoteView).toContain("AcmeAlphaNoteInsertSchema"); + expect(alphaNoteView).toContain("./AcmeAlphaNote.js"); + expect(alphaNoteView).not.toMatch(/[^a-zA-Z]NoteInsertSchema/); + }); + test("no-churn — a non-colliding value object keeps its BARE name/module (qualification never fires)", async () => { const root = await loadMultiPackageRoot([ { From 360d8ab27272f700dce3cefdb396eed2e840b9fb Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Thu, 30 Jul 2026 16:23:45 -0400 Subject: [PATCH 06/19] fix(#228): TS extract/output-parser tier uses collision-scoped names Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code --- .../src/generators/extractor-file.ts | 7 +- .../src/generators/output-parser-file.ts | 7 +- .../src/templates/extract-delegate-emitter.ts | 79 +++-- .../codegen-ts/src/templates/extractor.ts | 81 ++++-- .../codegen-ts/src/templates/output-parser.ts | 52 +++- .../test/extract-tier-collision.test.ts | 274 ++++++++++++++++++ .../packages/runtime-ts/src/extract-object.ts | 40 ++- 7 files changed, 452 insertions(+), 88 deletions(-) create mode 100644 server/typescript/packages/codegen-ts/test/extract-tier-collision.test.ts diff --git a/server/typescript/packages/codegen-ts/src/generators/extractor-file.ts b/server/typescript/packages/codegen-ts/src/generators/extractor-file.ts index 972323359..7bedce6e2 100644 --- a/server/typescript/packages/codegen-ts/src/generators/extractor-file.ts +++ b/server/typescript/packages/codegen-ts/src/generators/extractor-file.ts @@ -47,7 +47,12 @@ export const extractor = function extractor(opts?: ExtractorOpts): Generator { if (format !== "json" && format !== "xml") continue; files.push({ path: `${dirPrefix}${t.name}.extractor.ts`, - content: renderExtractor(ctx.loadedRoot, t.name), + // ADR-0044/#228: thread ctx.renderContext (when present — runGen always supplies it; + // a hand-rolled GenContext in a unit test may omit it, falling back to bare naming) so + // a payload/nested value-object whose bare name collides across packages emits/imports + // the entity-domain qualified name (Task 3's valueObjectEmittedName), matching + // entityFile()'s module. + content: renderExtractor(ctx.loadedRoot, t.name, ctx.renderContext), }); } return files; diff --git a/server/typescript/packages/codegen-ts/src/generators/output-parser-file.ts b/server/typescript/packages/codegen-ts/src/generators/output-parser-file.ts index 06b64b4cc..b46a86663 100644 --- a/server/typescript/packages/codegen-ts/src/generators/output-parser-file.ts +++ b/server/typescript/packages/codegen-ts/src/generators/output-parser-file.ts @@ -36,7 +36,12 @@ export const outputParser = function outputParser(opts?: OutputParserOpts): Gene for (const t of outputs) { files.push({ path: `${dirPrefix}${t.name}.output.ts`, - content: renderOutputParser(ctx.loadedRoot, t.name), + // ADR-0044/#228: thread ctx.renderContext (when present — runGen always supplies it; + // a hand-rolled GenContext in a unit test may omit it, falling back to bare naming) so + // a payload/nested value-object whose bare name collides across packages emits the + // entity-domain qualified mirror type (Task 3's valueObjectEmittedName), and the + // payload runtime lookup baked FQN-safe. + content: renderOutputParser(ctx.loadedRoot, t.name, ctx.renderContext), }); } return files; diff --git a/server/typescript/packages/codegen-ts/src/templates/extract-delegate-emitter.ts b/server/typescript/packages/codegen-ts/src/templates/extract-delegate-emitter.ts index f7208891f..250b531fc 100644 --- a/server/typescript/packages/codegen-ts/src/templates/extract-delegate-emitter.ts +++ b/server/typescript/packages/codegen-ts/src/templates/extract-delegate-emitter.ts @@ -28,6 +28,7 @@ import { resolveObjectRef, } from "@metaobjectsdev/metadata"; import { fields, isArray, scalarKind, jsonStringLiteral } from "./fr010-field-mapping.js"; +import type { RenderContext } from "../render-context.js"; // 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. @@ -50,14 +51,20 @@ function isObjectField(field: MetaData): boolean { return field.subType === FIELD_SUBTYPE_OBJECT; } -/** The extracted-mirror interface name for a value-object (`Extracted`). */ -export function mirrorName(vo: MetaData): string { - return `${vo.name}Extracted`; +/** The extracted-mirror interface name for a value-object (`Extracted`). ADR-0044/#228: + * `ctx` (optional) resolves the collision-scoped entity-domain emitted name (Task 3's + * `valueObjectEmittedName`), so a cross-package short-name collision qualifies both the + * entity module AND its extract mirror identically (`AcmeAlphaNoteExtracted`). Omitted → + * the bare `vo.name` (bare template unit-test calls; byte-identical to pre-#228 output). */ +export function mirrorName(vo: MetaData, ctx?: RenderContext): string { + const name = ctx ? ctx.valueObjectEmittedName(vo) : vo.name; + return `${name}Extracted`; } -/** The mapper function name for a value-object (`fromExtracted`). */ -function mapperName(vo: MetaData): string { - return `from${vo.name}Extracted`; +/** The mapper function name for a value-object (`fromExtracted`). See {@link mirrorName}. */ +function mapperName(vo: MetaData, ctx?: RenderContext): string { + const name = ctx ? ctx.valueObjectEmittedName(vo) : vo.name; + return `from${name}Extracted`; } // ============================================================================= @@ -65,10 +72,10 @@ function mapperName(vo: MetaData): string { // ============================================================================= /** The nullable mirror TS type for one field — nested-aware (recurses into nested mirror names). */ -function nestedMirrorType(field: MetaData, root: MetaData): string { +function nestedMirrorType(field: MetaData, root: MetaData, ctx?: RenderContext): string { if (isObjectField(field)) { const target = refVo(field, root); - const base = target !== undefined ? mirrorName(target) : "unknown"; + const base = target !== undefined ? mirrorName(target, ctx) : "unknown"; const elem = `${base} | null`; return isArray(field) ? `(${elem})[] | null` : elem; } @@ -92,10 +99,15 @@ function nestedMirrorType(field: MetaData, root: MetaData): string { * name (passed in) so the existing self-contained extract() and the delegating overload * share one mirror type. Returns the joined interface declarations in stable (BFS) order. */ -export function nestedMirrorInterfaces(vo: MetaData, root: MetaData, payloadMirror: string): string { +export function nestedMirrorInterfaces( + vo: MetaData, + root: MetaData, + payloadMirror: string, + ctx?: RenderContext, +): string { const out: string[] = []; const seen = new Set(); - emitMirror(vo, root, payloadMirror, seen, out); + emitMirror(vo, root, payloadMirror, seen, out, ctx); return out.join("\n\n"); } @@ -105,9 +117,14 @@ function emitMirror( interfaceName: string, seen: Set, out: string[], + ctx?: RenderContext, ): void { - if (seen.has(vo.name)) return; - seen.add(vo.name); + // ADR-0044/#228: dedupe by resolutionKey(), NOT the bare name — two distinct value-objects + // sharing a bare short name across packages (the collision case) are DIFFERENT nodes with + // DIFFERENT resolutionKey()s; bare-name dedupe would treat the second as "already seen" and + // silently DROP its mirror interface (and every mapper reading it downstream). + if (seen.has(vo.resolutionKey())) return; + seen.add(vo.resolutionKey()); const base = interfaceName.endsWith("Extracted") ? interfaceName.slice(0, -"Extracted".length) @@ -118,7 +135,7 @@ function emitMirror( ); lines.push(`export interface ${interfaceName} {`); for (const f of fields(vo)) { - lines.push(` ${f.name}: ${nestedMirrorType(f, root)};`); + lines.push(` ${f.name}: ${nestedMirrorType(f, root, ctx)};`); } lines.push("}"); out.push(lines.join("\n")); @@ -127,7 +144,7 @@ function emitMirror( for (const f of fields(vo)) { if (isObjectField(f)) { const target = refVo(f, root); - if (target !== undefined) emitMirror(target, root, mirrorName(target), seen, out); + if (target !== undefined) emitMirror(target, root, mirrorName(target, ctx), seen, out, ctx); } } } @@ -149,10 +166,11 @@ export function nestedMappers( root: MetaData, rootMapperFn: string, rootMirror: string, + ctx?: RenderContext, ): string { const out: string[] = []; const seen = new Set(); - emitMapper(vo, root, seen, out, { fn: rootMapperFn, mirror: rootMirror }); + emitMapper(vo, root, seen, out, { fn: rootMapperFn, mirror: rootMirror }, ctx); return out.join("\n\n"); } @@ -167,13 +185,17 @@ function emitMapper( seen: Set, out: string[], override?: { fn: string; mirror: string }, + ctx?: RenderContext, ): void { - if (seen.has(vo.name)) return; - seen.add(vo.name); + // ADR-0044/#228: dedupe by resolutionKey() — see emitMirror for why bare-name dedupe drops + // the second colliding VO's mapper (silently misdirecting its extraction to the FIRST + // colliding VO's mapper — the exact wrong-data bug closed by this fix). + if (seen.has(vo.resolutionKey())) return; + seen.add(vo.resolutionKey()); - const fn = override?.fn ?? mapperName(vo); - const mir = override?.mirror ?? mirrorName(vo); - const assigns = fields(vo).map((f) => ` ${f.name}: ${mapperArg(f, root)},`); + const fn = override?.fn ?? mapperName(vo, ctx); + const mir = override?.mirror ?? mirrorName(vo, ctx); + const assigns = fields(vo).map((f) => ` ${f.name}: ${mapperArg(f, root, ctx)},`); const body = [ `/** Map an assembled ValueObject graph into a typed \`${mir}\` mirror. Generated; null-tolerant. */`, `function ${fn}(o: unknown): ${mir} | null {`, @@ -188,19 +210,19 @@ function emitMapper( for (const f of fields(vo)) { if (isObjectField(f)) { const target = refVo(f, root); - if (target !== undefined) emitMapper(target, root, seen, out); + if (target !== undefined) emitMapper(target, root, seen, out, undefined, ctx); } } } /** The mirror-field initializer expression that reads `field` from the assembled object `o`. */ -function mapperArg(field: MetaData, root: MetaData): string { +function mapperArg(field: MetaData, root: MetaData, ctx?: RenderContext): string { const key = jsonStringLiteral(field.name); if (isObjectField(field)) { const target = refVo(field, root); if (target === undefined) return "null /* unresolved @objectRef */"; - const fn = mapperName(target); + const fn = mapperName(target, ctx); if (isArray(field)) { return `mapObjectList(readProp(o, ${key}), ${fn})`; } @@ -242,8 +264,10 @@ export function usedHelpers(vo: MetaData, root: MetaData): Set { const stack = [vo]; while (stack.length > 0) { const cur = stack.pop()!; - if (seen.has(cur.name)) continue; - seen.add(cur.name); + // ADR-0044/#228: dedupe by resolutionKey() — bare-name dedupe would skip walking the + // SECOND colliding VO's fields entirely, silently missing a helper only IT needs. + if (seen.has(cur.resolutionKey())) continue; + seen.add(cur.resolutionKey()); for (const f of fields(cur)) { if (isObjectField(f)) { const target = refVo(f, root); @@ -285,8 +309,9 @@ export function hasNested(vo: MetaData, root: MetaData): boolean { const stack = [vo]; while (stack.length > 0) { const cur = stack.pop()!; - if (seen.has(cur.name)) continue; - seen.add(cur.name); + // ADR-0044/#228: dedupe by resolutionKey() (see usedHelpers). + if (seen.has(cur.resolutionKey())) continue; + seen.add(cur.resolutionKey()); for (const f of cur.children().filter((c) => c.type === TYPE_FIELD)) { if (isObjectField(f)) { const target = refVo(f, root); diff --git a/server/typescript/packages/codegen-ts/src/templates/extractor.ts b/server/typescript/packages/codegen-ts/src/templates/extractor.ts index 77c4c9fb9..00f740571 100644 --- a/server/typescript/packages/codegen-ts/src/templates/extractor.ts +++ b/server/typescript/packages/codegen-ts/src/templates/extractor.ts @@ -37,6 +37,7 @@ import { fields, isArray } from "./fr010-field-mapping.js"; import { mirrorName } from "./extract-delegate-emitter.js"; import { enumUnionAliasName } from "./inferred-types.js"; import { enumValues } from "../enum-meta.js"; +import type { RenderContext } from "../render-context.js"; // 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. @@ -87,9 +88,13 @@ function isFieldRequired(field: MetaData): boolean { return field.attr(FIELD_ATTR_REQUIRED) === true; } -/** The mirror→strict mapper name for a value-object (`toStrict`). */ -function mapperName(vo: MetaData): string { - return `toStrict${vo.name}`; +/** The mirror→strict mapper name for a value-object (`toStrict`). ADR-0044/#228: `ctx` + * (optional) resolves the collision-scoped entity-domain emitted name (matches Task 3's + * entity module), so the mapper name agrees with the strict payload type it targets under a + * cross-package short-name collision (`toStrictAcmeAlphaNote`). Omitted → bare `vo.name`. */ +function mapperName(vo: MetaData, ctx?: RenderContext): string { + const name = ctx ? ctx.valueObjectEmittedName(vo) : vo.name; + return `toStrict${name}`; } /** @@ -99,7 +104,7 @@ function mapperName(vo: MetaData): string { * `f?: T` (= `T | undefined`, never `T | null`), so an absent optional maps to `undefined`. * Nested single/array objects recurse into their toStrict mapper, guarding when optional. */ -function strictArg(field: MetaData, root: MetaData, ownerName: string): string { +function strictArg(field: MetaData, root: MetaData, ownerName: string, ctx?: RenderContext): string { const name = field.name; const required = isFieldRequired(field); @@ -109,7 +114,7 @@ function strictArg(field: MetaData, root: MetaData, ownerName: string): string { // Unresolved @objectRef — the payload type would be `unknown`; pass through as-is. return required ? `m.${name}!` : `m.${name} ?? undefined`; } - const fn = mapperName(target); + const fn = mapperName(target, ctx); if (isArray(field)) { // Required array-of-objects: each element mapped; element nulls dropped at the type level // via the non-null assertion (extract never yields null elements for a present array). @@ -164,10 +169,10 @@ function strictArg(field: MetaData, root: MetaData, ownerName: string): string { * payload interface. The ROOT mapper reads the canonically-named root mirror (`