nameMap) {
+ String mapped = nameMap.get(vo.getName());
+ if (mapped != null) return mapped;
return SpringNaming.payloadName(SpringNaming.splitFqn(vo.getName())[1]);
}
@@ -417,15 +450,16 @@ private static String escapeJava(String value) {
return sb.toString();
}
- /** Resolve {@code @payloadRef} to its {@code object.value} target (rejects entities). */
- protected static MetaObject resolveValueObject(MetaDataLoader loader, String ref) {
- for (MetaObject obj : loader.getMetaObjects()) {
- if (!MetaObject.SUBTYPE_VALUE.equals(obj.getSubType())) continue;
- if (obj.getName().equals(ref)) return obj;
- String[] split = SpringNaming.splitFqn(obj.getName());
- if (split[1].equals(ref)) return obj;
- }
- return null;
+ /**
+ * Resolve {@code @payloadRef} to its {@code object.value} target (rejects entities)
+ * under the ADR-0042 package-local contract (#228) — was a package-BLIND bare-name
+ * scan over every loaded {@code object.value} (first match wins, load-order-dependent);
+ * now delegates to the shared {@link SpringNaming#resolveValueObjectRef} so a bare
+ * {@code @payloadRef} binds the referrer's OWN package first, agreeing with the
+ * loader's own {@code ValidationPhase} validation of the same ref.
+ */
+ protected static MetaObject resolveValueObject(MetaDataLoader loader, String ref, String referrerPkg) {
+ return SpringNaming.resolveValueObjectRef(loader, ref, referrerPkg);
}
// === MultiFileDirectGeneratorBase abstract-method stubs ====================
diff --git a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringOutputPromptGenerator.java b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringOutputPromptGenerator.java
index e6ab7286d..b510f6ea3 100644
--- a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringOutputPromptGenerator.java
+++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringOutputPromptGenerator.java
@@ -113,14 +113,16 @@ public static boolean appliesTo(MetaData node, MetaDataLoader loader) {
if (!supported) return false;
String payloadRef = template.getPayloadRef();
if (payloadRef == null || payloadRef.isEmpty()) return false;
- return resolveValueObject(loader, payloadRef) != null;
+ return resolveValueObject(loader, payloadRef,
+ com.metaobjects.util.MetaDataUtil.findPackageForMetaData(template)) != null;
}
protected void emit(MetaTemplate template, MetaDataLoader loader, Path outRoot) {
if (!appliesTo(template, loader)) {
return; // unsupported @format, missing @payloadRef, or not a VO
}
- MetaObject payloadVo = resolveValueObject(loader, template.getPayloadRef());
+ MetaObject payloadVo = resolveValueObject(loader, template.getPayloadRef(),
+ com.metaobjects.util.MetaDataUtil.findPackageForMetaData(template));
String[] split = SpringNaming.splitFqn(template.getName());
String templatePkg = split[0];
@@ -170,15 +172,14 @@ protected void emit(MetaTemplate template, MetaDataLoader loader, Path outRoot)
}
}
- /** Resolve {@code @payloadRef} to its {@code object.value} target (rejects entities). */
- protected static MetaObject resolveValueObject(MetaDataLoader loader, String ref) {
- for (MetaObject obj : loader.getMetaObjects()) {
- if (!MetaObject.SUBTYPE_VALUE.equals(obj.getSubType())) continue;
- if (obj.getName().equals(ref)) return obj;
- String[] split = SpringNaming.splitFqn(obj.getName());
- if (split[1].equals(ref)) return obj;
- }
- return null;
+ /**
+ * Resolve {@code @payloadRef} to its {@code object.value} target (rejects entities)
+ * under the ADR-0042 package-local contract (#228) — was a package-BLIND bare-name
+ * scan (first match wins, load-order-dependent); now delegates to the shared
+ * {@link SpringNaming#resolveValueObjectRef}.
+ */
+ protected static MetaObject resolveValueObject(MetaDataLoader loader, String ref, String referrerPkg) {
+ return SpringNaming.resolveValueObjectRef(loader, ref, referrerPkg);
}
// === MultiFileDirectGeneratorBase abstract-method stubs ====================
diff --git a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringPayloadGenerator.java b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringPayloadGenerator.java
index 80a4e2d28..f63659001 100644
--- a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringPayloadGenerator.java
+++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringPayloadGenerator.java
@@ -155,14 +155,20 @@ public void execute(MetaDataLoader loader) {
* {@code AcmeAlphaNotePayload}). A still-colliding derived name fails loud with
* {@link #ERR_PAYLOAD_NAME_COLLISION}. Pure function of the templates — never of
* emission order.
+ *
+ * public static (promoted from {@code protected} instance) so
+ * {@link SpringOutputParserGenerator} — a sibling generator consuming the SAME
+ * {@code @payloadRef} closure — reuses this ONE name map rather than re-deriving
+ * naming (#228: extract/output-parser tier collision-scoped naming).
*/
- protected Map computePayloadNameMap(List templates, MetaDataLoader loader) {
+ public static Map computePayloadNameMap(List templates, MetaDataLoader loader) {
// FQN -> output package (first reaching template in sorted order wins, matching
// the run-wide dedupe below). The primary VO is template-named, so excluded.
Map voOutPkg = new LinkedHashMap<>();
List orderedFqns = new ArrayList<>();
for (MetaTemplate tmpl : templates) {
- MetaObject vo = resolveValueObject(loader, tmpl.getPayloadRef());
+ MetaObject vo = resolveValueObject(loader, tmpl.getPayloadRef(),
+ com.metaobjects.util.MetaDataUtil.findPackageForMetaData(tmpl));
if (vo == null) continue;
String nestedPkg = SpringNaming.promptsPackage(SpringNaming.splitFqn(tmpl.getName())[0]);
Set seen = new HashSet<>();
@@ -212,8 +218,11 @@ protected Map computePayloadNameMap(List templates
* assigning each not-yet-seen target VO to {@code outPkg} (first reaching
* template wins) and recording it in {@code orderedFqns}. {@code seen} is seeded
* with the primary VO's FQN and doubles as the cycle guard.
+ *
+ * public static (promoted from {@code protected} instance, #228) — see
+ * {@link #computePayloadNameMap}.
*/
- protected void collectNestedClosure(MetaObject vo,
+ public static void collectNestedClosure(MetaObject vo,
MetaDataLoader loader,
String outPkg,
Map voOutPkg,
@@ -239,8 +248,11 @@ protected void collectNestedClosure(MetaObject vo,
* {@link #resolveCollectionType} ({@code origin.collection @via}) EXACTLY, so the
* closure walk and the emission walk agree on the target set. Passthrough /
* aggregate origins yield scalar types (no nested record).
+ *
+ * public static (promoted from {@code protected} instance, #228) — see
+ * {@link #computePayloadNameMap}.
*/
- protected MetaObject nestedTargetOf(MetaField> field, MetaDataLoader loader) {
+ public static MetaObject nestedTargetOf(MetaField> field, MetaDataLoader loader) {
MetaOrigin origin = firstOriginChild(field);
if (origin instanceof CollectionOrigin co) {
String via = co.getVia();
@@ -273,8 +285,11 @@ protected MetaObject nestedTargetOf(MetaField> field, MetaDataLoader loader) {
* {@code ::}->{@code .} converted by {@link SpringNaming#splitFqn}), concatenate,
* append the bare {@code shortName} ({@code "acme.alpha"} + {@code "Note"} ->
* {@code "AcmeAlphaNote"}). A root-level (empty-package) node keeps its bare name.
+ *
+ *
public static (widened from package-private-visible {@code protected
+ * static}, #228) — see {@link #computePayloadNameMap}.
*/
- protected static String packageQualifiedName(String javaPkg, String shortName) {
+ public static String packageQualifiedName(String javaPkg, String shortName) {
if (javaPkg == null || javaPkg.isEmpty()) return shortName;
StringBuilder sb = new StringBuilder();
for (String seg : javaPkg.split("\\.")) {
@@ -295,7 +310,8 @@ public static boolean appliesTo(MetaData node, MetaDataLoader loader) {
if (!(node instanceof MetaTemplate template)) return false;
String payloadRef = template.getPayloadRef();
if (payloadRef == null || payloadRef.isEmpty()) return false;
- return resolveValueObject(loader, payloadRef) != null;
+ return resolveValueObject(loader, payloadRef,
+ com.metaobjects.util.MetaDataUtil.findPackageForMetaData(template)) != null;
}
protected void emit(MetaTemplate template, MetaDataLoader loader, Path outRoot,
@@ -303,7 +319,8 @@ protected void emit(MetaTemplate template, MetaDataLoader loader, Path outRoot,
if (!appliesTo(template, loader)) {
return; // missing @payloadRef, or not a VO — same contract as Kotlin / C# / Python
}
- MetaObject payloadVo = resolveValueObject(loader, template.getPayloadRef());
+ MetaObject payloadVo = resolveValueObject(loader, template.getPayloadRef(),
+ com.metaobjects.util.MetaDataUtil.findPackageForMetaData(template));
String[] split = SpringNaming.splitFqn(template.getName());
String templatePkg = split[0];
@@ -673,11 +690,17 @@ protected static MetaObject resolveObjectByShortOrFqn(MetaDataLoader loader, Str
return null;
}
- /** Resolve {@code @payloadRef} to its {@code object.value} target (rejects entities). */
- public static MetaObject resolveValueObject(MetaDataLoader loader, String ref) {
- MetaObject obj = resolveObjectByShortOrFqn(loader, ref);
- if (obj == null) return null;
- return MetaObject.SUBTYPE_VALUE.equals(obj.getSubType()) ? obj : null;
+ /**
+ * Resolve {@code @payloadRef} to its {@code object.value} target (rejects entities)
+ * under the ADR-0042 package-local contract (#228): a bare ref resolves in
+ * {@code referrerPkg} first, else root-level; an FQN ref matches exactly. Distinct
+ * from {@link #resolveObjectByShortOrFqn} (used only by the {@code origin.@from}/
+ * {@code @of}/{@code @via} dotted-ref walk above, a different ref kind out of this
+ * fix's scope) — {@code @payloadRef} is the one every port's canonical resolver
+ * gates, matching the loader's own {@code ValidationPhase} validation of the same ref.
+ */
+ public static MetaObject resolveValueObject(MetaDataLoader loader, String ref, String referrerPkg) {
+ return SpringNaming.resolveValueObjectRef(loader, ref, referrerPkg);
}
/**
diff --git a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringRenderHelperGenerator.java b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringRenderHelperGenerator.java
index 48aaade2a..e3ead4ee3 100644
--- a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringRenderHelperGenerator.java
+++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringRenderHelperGenerator.java
@@ -134,7 +134,8 @@ public static boolean appliesTo(MetaData node, MetaDataLoader loader) {
if (!TemplateConstants.SUBTYPE_OUTPUT.equals(template.getSubType())) return false;
String payloadRef = template.getPayloadRef();
if (payloadRef == null || payloadRef.isEmpty()) return false;
- return resolveValueObject(loader, payloadRef) != null;
+ return resolveValueObject(loader, payloadRef,
+ com.metaobjects.util.MetaDataUtil.findPackageForMetaData(template)) != null;
}
protected void emit(MetaTemplate template, MetaDataLoader loader, Path outRoot,
@@ -142,7 +143,8 @@ protected void emit(MetaTemplate template, MetaDataLoader loader, Path outRoot,
if (!appliesTo(template, loader)) {
return; // missing @payloadRef, or not a VO — same contract as SpringPayloadGenerator
}
- MetaObject payloadVo = resolveValueObject(loader, template.getPayloadRef());
+ MetaObject payloadVo = resolveValueObject(loader, template.getPayloadRef(),
+ com.metaobjects.util.MetaDataUtil.findPackageForMetaData(template));
String[] split = SpringNaming.splitFqn(template.getName());
String templatePkg = split[0];
@@ -408,15 +410,16 @@ private static String attr(MetaTemplate template, String attr) {
return template.getMetaAttr(attr).getValueAsString();
}
- /** Resolve {@code @payloadRef} to its {@code object.value} target (rejects entities). */
- protected static MetaObject resolveValueObject(MetaDataLoader loader, String ref) {
- for (MetaObject obj : loader.getMetaObjects()) {
- if (!MetaObject.SUBTYPE_VALUE.equals(obj.getSubType())) continue;
- if (obj.getName().equals(ref)) return obj;
- String[] split = SpringNaming.splitFqn(obj.getName());
- if (split[1].equals(ref)) return obj;
- }
- return null;
+ /**
+ * Resolve {@code @payloadRef} to its {@code object.value} target (rejects entities)
+ * under the ADR-0042 package-local contract (#228) — was a package-BLIND bare-name
+ * scan (first match wins, load-order-dependent); now delegates to the shared
+ * {@link SpringNaming#resolveValueObjectRef}. Distinct from this file's OWN
+ * {@link #resolveNestedObjectRef} (the {@code @objectRef} field-tree walk), which was
+ * ALREADY package-local-correct.
+ */
+ protected static MetaObject resolveValueObject(MetaDataLoader loader, String ref, String referrerPkg) {
+ return SpringNaming.resolveValueObjectRef(loader, ref, referrerPkg);
}
/** Java string-literal quoting with the common escapes. */
diff --git a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/GeneratedTraceHelperCompileRunTest.java b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/GeneratedTraceHelperCompileRunTest.java
index d28b1a7a2..af00479e3 100644
--- a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/GeneratedTraceHelperCompileRunTest.java
+++ b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/GeneratedTraceHelperCompileRunTest.java
@@ -209,6 +209,64 @@ public void skipsEntityNotDerivedFromLlmCallBase() throws Exception {
Files.exists(gen.resolve("acme/ai/PlainEntityTraceHelper.java")));
}
+ /**
+ * #228 checkpoint 4 — {@code resolveValueObject}'s pre-fix bare-tail-fallback bug
+ * (the #219/#244 "wrong node despite a VALID FQN target" pattern): the old
+ * implementation checked, PER CANDIDATE in loader iteration order, "does this
+ * object's bare short name equal the ref's bare tail?" — so a same-bare-named
+ * DECOY {@code object.value} visited BEFORE the true FQN target would win
+ * immediately, even though the correctly-FQN-qualified target also exists and
+ * loads later. Package {@code acme::other} declares a decoy {@code GreetResponse}
+ * (loaded FIRST); {@code acme::ai} declares its OWN {@code GreetResponse} and an
+ * FQN {@code @responseRef: "acme::ai::GreetResponse"} that unambiguously names it.
+ * Asserts the generated helper derives its typed result record from {@code acme::ai}'s
+ * shape ({@code greeting}/{@code score}) — never the decoy's ({@code otherField}).
+ */
+ @Test
+ public void responseRefFqnBindsOwnPackageNotABareTailDecoyLoadedFirst() throws Exception {
+ String decoyMeta = "{ \"metadata.root\": {"
+ + " \"package\": \"acme::other\","
+ + " \"children\": ["
+ + " { \"object.value\": { \"name\": \"GreetResponse\", \"children\": ["
+ + " { \"field.string\": { \"name\": \"otherField\", \"@required\": true } }"
+ + " ]}}"
+ + " ]"
+ + "}}";
+
+ MetaDataLoader loader = new MetaDataLoader(
+ LoaderOptions.create(false, false, true),
+ MetaDataLoader.SUBTYPE_MANUAL, "trace-responseref-fqn");
+ loader.init();
+ // Decoy loads FIRST — under the pre-fix bare-tail-fallback bug this would win.
+ loader.load(List.of(
+ new InMemoryStringSource(decoyMeta, "trace-responseref-fqn/meta.other.json"),
+ new InMemoryStringSource(META, "trace-responseref-fqn/meta.ai.json")));
+
+ Path gen = tmp.newFolder("gen-responseref-fqn").toPath();
+ LlmTraceHelperGenerator generator = new LlmTraceHelperGenerator();
+ Map args = new HashMap<>();
+ args.put("outputDir", gen.toString());
+ generator.setArgs(args);
+ generator.execute(loader);
+
+ Path helper = gen.resolve("acme/ai/GreetingCallTraceHelper.java");
+ assertTrue("GreetingCallTraceHelper.java must be emitted at " + helper, Files.exists(helper));
+ String src = Files.readString(helper);
+
+ // The baked FQN string is the load-bearing proof: LlmTraceHelperGenerator bakes
+ // the RESOLVED responseVo's OWN name (not the raw @responseRef attr verbatim), so
+ // a pre-fix bare-tail-fallback mis-resolution to the decoy would have baked
+ // "acme::other::GreetResponse" here instead.
+ assertTrue("must resolve + bake acme::ai's OWN GreetResponse FQN; saw:\n" + src,
+ src.contains("getMetaObjectByName(\"acme::ai::GreetResponse\")"));
+ assertFalse("must NEVER bind/bake the decoy acme::other::GreetResponse; saw:\n" + src,
+ src.contains("acme::other") || src.contains("otherField"));
+
+ // Compile it too — proves the resolved MetaObject is a real, loadable node
+ // (not just a text match), same rigor as the other tests in this file.
+ compileGenerated(gen);
+ }
+
// -----------------------------------------------------------------------------------------
// helpers
// -----------------------------------------------------------------------------------------
diff --git a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/OutputParserExtractTierCollisionTest.java b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/OutputParserExtractTierCollisionTest.java
new file mode 100644
index 000000000..bb47ad0fc
--- /dev/null
+++ b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/OutputParserExtractTierCollisionTest.java
@@ -0,0 +1,266 @@
+package com.metaobjects.generator.spring;
+
+import com.metaobjects.loader.LoaderOptions;
+import com.metaobjects.loader.MetaDataLoader;
+import com.metaobjects.loader.uri.URIHelper;
+import com.metaobjects.registry.SharedRegistryTestBase;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+
+import java.net.URI;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * #228 — Java port of the extract/output-parser tier collision-scoped naming fix.
+ * {@link SpringOutputParserGenerator} now consumes {@link SpringPayloadGenerator}'s
+ * OWN ADR-0044 name map (never re-derives naming) so a cross-package short-name
+ * collision on a NESTED {@code field.object} target VO gets the SAME
+ * package-qualified record name the payload tier emits — a bare {@code NotePayload}
+ * reference would either be a dangling class reference or (worse) a duplicate-method
+ * compile error when two colliding VOs both derive {@code fromNotePayload(...)}.
+ *
+ * Also covers the ADR-0042 build-time {@code @payloadRef} resolver fix
+ * (checkpoint 3): {@code resolveValueObject} was previously a package-BLIND
+ * bare-name-anywhere scan (first match in load order wins); it now resolves in the
+ * referring template's OWN package first, matching the loader's own
+ * {@code ValidationPhase} validation of the same ref.
+ */
+public class OutputParserExtractTierCollisionTest extends SharedRegistryTestBase {
+
+ @Rule
+ public TemporaryFolder tempFolder = new TemporaryFolder();
+
+ // -------------------------------------------------------------------------
+ // Step 1 (brief) — shared xpkg-collision-json corpus: nested field.object
+ // collision (acme::alpha::Note / acme::beta::Note), both reachable from one
+ // payload (Digest) via FQN @objectRef.
+ // -------------------------------------------------------------------------
+
+ @Test
+ public void xpkgCollisionJsonEmitsDistinctMappersForBothCollidingNestedVos() throws Exception {
+ Path corpus = findCorpus();
+ assertTrue("shared corpus fixtures/template-output-render-conformance must be reachable",
+ corpus != null && Files.exists(corpus.resolve("xpkg-collision-json/meta.app.json")));
+ Path xpkg = corpus.resolve("xpkg-collision-json");
+
+ Path outDir = tempFolder.newFolder("outputparser-xpkg").toPath();
+ MetaDataLoader loader = loadMultiFile("xpkg-op",
+ xpkg.resolve("meta.alpha.json"),
+ xpkg.resolve("meta.beta.json"),
+ xpkg.resolve("meta.app.json"));
+
+ SpringOutputParserGenerator gen = new SpringOutputParserGenerator();
+ Map args = new HashMap<>();
+ args.put("outputDir", outDir.toString());
+ gen.setArgs(args);
+ gen.execute(loader);
+
+ Path parser = outDir.resolve("acme/app/prompts/DigestDocParser.java");
+ assertTrue("expected DigestDocParser.java at " + parser, Files.exists(parser));
+ String src = Files.readString(parser);
+
+ // Both colliding nested VOs get their OWN distinct, collision-scoped mapper —
+ // never the bare `NotePayload` the payload generator no longer emits under
+ // collision, and never a dropped/clobbered second mapper.
+ assertTrue("expected a fromAcmeAlphaNotePayload mapper; saw:\n" + src,
+ src.contains("private static AcmeAlphaNotePayload fromAcmeAlphaNotePayload(java.util.Map d)"));
+ assertTrue("expected a fromAcmeBetaNotePayload mapper; saw:\n" + src,
+ src.contains("private static AcmeBetaNotePayload fromAcmeBetaNotePayload(java.util.Map d)"));
+ assertFalse("must NEVER reference/emit the shadowed bare fromNotePayload mapper; saw:\n" + src,
+ src.contains("fromNotePayload("));
+ assertFalse("must NEVER reference the shadowed bare NotePayload type; saw:\n" + src,
+ src.contains("NotePayload fromNotePayload") || src.contains(" NotePayload)"));
+
+ // The root mapper's fromAlpha/fromBeta fields route to their OWN qualified mapper.
+ assertTrue("fromAlpha field must recurse into fromAcmeAlphaNotePayload; saw:\n" + src,
+ src.contains("fromAcmeAlphaNotePayload(asMap(d.get(\"fromAlpha\")))"));
+ assertTrue("fromBeta field must recurse into fromAcmeBetaNotePayload; saw:\n" + src,
+ src.contains("fromAcmeBetaNotePayload(asMap(d.get(\"fromBeta\")))"));
+ }
+
+ // -------------------------------------------------------------------------
+ // No-churn: a non-colliding nested VO keeps its bare mapper name/type — proves
+ // the nameMap consultation is a no-op absent a collision (byte-identical to
+ // pre-#228 output).
+ // -------------------------------------------------------------------------
+
+ private static final String NO_CHURN_FIXTURE = """
+ {
+ "metadata.root": { "package": "acme::ai", "children": [
+ { "object.value": { "name": "Detail", "children": [
+ { "field.string": { "name": "note", "@required": true } }
+ ] } },
+ { "object.value": { "name": "WidgetOut", "children": [
+ { "field.string": { "name": "title", "@required": true } },
+ { "field.object": { "name": "detail", "@objectRef": "Detail" } }
+ ] } },
+ { "template.output": {
+ "name": "WidgetDoc",
+ "@payloadRef": "WidgetOut",
+ "@textRef": "widget/doc",
+ "@format": "json"
+ } }
+ ] }
+ }
+ """;
+
+ @Test
+ public void noChurnNonCollidingNestedVoKeepsBareMapperName() throws Exception {
+ Path outDir = tempFolder.newFolder("outputparser-nochurn").toPath();
+ Path workspace = tempFolder.newFolder("outputparser-nochurn-fx").toPath();
+ MetaDataLoader loader = SpringTestFixtures.loadFixture(workspace, "nochurn", NO_CHURN_FIXTURE);
+
+ SpringOutputParserGenerator gen = new SpringOutputParserGenerator();
+ Map args = new HashMap<>();
+ args.put("outputDir", outDir.toString());
+ gen.setArgs(args);
+ gen.execute(loader);
+
+ Path parser = outDir.resolve("acme/ai/prompts/WidgetDocParser.java");
+ assertTrue("expected WidgetDocParser.java at " + parser, Files.exists(parser));
+ String src = Files.readString(parser);
+
+ assertTrue("non-colliding nested VO must keep its BARE mapper; saw:\n" + src,
+ src.contains("private static DetailPayload fromDetailPayload(java.util.Map d)"));
+ assertTrue("detail field must recurse into the bare fromDetailPayload; saw:\n" + src,
+ src.contains("fromDetailPayload(asMap(d.get(\"detail\")))"));
+ assertFalse("must NOT package-qualify a non-colliding VO", src.contains("AcmeAiDetailPayload"));
+ }
+
+ // -------------------------------------------------------------------------
+ // Checkpoint 3 — build-time @payloadRef resolver: a BARE @payloadRef that
+ // cross-package-collides on its OWN name must bind the referring template's
+ // OWN package, regardless of load order (was package-blind, first-match-wins).
+ // -------------------------------------------------------------------------
+
+ private static String alphaReportJson() {
+ return """
+ { "metadata.root": { "package": "acme::alpha", "children": [
+ { "object.value": { "name": "Report", "children": [
+ { "field.string": { "name": "alphaVal", "@required": true } }
+ ] } },
+ { "template.output": {
+ "name": "ReportDocAlpha",
+ "@payloadRef": "Report",
+ "@textRef": "report/alpha",
+ "@format": "json"
+ } }
+ ] } }
+ """;
+ }
+
+ private static String betaReportJson() {
+ return """
+ { "metadata.root": { "package": "acme::beta", "children": [
+ { "object.value": { "name": "Report", "children": [
+ { "field.string": { "name": "betaVal", "@required": true } }
+ ] } },
+ { "template.output": {
+ "name": "ReportDocBeta",
+ "@payloadRef": "Report",
+ "@textRef": "report/beta",
+ "@format": "json"
+ } }
+ ] } }
+ """;
+ }
+
+ @Test
+ public void barePayloadRefCollisionBindsOwnPackage_alphaLoadedFirst() throws Exception {
+ assertBarePayloadRefBindsOwnPackage(true);
+ }
+
+ @Test
+ public void barePayloadRefCollisionBindsOwnPackage_betaLoadedFirst() throws Exception {
+ assertBarePayloadRefBindsOwnPackage(false);
+ }
+
+ private void assertBarePayloadRefBindsOwnPackage(boolean alphaFirst) throws Exception {
+ Path workspace = tempFolder.newFolder("bare-payloadref-" + alphaFirst).toPath();
+ Path alphaFile = workspace.resolve("meta.alpha.json");
+ Path betaFile = workspace.resolve("meta.beta.json");
+ Files.writeString(alphaFile, alphaReportJson());
+ Files.writeString(betaFile, betaReportJson());
+
+ MetaDataLoader loader = alphaFirst
+ ? loadMultiFile("bare-" + alphaFirst, alphaFile, betaFile)
+ : loadMultiFile("bare-" + alphaFirst, betaFile, alphaFile);
+
+ Path outDir = tempFolder.newFolder("bare-payloadref-out-" + alphaFirst).toPath();
+
+ // SpringPayloadGenerator: each template's record must carry its OWN
+ // package's field, never the other's, regardless of load order.
+ SpringPayloadGenerator payloadGen = new SpringPayloadGenerator();
+ Map args = new HashMap<>();
+ args.put("outputDir", outDir.toString());
+ payloadGen.setArgs(args);
+ payloadGen.execute(loader);
+
+ String alphaPayloadSrc = Files.readString(outDir.resolve("acme/alpha/prompts/ReportDocAlphaPayload.java"));
+ String betaPayloadSrc = Files.readString(outDir.resolve("acme/beta/prompts/ReportDocBetaPayload.java"));
+ assertTrue("ReportDocAlphaPayload must carry alphaVal (own package); saw:\n" + alphaPayloadSrc,
+ alphaPayloadSrc.contains("String alphaVal"));
+ assertFalse("ReportDocAlphaPayload must NOT carry betaVal (wrong package); saw:\n" + alphaPayloadSrc,
+ alphaPayloadSrc.contains("betaVal"));
+ assertTrue("ReportDocBetaPayload must carry betaVal (own package); saw:\n" + betaPayloadSrc,
+ betaPayloadSrc.contains("String betaVal"));
+ assertFalse("ReportDocBetaPayload must NOT carry alphaVal (wrong package); saw:\n" + betaPayloadSrc,
+ betaPayloadSrc.contains("alphaVal"));
+
+ // SpringOutputParserGenerator: same resolver, same guarantee — the generated
+ // mapper for each template's OWN root payload must read its OWN field name.
+ SpringOutputParserGenerator parserGen = new SpringOutputParserGenerator();
+ parserGen.setArgs(args);
+ parserGen.execute(loader);
+
+ String alphaParserSrc = Files.readString(outDir.resolve("acme/alpha/prompts/ReportDocAlphaParser.java"));
+ String betaParserSrc = Files.readString(outDir.resolve("acme/beta/prompts/ReportDocBetaParser.java"));
+ assertTrue("ReportDocAlphaParser's mapper must read alphaVal; saw:\n" + alphaParserSrc,
+ alphaParserSrc.contains("ExtractMap.asString(d, \"alphaVal\")"));
+ assertFalse("ReportDocAlphaParser's mapper must NOT read betaVal; saw:\n" + alphaParserSrc,
+ alphaParserSrc.contains("betaVal"));
+ assertTrue("ReportDocBetaParser's mapper must read betaVal; saw:\n" + betaParserSrc,
+ betaParserSrc.contains("ExtractMap.asString(d, \"betaVal\")"));
+ assertFalse("ReportDocBetaParser's mapper must NOT read alphaVal; saw:\n" + betaParserSrc,
+ betaParserSrc.contains("alphaVal"));
+ }
+
+ // -------------------------------------------------------------------------
+ // Helpers (mirrors SpringPayloadGeneratorTest's private helpers of the same name).
+ // -------------------------------------------------------------------------
+
+ /** Walk up from {@code user.dir} to the repo-root shared corpus, or {@code null}. */
+ private static Path findCorpus() {
+ Path p = Paths.get(System.getProperty("user.dir")).toAbsolutePath();
+ while (p != null && !Files.exists(p.resolve("fixtures/template-output-render-conformance"))) {
+ p = p.getParent();
+ }
+ return p != null ? p.resolve("fixtures/template-output-render-conformance") : null;
+ }
+
+ /** Load several metadata files into one merged loader (multi-package fixtures), in the
+ * EXACT order given (MetaDataLoader does not re-sort an explicit URI list). */
+ private MetaDataLoader loadMultiFile(String baseName, Path... files) throws Exception {
+ List uris = new ArrayList<>();
+ for (Path f : files) {
+ uris.add(URIHelper.toURI("model:file:" + f.toAbsolutePath().toString().replace('\\', '/')));
+ }
+ MetaDataLoader loader = new MetaDataLoader(
+ LoaderOptions.create(false, false, true),
+ MetaDataLoader.SUBTYPE_MANUAL,
+ "spring-test-" + baseName);
+ loader.setSourceURIs(uris);
+ loader.init();
+ return loader;
+ }
+}
diff --git a/server/python/src/metaobjects/apidocs/builder.py b/server/python/src/metaobjects/apidocs/builder.py
index fe7357620..de70bd2d1 100644
--- a/server/python/src/metaobjects/apidocs/builder.py
+++ b/server/python/src/metaobjects/apidocs/builder.py
@@ -55,11 +55,24 @@
from metaobjects.meta.persistence.source.source_constants import SOURCE_KIND_TABLE
from metaobjects.meta.template import template_constants as tc
from metaobjects.shared.base_types import TYPE_OBJECT, TYPE_TEMPLATE
+from metaobjects.shared.separators import PACKAGE_SEP
# Structured formats that get an output-format prompt + a tolerant extractor.
_STRUCTURED_FORMATS = frozenset({tc.TEMPLATE_FORMAT_JSON, tc.TEMPLATE_FORMAT_XML})
+def _pkg_of(node: MetaData) -> str:
+ """The effective package of a node — its ``resolution_key()`` minus the
+ trailing ``::`` ("" for a root-level node). Duplicated (not imported) to
+ match the existing per-generator convention. Used to derive a template's
+ referrer package for ``resolve_payload_vo`` (#228) — see that function's
+ docstring for why this ancestor-walk-aware form is used instead of the
+ loader's bare ``tpl.package or tpl.file_default_package or ""``."""
+ key = node.resolution_key()
+ i = key.rfind(PACKAGE_SEP)
+ return "" if i == -1 else key[:i]
+
+
# ---------------------------------------------------------------------------
# Applies-predicates — each REUSES the matching generator's own gate helpers, so
# inclusion can never drift from emission. (The Python generators gate inline;
@@ -95,7 +108,9 @@ def _payload_resolves(tmpl: MetaData, root: MetaData) -> MetaObject | None:
payload_ref = tmpl.get_meta_attr(tc.TEMPLATE_ATTR_PAYLOAD_REF) # ADR-0039: template attr resolves via extends (not origin; templates CAN extend)
if not isinstance(payload_ref, str) or not payload_ref:
return None
- return resolve_payload_vo(root, payload_ref)
+ # ADR-0042 (#228): the referrer is THIS template — a bare @payloadRef resolves
+ # in ITS OWN package first.
+ return resolve_payload_vo(root, payload_ref, _pkg_of(tmpl))
def _is_email_kind(tmpl: MetaData) -> bool:
diff --git a/server/python/src/metaobjects/cli.py b/server/python/src/metaobjects/cli.py
index 11522d4e9..b4b45e3a0 100644
--- a/server/python/src/metaobjects/cli.py
+++ b/server/python/src/metaobjects/cli.py
@@ -84,6 +84,17 @@
verify as render_verify,
)
from metaobjects.shared.base_types import TYPE_TEMPLATE
+from metaobjects.shared.separators import PACKAGE_SEP
+
+
+def _pkg_of(node: MetaData) -> str:
+ """The effective package of a node — its ``resolution_key()`` minus the
+ trailing ``::`` ("" for a root-level node). Duplicated (not imported) to
+ match the existing per-generator convention. Used to derive a template's
+ referrer package for ``_resolve_payload_vo`` (#228)."""
+ key = node.resolution_key()
+ i = key.rfind(PACKAGE_SEP)
+ return "" if i == -1 else key[:i]
def _default_generators() -> list[Generator]:
@@ -642,7 +653,9 @@ def _verify_templates(args: argparse.Namespace) -> int:
print(f"error: [{tmpl.name}] missing @payloadRef.", file=sys.stderr)
error_count += 1
continue
- vo = _resolve_payload_vo(root, payload_ref)
+ # ADR-0042 (#228): the referrer is THIS template — a bare @payloadRef
+ # resolves in ITS OWN package first.
+ vo = _resolve_payload_vo(root, payload_ref, _pkg_of(tmpl))
if vo is None:
print(
f"error: [{tmpl.name}] @payloadRef '{payload_ref}' did not "
diff --git a/server/python/src/metaobjects/codegen/collision_names.py b/server/python/src/metaobjects/codegen/collision_names.py
new file mode 100644
index 000000000..e03b0bfb4
--- /dev/null
+++ b/server/python/src/metaobjects/codegen/collision_names.py
@@ -0,0 +1,110 @@
+"""ADR-0044 — collision-scoped nested-VO name assignment (shared).
+
+Promoted out of ``payload_vo_generator.py`` (formerly the module-private
+``_package_qualified_name`` / ``_assign_nested_names``) so every Python generator that
+walks a payload's nested-value-object closure derives IDENTICAL emitted names for an
+IDENTICAL closure. Today that's the payload-record tier (``payload_vo_generator.py``)
+AND the extract/output-parser tier (``extract_delegate_emitter.py`` /
+``extractor_generator.py`` / ``output_parser_generator.py``) — a nested class an
+extractor module IMPORTS from the sibling payload module must be spelled exactly the
+way the payload module itself emitted it, so both tiers MUST share one naming
+function rather than re-derive a second (and possibly-diverging) copy (issue #228).
+
+:func:`assign_nested_names` returns BASE names only — a bare short name when it is
+unique across the closure, else its package-qualified derived form
+(``acme::alpha`` + ``Note`` → ``AcmeAlphaNote``). It never bakes in a suffix; each
+caller applies its OWN transform on top of the shared base (the payload tier:
+``payload_class_name(base)`` → ``...Payload``; the extract tier: ``f"{base}Extracted"``
+for the mirror dataclass, ``f"_to_strict_{snake(base)}"`` / ``f"_from_{snake(base)}
+_extracted"`` for the mapper function names) — one pure function of the closure feeds
+every naming scheme that must agree on the same base.
+"""
+from __future__ import annotations
+
+from collections.abc import Callable, Mapping
+
+from metaobjects.errors import ErrorCode
+from metaobjects.meta.meta_data import MetaData
+from metaobjects.shared.separators import PACKAGE_SEP
+
+#: ADR-0044 backstop error code — REUSED (never redefined) from the shared cross-port
+#: error-code ledger (``metaobjects.errors.ErrorCode``), which already carries it.
+ERR_PAYLOAD_NAME_COLLISION = ErrorCode.ERR_PAYLOAD_NAME_COLLISION.value
+
+
+def pascal_segment(name: str) -> str:
+ """``priority`` → ``Priority`` (leading char upper-cased only; no snake-splitting)
+ — matches the cross-port rule for PascalCasing a bare field/package segment."""
+ return name[:1].upper() + name[1:] if name else name
+
+
+def package_qualified_name(pkg: str, short_name: str) -> str:
+ """PascalCase each ``::``-segment of *pkg*, concatenate, append the bare
+ *short_name* (``acme::alpha`` + ``Note`` → ``AcmeAlphaNote``). A root-level
+ (empty-package) node keeps its bare short name — the loader's own-package
+ uniqueness already precludes two root-level nodes sharing a name, so this can't
+ silently under-qualify."""
+ if pkg == "":
+ return short_name
+ return "".join(pascal_segment(seg) for seg in pkg.split(PACKAGE_SEP)) + short_name
+
+
+def _pkg_of(node: MetaData) -> str:
+ """The effective package of an object — its ``resolution_key()`` minus the
+ trailing ``::`` ("" for a root-level object). Derived from the resolution
+ key so it is correct for BOTH loaded trees (file_default_package) and
+ programmatically-built trees (package only on the root)."""
+ key = node.resolution_key()
+ i = key.rfind(PACKAGE_SEP)
+ return "" if i == -1 else key[:i]
+
+
+def assign_nested_names(
+ closure: Mapping[str, MetaData],
+ class_name_fn: Callable[[str], str] | None = None,
+) -> dict[str, str]:
+ """ADR-0044 pass 2 — ``resolution_key()`` → emitted name. A PURE function of the
+ closure's ``(key, short-name, package)`` triples, never of traversal order: a bare
+ short name unique in the closure emits its bare form (byte-identical to
+ pre-ADR-0044 output); a short-name collision emits EVERY member under its
+ package-qualified derived form. If two distinct keys still derive the same name,
+ fails loud with ``ERR_PAYLOAD_NAME_COLLISION`` — never silently collides a second
+ time.
+
+ *class_name_fn*, when supplied, transforms each derived BASE name (bare or
+ package-qualified) into the caller's final emitted name — e.g.
+ ``payload_vo_generator`` passes ``payload_class_name`` (bare ``"Note"`` →
+ ``"NotePayload"``) so its own collision backstop message names the actual emitted
+ class. Omitted (``None``, the default) → identity, returning bare BASE names — the
+ extract tier's callers apply their OWN suffix/transform on top (see module
+ docstring) so every naming scheme derives from ONE shared base-assignment pass.
+ """
+ name_fn: Callable[[str], str] = class_name_fn if class_name_fn is not None else (lambda base: base)
+
+ by_short: dict[str, list[str]] = {}
+ for key, node in closure.items():
+ by_short.setdefault(node.name, []).append(key)
+
+ name_map: dict[str, str] = {}
+ for short, keys in by_short.items():
+ if len(keys) == 1:
+ name_map[keys[0]] = name_fn(short)
+ continue
+ for key in keys:
+ node = closure[key]
+ name_map[key] = name_fn(package_qualified_name(_pkg_of(node), short))
+
+ # Backstop — sorted by key so both the emptiness of the colliding set and the
+ # pair named in the message are a pure function of the closure, not dict order.
+ owner: dict[str, str] = {}
+ for key in sorted(name_map):
+ emitted = name_map[key]
+ existing = owner.get(emitted)
+ if existing is not None and existing != key:
+ raise ValueError(
+ f"{ERR_PAYLOAD_NAME_COLLISION}: payload record name collision: "
+ f'"{emitted}" derives from both "{existing}" and "{key}" — rename one '
+ "value-object or move it to a package that derives a distinct name"
+ )
+ owner[emitted] = key
+ return name_map
diff --git a/server/python/src/metaobjects/codegen/extract_delegate_emitter.py b/server/python/src/metaobjects/codegen/extract_delegate_emitter.py
index ae282563a..5c2e7ba8f 100644
--- a/server/python/src/metaobjects/codegen/extract_delegate_emitter.py
+++ b/server/python/src/metaobjects/codegen/extract_delegate_emitter.py
@@ -24,43 +24,55 @@
Bounded by the cross-port ``MAX_NEST_DEPTH`` via the runtime — codegen here only mirrors
the runtime's resolved object graph, so depth/cycle guarding lives in ``object_extract``.
-The emitter dedupes mirrors/mappers by VO simple name (cycle-safe).
+The emitter dedupes mirrors/mappers by ``resolution_key()`` (the package-qualified FQN,
+cycle-safe) — NOT the bare VO ``name``, which would silently collapse two same-short-name
+value-objects from different packages into one (ADR-0044, #228). A bare-name collision
+resolves to a package-qualified emitted name via the shared
+:func:`~metaobjects.codegen.collision_names.assign_nested_names` pass (see
+:func:`build_name_map`) — the SAME naming pass the payload-record tier
+(``payload_vo_generator``) runs, so this tier's names never diverge from the payload
+module's own.
"""
from __future__ import annotations
from metaobjects.codegen import fr010_field_mapping as fm
+from metaobjects.codegen.collision_names import assign_nested_names
from metaobjects.meta.core.field import field_constants as fc
from metaobjects.meta.meta_data import MetaData
-from metaobjects.shared.base_types import TYPE_OBJECT
+from metaobjects.naming_refs import resolve_object_ref
from metaobjects.shared.separators import PACKAGE_SEP
-def _find_object(root: MetaData, name: str) -> MetaData | None:
- """The top-level ``object.*`` node named *name*, or ``None``.
-
- ADR-0039 sanctioned own: top-level object lookup on the loader ROOT
- (metadata.root is never extended, so own == effective) — mirrors the TS
- reference (``root.ownChildren()``).
- """
- for c in root.own_children():
- if c.type == TYPE_OBJECT and c.name == name:
- return c
- return None
+def _pkg_of(node: MetaData) -> str:
+ """The effective package of an object — its ``resolution_key()`` minus the
+ trailing ``::`` ("" for a root-level object). Duplicated (not imported) to
+ match the existing per-generator convention — ``payload_vo_generator.py`` and
+ ``render_helper_generator.py`` each carry their own identical copy."""
+ key = node.resolution_key()
+ i = key.rfind(PACKAGE_SEP)
+ return "" if i == -1 else key[:i]
def ref_vo(field: MetaData, root: MetaData) -> MetaData | None:
"""The ``@objectRef`` target VO for a nested-object field, or ``None`` when
- unresolvable. Matches first on the full ref, then the trailing simple-name
- segment (mirrors the runtime ``_resolve_object_ref`` short-name fallback)."""
+ unresolvable.
+
+ ADR-0042 (#228) — resolves via the canonical `resolve_object_ref` package-local
+ contract: an FQN ref resolves EXACTLY; a bare ref resolves in the DECLARING
+ field's own package first, else a root-level object. NO bare-tail short-name
+ fallback — that pattern (matching an FQN ref by its trailing simple-name segment
+ against ANY same-named object root-wide) is the #219/ADR-0042-banned "wrong
+ node" bug: under a cross-package short-name collision it silently binds
+ whichever same-named object happens to load first, regardless of which package
+ the ref actually pointed at. *referrer_pkg* is the field's OWN declaring
+ package (which differs from the VO's when the field is inherited via `extends`
+ from an abstract VO in another package) — mirrors payload_vo_generator's
+ `_resolve_object_field_type`."""
ref = field.attrs().get(fc.FIELD_ATTR_OBJECT_REF)
if not isinstance(ref, str) or not ref:
return None
- direct = _find_object(root, ref)
- if direct is not None:
- return direct
- if PACKAGE_SEP in ref:
- return _find_object(root, ref.rsplit(PACKAGE_SEP, 1)[-1])
- return None
+ referrer_pkg = _pkg_of(field.parent) if field.parent is not None else ""
+ return resolve_object_ref(root, ref, referrer_pkg)
def _is_object_field(field: MetaData) -> bool:
@@ -69,14 +81,20 @@ def _is_object_field(field: MetaData) -> bool:
return field.sub_type == fc.FIELD_SUBTYPE_OBJECT
-def mirror_name(vo: MetaData) -> str:
- """The extracted-mirror dataclass name for a value-object (``Extracted``)."""
- return f"{vo.name}Extracted"
+def mirror_name(vo: MetaData, name_map: dict[str, str]) -> str:
+ """The extracted-mirror dataclass name for a value-object
+ (``Extracted``) — ADR-0044 (#228) collision-scoped: *base* is the bare
+ ``vo.name`` unless a cross-package bare-name collision requires the
+ package-qualified derived form (see :func:`build_name_map`)."""
+ base = name_map.get(vo.resolution_key(), vo.name)
+ return f"{base}Extracted"
-def _mapper_name(vo: MetaData) -> str:
- """The mapper function name for a value-object (``_from__extracted``)."""
- return f"_from_{_snake(vo.name)}_extracted"
+def _mapper_name(vo: MetaData, name_map: dict[str, str]) -> str:
+ """The mapper function name for a value-object (``_from__extracted``)
+ — *base* per :func:`mirror_name`."""
+ base = name_map.get(vo.resolution_key(), vo.name)
+ return f"_from_{_snake(base)}_extracted"
def root_mapper_name(template_name: str) -> str:
@@ -101,12 +119,12 @@ def _snake(name: str) -> str:
# =============================================================================
-def _nested_mirror_type(field: MetaData, root: MetaData) -> str:
+def _nested_mirror_type(field: MetaData, root: MetaData, name_map: dict[str, str]) -> str:
"""The nullable mirror annotation for one field — nested-aware (nested objects
become ``Extracted``; array-of-objects become ``list[...]``)."""
if _is_object_field(field):
target = ref_vo(field, root)
- base = f'"{mirror_name(target)}"' if target is not None else "object"
+ base = f'"{mirror_name(target, name_map)}"' if target is not None else "object"
elem = f"{base} | None"
return f"list[{elem}] | None" if fm.is_array(field) else elem
if fm.is_array(field):
@@ -130,20 +148,29 @@ def _nested_mirror_type(field: MetaData, root: MetaData) -> str:
def reachable_vos(vo: MetaData, root: MetaData) -> list[MetaData]:
"""``vo`` + every value-object reachable through nested ``@objectRef`` fields, in
- stable BFS order, deduped by simple name (cycle-safe)."""
+ stable BFS order, deduped by ``resolution_key()`` (cycle-safe).
+
+ ADR-0044 (#228) — deduping by the bare ``name`` (pre-fix) silently DROPPED a
+ second cross-package value-object sharing the first one's bare short name (e.g.
+ ``acme::alpha::Note`` + ``acme::beta::Note``): once the first ``Note`` was seen,
+ the second's bare name matched ``seen`` and it was never queued/emitted — a
+ silent shape loss, not merely a naming cosmetic. ``resolution_key()`` is the
+ package-qualified FQN, so two same-short-name VOs from different packages are
+ two distinct keys and both survive the walk."""
out: list[MetaData] = []
seen: set[str] = set()
queue: list[MetaData] = [vo]
while queue:
cur = queue.pop(0)
- if cur.name in seen:
+ key = cur.resolution_key()
+ if key in seen:
continue
- seen.add(cur.name)
+ seen.add(key)
out.append(cur)
for f in fm.fields(cur):
if _is_object_field(f):
target = ref_vo(f, root)
- if target is not None and target.name not in seen:
+ if target is not None and target.resolution_key() not in seen:
queue.append(target)
return out
@@ -156,30 +183,55 @@ def has_nested(vo: MetaData, root: MetaData) -> bool:
return False
+def build_name_map(vo: MetaData, root: MetaData) -> dict[str, str]:
+ """ADR-0044 (#228) — the collision-scoped BASE name map for ``vo``'s reachable
+ nested-VO closure, keyed by ``resolution_key()``. ``vo`` itself (the PRIMARY —
+ named after the enclosing template/payload, never its own bare name) is
+ excluded from the collision domain, mirroring payload_vo_generator's
+ `_collect_nested_closure` (which seeds ``seen`` with the primary's own key for
+ the identical reason).
+
+ Reuses the SAME shared :func:`~metaobjects.codegen.collision_names.assign_nested_names`
+ pass the payload-record tier runs, so a nested VO's derived BASE here agrees
+ exactly with the payload module's own emitted class name (modulo the
+ ``Payload``/``Extracted`` suffix each tier applies on top) — the extractor's
+ imports and the payload module's declarations can never diverge."""
+ primary_key = vo.resolution_key()
+ closure: dict[str, MetaData] = {
+ cur.resolution_key(): cur
+ for cur in reachable_vos(vo, root)
+ if cur.resolution_key() != primary_key
+ }
+ return assign_nested_names(closure)
+
+
# =============================================================================
# Nested-aware mirror dataclasses
# =============================================================================
def nested_mirror_dataclasses(
- vo: MetaData, root: MetaData, payload_mirror: str
+ vo: MetaData, root: MetaData, payload_mirror: str, name_map: dict[str, str]
) -> list[str]:
"""Emit the nested-aware mirror dataclass for ``vo`` and every reachable nested VO
(deduped). The payload mirror keeps the canonical ``Extracted`` name
(``payload_mirror``) so the existing self-contained ``extract_()`` initializer
and the delegating path share ONE mirror type. The nested mirrors carry their own
- ``Extracted`` name. Returns source lines (blank-line separated)."""
+ ADR-0044 (#228) collision-scoped ``Extracted`` name (*name_map*, from
+ :func:`build_name_map`). Returns source lines (blank-line separated)."""
lines: list[str] = []
for i, cur in enumerate(reachable_vos(vo, root)):
if i > 0:
lines.append("")
lines.append("")
- name = payload_mirror if i == 0 else mirror_name(cur)
- lines.extend(_one_mirror(cur, root, name))
+ name = payload_mirror if i == 0 else mirror_name(cur, name_map)
+ lines.extend(_one_mirror(cur, root, name, name_map))
return lines
-def _one_mirror(vo: MetaData, root: MetaData, record_name: str) -> list[str]:
+def _one_mirror(
+ vo: MetaData, root: MetaData, record_name: str, name_map: dict[str, str]
+) -> list[str]:
base = (
record_name[: -len("Extracted")]
if record_name.endswith("Extracted")
@@ -192,7 +244,8 @@ def _one_mirror(vo: MetaData, root: MetaData, record_name: str) -> list[str]:
' (``None`` where the value was lost or malformed)."""',
]
field_lines = [
- f" {f.name}: {_nested_mirror_type(f, root)} = None" for f in fm.fields(vo)
+ f" {f.name}: {_nested_mirror_type(f, root, name_map)} = None"
+ for f in fm.fields(vo)
]
lines.extend(field_lines or [" pass"])
return lines
@@ -204,26 +257,31 @@ def _one_mirror(vo: MetaData, root: MetaData, record_name: str) -> list[str]:
def nested_mappers(
- vo: MetaData, root: MetaData, root_mapper_fn: str, root_mirror: str
+ vo: MetaData,
+ root: MetaData,
+ root_mapper_fn: str,
+ root_mirror: str,
+ name_map: dict[str, str],
) -> list[str]:
- """Emit one ``_from__extracted(o)`` mapper per reachable VO (payload + nested,
- deduped). The ROOT mapper is overridden to the template-derived ``root_mapper_fn`` /
- ``root_mirror`` so it returns the canonically-named root mirror. Returns source
- lines (blank-line separated)."""
+ """Emit one ``_from__extracted(o)`` mapper per reachable VO (payload +
+ nested, deduped). The ROOT mapper is overridden to the template-derived
+ ``root_mapper_fn`` / ``root_mirror`` so it returns the canonically-named root
+ mirror. Nested mappers use the ADR-0044 (#228) collision-scoped *name_map* (from
+ :func:`build_name_map`). Returns source lines (blank-line separated)."""
lines: list[str] = []
vos = reachable_vos(vo, root)
for i, cur in enumerate(vos):
if i > 0:
lines.append("")
lines.append("")
- fn = root_mapper_fn if i == 0 else _mapper_name(cur)
- mir = root_mirror if i == 0 else mirror_name(cur)
- lines.extend(_one_mapper(cur, root, fn, mir))
+ fn = root_mapper_fn if i == 0 else _mapper_name(cur, name_map)
+ mir = root_mirror if i == 0 else mirror_name(cur, name_map)
+ lines.extend(_one_mapper(cur, root, fn, mir, name_map))
return lines
def _one_mapper(
- vo: MetaData, root: MetaData, fn: str, mirror: str
+ vo: MetaData, root: MetaData, fn: str, mirror: str, name_map: dict[str, str]
) -> list[str]:
lines: list[str] = [
f'def {fn}(o: object | None) -> "{mirror} | None":',
@@ -234,19 +292,19 @@ def _one_mapper(
f" return {mirror}(",
]
for f in fm.fields(vo):
- lines.append(f" {f.name}={_mapper_arg(f, root)},")
+ lines.append(f" {f.name}={_mapper_arg(f, root, name_map)},")
lines.append(" )")
return lines
-def _mapper_arg(field: MetaData, root: MetaData) -> str:
+def _mapper_arg(field: MetaData, root: MetaData, name_map: dict[str, str]) -> str:
"""The mirror-field initializer that reads ``field`` from the assembled object ``o``."""
key = f'"{field.name}"'
if _is_object_field(field):
target = ref_vo(field, root)
if target is None:
return "None # unresolved @objectRef"
- fn = _mapper_name(target)
+ fn = _mapper_name(target, name_map)
if fm.is_array(field):
return f"_map_object_list(_read_prop(o, {key}), {fn})"
return f"{fn}(_read_prop(o, {key}))"
diff --git a/server/python/src/metaobjects/codegen/generators/extractor_generator.py b/server/python/src/metaobjects/codegen/generators/extractor_generator.py
index 5a67a14d4..1316cd4f9 100644
--- a/server/python/src/metaobjects/codegen/generators/extractor_generator.py
+++ b/server/python/src/metaobjects/codegen/generators/extractor_generator.py
@@ -52,6 +52,7 @@
from metaobjects.meta.meta_data import MetaData
from metaobjects.meta.template import template_constants as tc
from metaobjects.shared.base_types import TYPE_TEMPLATE
+from metaobjects.shared.separators import PACKAGE_SEP
_GENERATOR_NAME = "extractor-generator"
@@ -59,21 +60,45 @@
_EXTRACT_FORMATS = frozenset({tc.TEMPLATE_FORMAT_JSON, tc.TEMPLATE_FORMAT_XML})
-def _strict_class(vo: MetaData, root_vo: MetaData, template_name: str) -> str:
- """The strict Pydantic class name for a value-object. The ROOT payload VO maps to
- the template-named ``Payload`` (payload_vo emits the primary class under
- the template name); every nested VO maps to ``Payload``."""
- if vo.name == root_vo.name:
+def _pkg_of(node: MetaData) -> str:
+ """The effective package of a node — its ``resolution_key()`` minus the
+ trailing ``::`` ("" for a root-level node). Duplicated (not imported) to
+ match the existing per-generator convention. Used to derive a template's
+ referrer package for ``resolve_payload_vo`` (#228) — see that function's
+ docstring for why this ancestor-walk-aware form is used instead of the
+ loader's bare ``tpl.package or tpl.file_default_package or ""``."""
+ key = node.resolution_key()
+ i = key.rfind(PACKAGE_SEP)
+ return "" if i == -1 else key[:i]
+
+
+def _strict_class(
+ vo: MetaData, root_vo: MetaData, template_name: str, name_map: dict[str, str]
+) -> str:
+ """The strict Pydantic class name for a value-object. The ROOT payload VO
+ (matched by ``resolution_key()`` — NOT bare ``name``, so a nested VO that
+ happens to share the root's bare name across packages is never mistaken for
+ the root) maps to the template-named ``Payload`` (payload_vo emits
+ the primary class under the template name); every OTHER (nested) VO maps to
+ its ADR-0044 (#228) *name_map* base + ``Payload`` — bare when unique in the
+ payload's nested-VO closure, package-qualified on a cross-package short-name
+ collision (see :func:`~metaobjects.codegen.extract_delegate_emitter.build_name_map`,
+ which reuses the SAME naming pass ``payload_vo_generator`` runs, so this name
+ always matches the payload module's own emitted class)."""
+ if vo.resolution_key() == root_vo.resolution_key():
return payload_class_name(template_name)
- return payload_class_name(vo.name)
+ base = name_map.get(vo.resolution_key(), vo.name)
+ return payload_class_name(base)
-def _mapper_name(vo: MetaData) -> str:
- """``_to_strict_`` — the recursive mirror→strict mapper for a VO."""
- return f"_to_strict_{_snake_case(vo.name)}"
+def _mapper_name(vo: MetaData, name_map: dict[str, str]) -> str:
+ """``_to_strict_`` — the recursive mirror→strict mapper for a VO,
+ *base* per the ADR-0044 (#228) *name_map* (see :func:`_strict_class`)."""
+ base = name_map.get(vo.resolution_key(), vo.name)
+ return f"_to_strict_{_snake_case(base)}"
-def _strict_arg(field: MetaData, root: MetaData) -> str:
+def _strict_arg(field: MetaData, root: MetaData, name_map: dict[str, str]) -> str:
"""The strict-payload initializer expression for one field, reading the mirror
member ``m.`` and mapping it onto the strict payload's exact optionality
(``is_field_required`` — shared with payload_vo so there is no skew).
@@ -93,7 +118,7 @@ def _strict_arg(field: MetaData, root: MetaData) -> str:
target = rde.ref_vo(field, root)
if target is None:
return f"m.{name}" # unresolved @objectRef — pass the mirror value through
- fn = _mapper_name(target)
+ fn = _mapper_name(target, name_map)
if fm.is_array(field):
# Required or optional array-of-objects: map present elements (drop Nones).
return f"[{fn}(e) for e in (m.{name} or [])]" if required else (
@@ -119,11 +144,17 @@ def _strict_arg(field: MetaData, root: MetaData) -> str:
return f"m.{name}"
-def _emit_mapper(vo: MetaData, root: MetaData, root_vo: MetaData, template_name: str) -> list[str]:
- """One ``_to_strict_(m) -> `` mapper, one-shot-constructing the strict
+def _emit_mapper(
+ vo: MetaData,
+ root: MetaData,
+ root_vo: MetaData,
+ template_name: str,
+ name_map: dict[str, str],
+) -> list[str]:
+ """One ``_to_strict_(m) -> `` mapper, one-shot-constructing the strict
Pydantic model from the mirror ``m``."""
- fn = _mapper_name(vo)
- strict = _strict_class(vo, root_vo, template_name)
+ fn = _mapper_name(vo, name_map)
+ strict = _strict_class(vo, root_vo, template_name, name_map)
lines: list[str] = [
f"def {fn}(m) -> {strict}:",
f' """Map the all-nullable extracted mirror onto the strict ``{strict}``.',
@@ -131,7 +162,7 @@ def _emit_mapper(vo: MetaData, root: MetaData, root_vo: MetaData, template_name:
f" return {strict}(",
]
for f in fm.fields(vo):
- lines.append(f" {f.name}={_strict_arg(f, root)},")
+ lines.append(f" {f.name}={_strict_arg(f, root, name_map)},")
lines.append(" )")
return lines
@@ -154,7 +185,9 @@ def render_extractor(
payload_ref = template.get_meta_attr(tc.TEMPLATE_ATTR_PAYLOAD_REF) # ADR-0039: template attr resolves via extends (not origin; templates CAN extend)
if not isinstance(payload_ref, str) or not payload_ref:
return None
- payload = resolve_payload_vo(root, payload_ref)
+ # ADR-0042 (#228): the referrer is THIS template — a bare @payloadRef resolves
+ # in ITS OWN package first.
+ payload = resolve_payload_vo(root, payload_ref, _pkg_of(template))
if payload is None:
return None
@@ -171,18 +204,22 @@ def render_extractor(
extract_lenient_fn = f"extract_lenient_{snake}"
extract_fn = f"extract_{snake}"
root_strict = payload_class_name(template_name)
- root_mapper = _mapper_name(payload)
fqn = f"{payload.package}::{template_name}" if payload.package else template_name
# The strict payload graph: root payload class (template-named) + every nested
- # VO's ``Payload`` (reachable through @objectRef, deduped/cycle-safe — the SAME
- # walk payload_vo emits the nested classes for, so each import resolves).
+ # VO's ADR-0044 (#228) collision-scoped ``Payload`` (reachable through
+ # @objectRef, deduped/cycle-safe — the SAME walk + the SAME shared name-map
+ # payload_vo emits the nested classes for, so each import resolves to the exact
+ # class payload_vo_generator declared).
vos = rde.reachable_vos(payload, root)
+ name_map = rde.build_name_map(payload, root)
+ root_mapper = _mapper_name(payload, name_map)
strict_imports = {root_strict}
for vo in vos:
- if vo.name != payload.name:
- strict_imports.add(payload_class_name(vo.name))
+ if vo.resolution_key() != payload.resolution_key():
+ base = name_map.get(vo.resolution_key(), vo.name)
+ strict_imports.add(payload_class_name(base))
lines: list[str] = [
generated_header(template_name, fqn),
@@ -233,7 +270,7 @@ def render_extractor(
if i > 0:
lines.append("")
lines.append("")
- lines.extend(emit_mapper(vo, root, payload, template_name))
+ lines.extend(emit_mapper(vo, root, payload, template_name, name_map))
lines.append("")
lines.append("")
@@ -257,12 +294,14 @@ def _emit_mapper(
root: MetaData,
root_vo: MetaData,
template_name: str,
+ name_map: dict[str, str],
) -> list[str]:
- """EXTENSION SEAM — one ``_to_strict_(m) -> `` mirror→strict
+ """EXTENSION SEAM — one ``_to_strict_(m) -> `` mirror→strict
mapper block. Defaults to the module-level :func:`_emit_mapper`; override to
customize how the extracted mirror graph is mapped onto the strict Pydantic
- payload (e.g. coercion, post-validation, default-filling)."""
- return _emit_mapper(vo, root, root_vo, template_name)
+ payload (e.g. coercion, post-validation, default-filling). *name_map* is the
+ ADR-0044 (#228) collision-scoped name map (see :func:`_strict_class`)."""
+ return _emit_mapper(vo, root, root_vo, template_name, name_map)
def _render_module(self, template: MetaData, root: MetaData) -> str | None:
"""EXTENSION SEAM — render the whole extractor module for one
diff --git a/server/python/src/metaobjects/codegen/generators/output_parser_generator.py b/server/python/src/metaobjects/codegen/generators/output_parser_generator.py
index 57237c6cf..af2c85b12 100644
--- a/server/python/src/metaobjects/codegen/generators/output_parser_generator.py
+++ b/server/python/src/metaobjects/codegen/generators/output_parser_generator.py
@@ -37,6 +37,7 @@
from metaobjects.meta.meta_data import MetaData
from metaobjects.meta.template import template_constants as tc
from metaobjects.shared.base_types import TYPE_TEMPLATE
+from metaobjects.shared.separators import PACKAGE_SEP
# FR-010: only structured formats get a tolerant extract() alongside the strict parser.
_EXTRACT_FORMATS = frozenset({tc.TEMPLATE_FORMAT_JSON, tc.TEMPLATE_FORMAT_XML})
@@ -45,6 +46,52 @@
_GENERATOR_NAME = "output-parser-generator"
+def _pkg_of(node: MetaData) -> str:
+ """The effective package of a node — its ``resolution_key()`` minus the
+ trailing ``::`` ("" for a root-level node). Duplicated (not imported) to
+ match the existing per-generator convention (``payload_vo_generator.py`` /
+ ``render_helper_generator.py`` / ``extract_delegate_emitter.py`` each carry
+ their own identical copy). Used to derive a template's referrer package for
+ ``resolve_payload_vo`` — see that function's docstring for why this
+ ancestor-walk-aware form is used instead of the loader's bare
+ ``tpl.package or tpl.file_default_package or ""`` (equivalent for any
+ loader-parsed tree; ALSO correct for this generator's hand-built test trees)."""
+ key = node.resolution_key()
+ i = key.rfind(PACKAGE_SEP)
+ return "" if i == -1 else key[:i]
+
+
+def _payload_name_collides(root: MetaData, payload: MetaObject) -> bool:
+ """ADR-0044 (#228) — True iff more than one root-level ``MetaObject`` (ANY
+ subtype — matching the exact domain the GENERATED runtime lookup itself
+ scans: ``isinstance(child, MetaObject)``, no subtype filter) shares
+ *payload*'s bare ``name``.
+
+ The emitted ``extract_lenient_*_with_loader`` resolves its payload at
+ RUNTIME via a bare-name-only, load-order-dependent first-match scan over
+ ``root.own_children()`` — the same ADR-0042 "wrong node" hazard class
+ ``ref_vo`` had before #228, just one layer up (a generated-code runtime
+ lookup, not a build-time codegen resolution). When two ``template.output``s
+ in different packages declare an own ``@payloadRef`` payload that shares a
+ bare name, that scan could silently bind whichever object the loader
+ happened to iterate first.
+
+ Mirrors the TS reference (``output-parser.ts``'s ``payloadNameCollides``,
+ driven there by the entity-domain name map Task 3 built for the whole
+ run). Python's payload-record tier has no equivalent whole-run name map to
+ reuse for this signal (its ADR-0044 closure is scoped per-payload, not
+ global) — so this computes the identical collision FACT directly against
+ ``root.own_children()``, the actual domain the generated scan searches."""
+ return (
+ sum(
+ 1
+ for c in root.own_children()
+ if isinstance(c, MetaObject) and c.name == payload.name
+ )
+ > 1
+ )
+
+
def render_output_parser(template: MetaData, root: MetaData) -> str | None:
"""Render one parser module for a ``template.output`` node.
@@ -57,10 +104,19 @@ def render_output_parser(template: MetaData, root: MetaData) -> str | None:
payload_ref = template.get_meta_attr(tc.TEMPLATE_ATTR_PAYLOAD_REF) # ADR-0039: template attr resolves via extends (not origin; templates CAN extend)
if not isinstance(payload_ref, str) or not payload_ref:
return None
- payload = resolve_payload_vo(root, payload_ref)
+ # ADR-0042 (#228): the referrer is THIS template — a bare @payloadRef resolves
+ # in ITS OWN package first.
+ payload = resolve_payload_vo(root, payload_ref, _pkg_of(template))
if payload is None:
return None
+ # ADR-0044 (#228) — computed once, up front: does more than one root-level
+ # object share this payload's bare name? Drives BOTH the conditional
+ # `resolve_object_ref` import below and the PAYLOAD_NAME/lookup emission
+ # further down. Only relevant when the extract-lenient block is emitted
+ # (text-format outputs never bake a runtime payload lookup at all).
+ payload_name_collides = _payload_name_collides(root, payload)
+
template_name = template.name
snake = _snake_case(template_name)
payload_class = payload_class_name(template_name) # Payload
@@ -101,9 +157,18 @@ def render_output_parser(template: MetaData, root: MetaData) -> str | None:
# (which assembles the FULL nested object graph reflection-free by reading
# the live metadata directly). Codegen-wrapping-runtime — mirrors the
# Java/Kotlin/TS pilots.
- lines.append(
- "from metaobjects.meta.core.object.meta_object import MetaObject"
- )
+ #
+ # ADR-0044 (#228) — the payload lookup below is EITHER a bare
+ # `root.own_children()` scan (needs the `MetaObject` isinstance check) OR,
+ # on a bare-name collision, a canonical FQN-exact `resolve_object_ref` (needs
+ # no `MetaObject` import) — so exactly ONE of these two imports is emitted,
+ # never both, keeping the generated module import-clean either way.
+ if payload_name_collides:
+ lines.append("from metaobjects.naming_refs import resolve_object_ref")
+ else:
+ lines.append(
+ "from metaobjects.meta.core.object.meta_object import MetaObject"
+ )
lines.append(
"from metaobjects.meta.core.object.object_extract import extract_object"
)
@@ -128,28 +193,56 @@ def render_output_parser(template: MetaData, root: MetaData) -> str | None:
)
if emit_extract_lenient:
+ # ADR-0044 (#228) — the collision-scoped BASE name map for the payload's
+ # reachable nested-VO closure (bare unless a cross-package bare-name
+ # collision requires package-qualification). Computed ONCE and threaded
+ # through both the mirror dataclasses and the mappers below so a nested
+ # VO's ``Extracted`` name and its `_from__extracted` mapper
+ # agree — and so the STRICT payload class the extractor tier imports for
+ # the SAME base (see extractor_generator.py) can never diverge.
+ name_map = rde.build_name_map(payload, root)
+
# FR-010 nested-AWARE extracted mirror: the payload mirror keeps the canonical
# ``PayloadExtracted`` name, and a mirror dataclass is emitted for every
# reachable nested value-object. The single (delegating) extract path returns it.
- lines.extend(rde.nested_mirror_dataclasses(payload, root, extracted_class))
+ lines.extend(rde.nested_mirror_dataclasses(payload, root, extracted_class, name_map))
lines.append("")
lines.append("")
# ---- Runtime-delegating extract (the single metadata-driven extract path) ----
- # The baked PAYLOAD_NAME is the resolved payload VO's SIMPLE name: the
+ # The baked PAYLOAD_NAME is normally the resolved payload VO's SIMPLE name: the
# delegating entry resolves the MetaObject from a loaded MetaRoot by it
# (root child named ``payload.name``), then delegates to the runtime
# ``extract_object`` (FULL nested graph, reflection-free) and maps the
# assembled ValueObject graph into the typed nullable mirror graph.
+ #
+ # ADR-0044 (#228) — a bare ``root.own_children()`` first-match scan (below) is a
+ # load-order-dependent "wrong node" hazard when this payload's OWN bare name
+ # collides with another root-level object elsewhere in the run (see
+ # ``_payload_name_collides``). When it does, PAYLOAD_NAME bakes the FQN
+ # (``resolution_key()``) and the lookup resolves via the canonical ADR-0042
+ # ``resolve_object_ref`` (FQN-exact, load-order-independent) instead of the
+ # scan. A non-colliding payload keeps the bare name + the scan — byte-identical
+ # to pre-#228 output.
format_enum = "Format.XML" if fmt_str.lower() == "xml" else "Format.JSON"
root_mapper = rde.root_mapper_name(template_name)
extract_lenient_with_fn = f"{extract_lenient_fn}_with_loader"
+ baked_payload_name = (
+ payload.resolution_key() if payload_name_collides else payload.name
+ )
lines.append("#: Payload value-object name this parser extracts — resolved")
- lines.append("#: against a loaded MetaRoot at runtime.")
- lines.append(f'PAYLOAD_NAME = "{payload.name}"')
+ if payload_name_collides:
+ lines.append("#: against a loaded MetaRoot at runtime. ADR-0042 FQN (this")
+ lines.append("#: payload's bare name collides with a same-short-name")
+ lines.append("#: object elsewhere in the run).")
+ else:
+ lines.append("#: against a loaded MetaRoot at runtime.")
+ lines.append(f'PAYLOAD_NAME = "{baked_payload_name}"')
lines.append("")
lines.append("")
- lines.extend(rde.nested_mappers(payload, root, root_mapper, extracted_class))
+ lines.extend(
+ rde.nested_mappers(payload, root, root_mapper, extracted_class, name_map)
+ )
lines.append("")
lines.append("")
lines.extend(rde.delegate_helpers(rde.used_helpers(payload, root)))
@@ -171,16 +264,23 @@ def render_output_parser(template: MetaData, root: MetaData) -> str | None:
lines.append("")
lines.append(" :param root: a loaded ``MetaRoot`` that declares the")
lines.append(f' ``{payload.name}`` value-object."""')
- lines.append(" mo = None")
- # Emits a root-scan into generated code: root is the loader ROOT (never
- # extended, so own == effective) — ADR-0039 sanctioned own in emitted code.
- lines.append(" for child in root.own_children():")
- lines.append(" if (")
- lines.append(" isinstance(child, MetaObject)")
- lines.append(" and child.name == PAYLOAD_NAME")
- lines.append(" ):")
- lines.append(" mo = child")
- lines.append(" break")
+ if payload_name_collides:
+ # ADR-0044 (#228) — PAYLOAD_NAME is a baked FQN; resolve it via the
+ # canonical ADR-0042 package-local contract (FQN-exact here, so the
+ # "" referrer package is inert) rather than a bare load-order-dependent
+ # scan.
+ lines.append(' mo = resolve_object_ref(root, PAYLOAD_NAME, "")')
+ else:
+ lines.append(" mo = None")
+ # Emits a root-scan into generated code: root is the loader ROOT (never
+ # extended, so own == effective) — ADR-0039 sanctioned own in emitted code.
+ lines.append(" for child in root.own_children():")
+ lines.append(" if (")
+ lines.append(" isinstance(child, MetaObject)")
+ lines.append(" and child.name == PAYLOAD_NAME")
+ lines.append(" ):")
+ lines.append(" mo = child")
+ lines.append(" break")
lines.append(" if mo is None:")
lines.append(" raise ValueError(")
lines.append(
diff --git a/server/python/src/metaobjects/codegen/generators/output_prompt_generator.py b/server/python/src/metaobjects/codegen/generators/output_prompt_generator.py
index 0edd2299b..3e4fb8907 100644
--- a/server/python/src/metaobjects/codegen/generators/output_prompt_generator.py
+++ b/server/python/src/metaobjects/codegen/generators/output_prompt_generator.py
@@ -30,6 +30,7 @@ class name, so the prompt fragment and the ``extract_()`` codegen agree on
from metaobjects.meta.meta_data import MetaData
from metaobjects.meta.template import template_constants as tc
from metaobjects.shared.base_types import TYPE_TEMPLATE
+from metaobjects.shared.separators import PACKAGE_SEP
_GENERATOR_NAME = "output-prompt-generator"
@@ -37,6 +38,18 @@ class name, so the prompt fragment and the ``extract_()`` codegen agree on
_PROMPT_FORMATS = frozenset({tc.TEMPLATE_FORMAT_JSON, tc.TEMPLATE_FORMAT_XML})
+def _pkg_of(node: MetaData) -> str:
+ """The effective package of a node — its ``resolution_key()`` minus the
+ trailing ``::`` ("" for a root-level node). Duplicated (not imported) to
+ match the existing per-generator convention. Used to derive a template's
+ referrer package for ``resolve_payload_vo`` (#228) — see that function's
+ docstring for why this ancestor-walk-aware form is used instead of the
+ loader's bare ``tpl.package or tpl.file_default_package or ""``."""
+ key = node.resolution_key()
+ i = key.rfind(PACKAGE_SEP)
+ return "" if i == -1 else key[:i]
+
+
def _emit_format_spec(
payload: MetaObject, template: MetaData, root_name: str
) -> str:
@@ -68,7 +81,9 @@ def render_output_prompt(
payload_ref = template.get_meta_attr(tc.TEMPLATE_ATTR_PAYLOAD_REF) # ADR-0039: template attr resolves via extends (not origin; templates CAN extend)
if not isinstance(payload_ref, str) or not payload_ref:
return None
- payload = resolve_payload_vo(root, payload_ref)
+ # ADR-0042 (#228): the referrer is THIS template — a bare @payloadRef resolves
+ # in ITS OWN package first.
+ payload = resolve_payload_vo(root, payload_ref, _pkg_of(template))
if payload is None:
return None
diff --git a/server/python/src/metaobjects/codegen/generators/payload_vo_generator.py b/server/python/src/metaobjects/codegen/generators/payload_vo_generator.py
index 1b7836678..b54c5be88 100644
--- a/server/python/src/metaobjects/codegen/generators/payload_vo_generator.py
+++ b/server/python/src/metaobjects/codegen/generators/payload_vo_generator.py
@@ -43,6 +43,10 @@ class graph.
from collections.abc import Callable
+from metaobjects.codegen.collision_names import (
+ ERR_PAYLOAD_NAME_COLLISION, # noqa: F401 — re-exported; tests import it from here
+ assign_nested_names,
+)
from metaobjects.codegen.constants import generated_header
from metaobjects.codegen.format import ruff_format
from metaobjects.codegen import type_map
@@ -69,12 +73,9 @@ class graph.
_GENERATOR_NAME = "payload-vo-generator"
-# ADR-0044 backstop error code — a codegen-time (not loader) error, peer of the
-# render tier's ERR_VAR_NOT_ON_PAYLOAD. Declared LOCALLY here for the same reason
-# the TS reference (codegen-ts/src/payload-codegen.ts) declares it locally rather
-# than in the shared cross-port ledger: promoting it to the ledger is coordinated
-# with the Java/Kotlin follow-up so no port reddens on a code it doesn't yet emit.
-ERR_PAYLOAD_NAME_COLLISION = "ERR_PAYLOAD_NAME_COLLISION"
+# ADR-0044 backstop error code — re-exported (not redefined; see #228) from the
+# shared `collision_names` module, which reuses the canonical `errors.ErrorCode`
+# entry. Kept importable under this name for back-compat (tests import it from here).
# ---------------------------------------------------------------------------
@@ -142,11 +143,34 @@ def _resolve_object_by_short_or_fqn(root: MetaData, ref: str) -> MetaObject | No
return None
-def resolve_payload_vo(root: MetaData, ref: str) -> MetaObject | None:
- """Resolve a ``@payloadRef`` to its ``object.value``. Rejects entities —
- payloads MUST be value-objects (same contract as Kotlin)."""
- obj = _resolve_object_by_short_or_fqn(root, ref)
- if obj is None or obj.sub_type != OBJECT_SUBTYPE_VALUE:
+def resolve_payload_vo(root: MetaData, ref: str, referrer_pkg: str) -> MetaObject | None:
+ """Resolve a ``@payloadRef`` to its ``object.value``, PACKAGE-LOCAL (ADR-0042) —
+ the SAME canonical ``resolve_object_ref`` contract the loader's own
+ ``_validate_templates`` pass already uses to validate this exact ref
+ (``loader/validation_passes.py`` — an FQN resolves exactly; a bare ref resolves
+ in the referrer's own package first, else a root-level object). Rejects
+ entities — payloads MUST be value-objects (same contract as Kotlin).
+
+ *referrer_pkg* is the REFERENCING TEMPLATE's own effective package — pass
+ ``_pkg_of(template)`` (this module's ancestor-walk-aware helper, via
+ ``resolution_key()``), NOT the template's bare ``.package``/``.file_default_package``
+ attrs directly: for any LOADER-PARSED tree the two are identical (every parsed
+ node is stamped with ``file_default_package`` at parse time, so
+ ``resolution_key()``'s ancestor-walk branch never fires — this is provably the
+ SAME value the loader's own ``tpl.package or tpl.file_default_package or ""``
+ computes), but ``_pkg_of`` is ALSO correct for the many hand-built (non-loader)
+ ``MetaData`` trees this generator's own test suite constructs (package set only
+ on an ancestor, never stamped onto every node) — the bare expression would
+ wrongly resolve those to ``""``, breaking existing byte-identical output.
+
+ #228 — this used to delegate to ``_resolve_object_by_short_or_fqn``, a flat,
+ package-BLIND bare-name-anywhere-at-root scan: a bare ``@payloadRef`` colliding
+ across packages resolved to whichever same-bare-named ``object.value`` happened
+ to load first, regardless of which package the referencing template belonged
+ to — a "wrong node" mismatch against the loader, which ALREADY validates this
+ exact ref package-local. Now both agree."""
+ obj = resolve_object_ref(root, ref, referrer_pkg)
+ if not isinstance(obj, MetaObject) or obj.sub_type != OBJECT_SUBTYPE_VALUE:
return None
return obj
@@ -465,20 +489,13 @@ def _resolve_field_type(
# assigns names as a pure function of the closure (bare when unique, package-
# qualified on collision, hard fail on a still-colliding derived name); pass 3
# (the existing emit path) uses the name map for both declaration and reference.
+#
+# Pass 2 (:func:`assign_nested_names`) + its ``package_qualified_name`` helper are
+# PROMOTED to ``metaobjects.codegen.collision_names`` (#228) so the extract/
+# output-parser tier reuses this SAME naming pass rather than re-deriving one.
# ---------------------------------------------------------------------------
-def _package_qualified_name(pkg: str, short_name: str) -> str:
- """ADR-0044 — PascalCase each ``::``-segment of *pkg*, concatenate, append the
- bare *short_name* (``acme::alpha`` + ``Note`` → ``AcmeAlphaNote``). A root-level
- (empty-package) node keeps its bare short name — the loader's own-package
- uniqueness already precludes two root-level VOs sharing a name, so this can't
- silently under-qualify."""
- if pkg == "":
- return short_name
- return "".join(_pascal(seg) for seg in pkg.split(PACKAGE_SEP)) + short_name
-
-
def _nested_target_of(field: MetaField, root: MetaData) -> MetaObject | None:
"""The nested-payload target VO a *field* contributes to the module closure,
or ``None`` when it contributes no nested class. Mirrors the resolution in
@@ -544,44 +561,6 @@ def _collect_nested_closure(
_collect_nested_closure(root, target, closure, seen)
-def _assign_nested_names(closure: dict[str, MetaObject]) -> dict[str, str]:
- """ADR-0044 pass 2 — ``resolution_key()`` → emitted class name. A PURE function
- of the closure's ``(key, short-name, package)`` triples, never of traversal
- order: a bare short name unique in the closure emits ``Payload``
- (byte-identical to pre-ADR-0044 output); a short-name collision emits EVERY
- member under its package-qualified derived name
- (``Payload``). If two distinct keys still derive the
- same name, fail loud with ``ERR_PAYLOAD_NAME_COLLISION`` — never silently
- collide a second time."""
- by_short: dict[str, list[str]] = {}
- for key, node in closure.items():
- by_short.setdefault(node.name, []).append(key)
-
- name_map: dict[str, str] = {}
- for short, keys in by_short.items():
- if len(keys) == 1:
- name_map[keys[0]] = payload_class_name(short)
- continue
- for key in keys:
- node = closure[key]
- name_map[key] = payload_class_name(_package_qualified_name(_pkg_of(node), short))
-
- # Backstop — sorted by key so both the emptiness of the colliding set and the
- # pair named in the message are a pure function of the closure, not dict order.
- owner: dict[str, str] = {}
- for key in sorted(name_map):
- emitted = name_map[key]
- existing = owner.get(emitted)
- if existing is not None and existing != key:
- raise ValueError(
- f"{ERR_PAYLOAD_NAME_COLLISION}: payload record name collision: "
- f'"{emitted}" derives from both "{existing}" and "{key}" — rename one '
- "value-object or move it to a package that derives a distinct name"
- )
- owner[emitted] = key
- return name_map
-
-
# ---------------------------------------------------------------------------
# Class-block emission.
# ---------------------------------------------------------------------------
@@ -660,7 +639,9 @@ def render_payload_vo(
payload_ref = template.get_meta_attr(tc.TEMPLATE_ATTR_PAYLOAD_REF) # ADR-0039: template attr resolves via extends (not origin; templates CAN extend)
if not isinstance(payload_ref, str) or not payload_ref:
return None
- payload = resolve_payload_vo(root, payload_ref)
+ # ADR-0042 (#228): the referrer is THIS template — a bare @payloadRef resolves
+ # in ITS OWN package first.
+ payload = resolve_payload_vo(root, payload_ref, _pkg_of(template))
if payload is None:
return None
@@ -669,7 +650,7 @@ def render_payload_vo(
# the TEMPLATE, not the VO) stays out of the VO-short-name collision domain.
closure: dict[str, MetaObject] = {}
_collect_nested_closure(root, payload, closure, {payload.resolution_key()})
- name_map = _assign_nested_names(closure)
+ name_map = assign_nested_names(closure, payload_class_name)
# Per-file dedupe set: scoped to this single render call so each emitted
# module is self-contained (no cross-template forward references). Keyed by
diff --git a/server/python/src/metaobjects/codegen/generators/render_helper_generator.py b/server/python/src/metaobjects/codegen/generators/render_helper_generator.py
index e68039738..00ef2b5e0 100644
--- a/server/python/src/metaobjects/codegen/generators/render_helper_generator.py
+++ b/server/python/src/metaobjects/codegen/generators/render_helper_generator.py
@@ -50,6 +50,9 @@
from metaobjects.codegen.constants import generated_header
from metaobjects.codegen.format import ruff_format
from metaobjects.codegen.generator import EmittedFile, GenContext, Generator
+from metaobjects.codegen.generators.payload_vo_generator import (
+ resolve_payload_vo as _shared_resolve_payload_vo,
+)
from metaobjects.meta.core.field import field_constants as fc
from metaobjects.meta.core.field.meta_field import MetaField
from metaobjects.meta.core.object.meta_object import MetaObject
@@ -161,23 +164,23 @@ def _field_tree_literal(fields: list[PayloadField]) -> str:
# ---------------------------------------------------------------------------
-def _resolve_payload_vo(root: MetaData, payload_ref: str) -> MetaObject | None:
- """``@payloadRef`` must resolve to an ``object.value``. Package-local (ADR-0042):
- the referrer's own package first (else a root-level object), FQN exact. The
- referrer package is derived from the resolved match's own resolution key, so this
- keeps the same behavior for the single-package + FQN codegen fixtures while never
- binding a same-named VO in the wrong package (the nested @objectRef path is the
- one #191 named — see _resolve_nested_object_ref). Matches a value-object child by
- the package-folded resolution key OR the bare short name (FR-026 expands refs to
- FQN, while the child node still carries the short ``name``)."""
- ref_short = payload_ref.rsplit(PACKAGE_SEP, 1)[-1]
- # ADR-0039 sanctioned own: top-level scan on the loader ROOT (never extended, own == effective)
- for child in root.own_children():
- if not isinstance(child, MetaObject) or child.sub_type != OBJECT_SUBTYPE_VALUE:
- continue
- if child.resolution_key() == payload_ref or child.name == ref_short:
- return child
- return None
+def _resolve_payload_vo(
+ root: MetaData, payload_ref: str, referrer_pkg: str
+) -> MetaObject | None:
+ """``@payloadRef`` must resolve to an ``object.value`` — delegates to the ONE
+ shared canonical resolver every other generator uses
+ (:func:`~metaobjects.codegen.generators.payload_vo_generator.resolve_payload_vo`),
+ which routes through ``naming_refs.resolve_object_ref`` (ADR-0042 package-local:
+ an FQN resolves exactly; a bare ref resolves in *referrer_pkg* first, else a
+ root-level object).
+
+ #228 — this used to be a LOCAL bare-tail-fallback matcher (``child.name ==
+ payload_ref.rsplit("::", 1)[-1]``) that mis-bound even an FULLY-QUALIFIED ref
+ under a cross-package bare-name collision — the same #244 "wrong node" class
+ the entity tier already closed elsewhere. Collapsed onto the shared resolver
+ rather than re-deriving a second, subtly-different copy that could (and did)
+ drift out of sync."""
+ return _shared_resolve_payload_vo(root, payload_ref, referrer_pkg)
def _max_chars_of(tmpl: MetaData) -> int | None:
@@ -255,7 +258,9 @@ def generate(self, ctx: GenContext) -> list[EmittedFile]:
"@payloadRef — skipped."
)
continue
- vo = _resolve_payload_vo(root, payload_ref)
+ # ADR-0042 (#228): the referrer is THIS template — a bare @payloadRef
+ # resolves in ITS OWN package first.
+ vo = _resolve_payload_vo(root, payload_ref, _pkg_of(tmpl))
if vo is None:
ctx.warn(
f"{_GENERATOR_NAME}: template.output '{tmpl.name}' @payloadRef "
diff --git a/server/python/src/metaobjects/codegen/generators/trace_helper_generator.py b/server/python/src/metaobjects/codegen/generators/trace_helper_generator.py
index bc6e4cafc..41c216698 100644
--- a/server/python/src/metaobjects/codegen/generators/trace_helper_generator.py
+++ b/server/python/src/metaobjects/codegen/generators/trace_helper_generator.py
@@ -53,6 +53,7 @@
from metaobjects.meta.template import template_constants as tc
from metaobjects.meta.template.meta_template import MetaTemplate
from metaobjects.shared.base_types import TYPE_TEMPLATE
+from metaobjects.shared.separators import PACKAGE_SEP
_GENERATOR_NAME = "trace-helper"
@@ -61,6 +62,18 @@
LLM_CALL_BASE = "LlmCallBase"
+def _pkg_of(node: MetaData) -> str:
+ """The effective package of a node — its ``resolution_key()`` minus the
+ trailing ``::`` ("" for a root-level node). Duplicated (not imported) to
+ match the existing per-generator convention. Used to derive the referring
+ ``template.prompt``'s package for ``resolve_payload_vo`` (#228) — see that
+ function's docstring for why this ancestor-walk-aware form is used instead of
+ the loader's bare ``tpl.package or tpl.file_default_package or ""``."""
+ key = node.resolution_key()
+ i = key.rfind(PACKAGE_SEP)
+ return "" if i == -1 else key[:i]
+
+
def _snake_case(name: str) -> str:
"""``GreetingCall`` → ``greeting_call``. PascalCase → snake_case with no
acronym handling — matches the convention used by sibling generators
@@ -136,7 +149,12 @@ def render_trace_helper(entity: MetaObject, root: MetaData) -> str | None:
# The response VO drives the baked extract schema + the typed voResponse. When
# only @payloadRef is set we still emit a helper (the request is typed); the
# extract schema falls back to an empty descriptor (no response VO to shape it).
- response_vo = resolve_payload_vo(root, response_ref) if response_ref else None
+ # ADR-0042 (#228): the referrer is the PROMPT (a bare @responseRef resolves in
+ # its own package first — the prompt is nested inside `entity` but carries its
+ # OWN effective package via resolution_key()'s ancestor walk).
+ response_vo = (
+ resolve_payload_vo(root, response_ref, _pkg_of(prompt)) if response_ref else None
+ )
if response_ref is not None and response_vo is None:
raise ValueError(
f"{_GENERATOR_NAME}: entity {entity.name!r} prompt @responseRef "
diff --git a/server/python/tests/codegen/test_cli_verify_subverbs.py b/server/python/tests/codegen/test_cli_verify_subverbs.py
index ff6e6ef95..eb7a40d16 100644
--- a/server/python/tests/codegen/test_cli_verify_subverbs.py
+++ b/server/python/tests/codegen/test_cli_verify_subverbs.py
@@ -17,8 +17,11 @@
from __future__ import annotations
+import json
from pathlib import Path
+import pytest
+
from metaobjects.cli import main
FITNESS = (
@@ -312,3 +315,110 @@ def test_combined_codegen_and_templates_aggregates_exit(tmp_path: Path) -> None:
]
)
assert rc != 0
+
+
+# --- FQN @payloadRef collision through the `verify --templates` CLI path ----
+# (#228 fix round 2) — render_helper_generator._resolve_payload_vo (shared by
+# THIS `verify --templates` path) used to fall back to a bare-TAIL short-name
+# match (`child.name == ref.rsplit("::", 1)[-1]`) that could mis-bind an FQN
+# `@payloadRef` under a cross-package bare-name collision: whichever
+# same-bare-named object.value the loader iterated FIRST won, even when the ref
+# explicitly named the OTHER package's object — the #244 "wrong node" class.
+#
+# Two packages each declare a bare-colliding `Note` VO with a DIFFERENT single
+# field (so a wrong-node bind is externally observable as spurious drift);
+# `DigestDoc`'s `@payloadRef` FQN-targets one specific package's `Note`, and its
+# mustache references ONLY that package's field. Filenames force the SORTED
+# (deterministic) load order — both orders are exercised (parametrized) so the
+# fix is proven independent of which package's Note the loader iterates first.
+
+
+@pytest.mark.parametrize(
+ ("first_pkg", "first_field", "second_pkg", "second_field", "target_pkg", "target_field"),
+ [
+ ("alpha", "alphaOnly", "beta", "betaOnly", "beta", "betaOnly"),
+ ("beta", "betaOnly", "alpha", "alphaOnly", "alpha", "alphaOnly"),
+ ],
+ ids=["alpha-loads-first-fqn-targets-beta", "beta-loads-first-fqn-targets-alpha"],
+)
+def test_templates_payload_ref_fqn_collision_binds_correct_package_not_load_first(
+ tmp_path: Path,
+ first_pkg: str,
+ first_field: str,
+ second_pkg: str,
+ second_field: str,
+ target_pkg: str,
+ target_field: str,
+) -> None:
+ d = tmp_path / "meta"
+ d.mkdir()
+ # "1_"/"2_"/"3_" filename prefixes force the sorted directory-scan order
+ # (DirectorySource sorts by filename) — deterministic, not incidental.
+ (d / f"meta.1_{first_pkg}.json").write_text(
+ json.dumps(
+ {
+ "metadata.root": {
+ "package": f"acme::{first_pkg}",
+ "children": [
+ {
+ "object.value": {
+ "name": "Note",
+ "children": [
+ {"field.string": {"name": first_field, "@required": True}}
+ ],
+ }
+ }
+ ],
+ }
+ }
+ )
+ )
+ (d / f"meta.2_{second_pkg}.json").write_text(
+ json.dumps(
+ {
+ "metadata.root": {
+ "package": f"acme::{second_pkg}",
+ "children": [
+ {
+ "object.value": {
+ "name": "Note",
+ "children": [
+ {"field.string": {"name": second_field, "@required": True}}
+ ],
+ }
+ }
+ ],
+ }
+ }
+ )
+ )
+ (d / "meta.3_app.json").write_text(
+ json.dumps(
+ {
+ "metadata.root": {
+ "package": "acme::app",
+ "children": [
+ {
+ "template.output": {
+ "name": "DigestDoc",
+ "@kind": "document",
+ "@payloadRef": f"acme::{target_pkg}::Note",
+ "@textRef": "pages/digest",
+ "@format": "html",
+ }
+ }
+ ],
+ }
+ }
+ )
+ )
+ troot = tmp_path / "templates"
+ (troot / "pages").mkdir(parents=True)
+ (troot / "pages" / "digest.mustache").write_text("{{" + target_field + "}}")
+
+ # A pre-fix wrong-node bind would have resolved to WHICHEVER package loaded
+ # first (never the FQN's actual target unless it happened to be first),
+ # whose field tree lacks `target_field` — spurious ERR_VAR_NOT_ON_PAYLOAD
+ # drift. The fix must report CLEAN (exit 0) regardless of load order.
+ rc = main(["verify", "--templates", str(d), "--templates-root", str(troot)])
+ assert rc == 0
diff --git a/server/python/tests/codegen/test_extract_tier_collision.py b/server/python/tests/codegen/test_extract_tier_collision.py
new file mode 100644
index 000000000..7531f76a1
--- /dev/null
+++ b/server/python/tests/codegen/test_extract_tier_collision.py
@@ -0,0 +1,507 @@
+"""Extract/output-parser tier — ADR-0044 collision-scoped naming (#228, Python port).
+
+Python has TWO real bug classes here (worse than a naming cosmetic — see
+``extract_delegate_emitter.py``):
+
+ 1. ``reachable_vos`` deduped by the bare ``name`` — a second cross-package
+ same-short-name value-object was silently DROPPED (never queued, never
+ emitted): its mirror dataclass and mapper never made it into the generated
+ module, and the root mirror lost the field's shape entirely.
+ 2. ``ref_vo`` mis-resolved an FQN ``@objectRef`` via a bare-tail short-name
+ fallback (the #219 / ADR-0042-banned "wrong node" pattern) — under a
+ cross-package bare-name collision it bound whichever same-named
+ value-object happened to load first, regardless of which package the FQN
+ ref actually pointed at.
+
+Both are exercised against the SAME shared corpus every port's #228 task uses
+(``fixtures/template-output-render-conformance/xpkg-collision-json/``): a
+``Digest`` payload with two ``field.object`` children, each FQN-``@objectRef``-ing
+a DIFFERENT package's same-bare-named ``Note`` (``acme::alpha::Note`` /
+``acme::beta::Note``). The proof is generate -> materialize -> import -> RUN: the
+generated code must extract each nested value-object into its OWN shape (never
+the other's, never dropped).
+
+Design: Python's canonical STRICT extract-tier artifact is the payload record
+(``AcmeAlphaNotePayload`` / ``AcmeBetaNotePayload``, Pydantic ``BaseModel``s) — the
+SAME collision-scoped name-map ``payload_vo_generator`` computes for the payload
+module (promoted to ``metaobjects.codegen.collision_names``), reused (not
+re-derived) by the extract tier so an extractor's import can never diverge from
+the payload module's own emitted class name.
+"""
+from __future__ import annotations
+
+import importlib
+import json
+import os
+import sys
+from importlib import import_module
+from pathlib import Path
+
+import pytest
+
+import metaobjects.core_types # noqa: F401 — side-effect: registers attr classes
+from metaobjects import InMemoryStringSource, MetaDataLoader
+from metaobjects.codegen.config import GenConfig
+from metaobjects.codegen.generator import GenContext
+from metaobjects.codegen.generators.extractor_generator import ExtractorGenerator
+from metaobjects.codegen.generators.output_parser_generator import OutputParserGenerator
+from metaobjects.codegen.generators.payload_vo_generator import PayloadVoGenerator
+from metaobjects.meta.core.field import field_constants as fc
+from metaobjects.meta.core.field.meta_field import MetaField
+from metaobjects.meta.core.object.meta_object import MetaObject
+from metaobjects.meta.meta_root import MetaRoot
+from metaobjects.meta.template import template_constants as tc
+from metaobjects.meta.template.meta_template import MetaTemplate
+from metaobjects.shared.base_types import (
+ SUBTYPE_ROOT,
+ TYPE_FIELD,
+ TYPE_METADATA,
+ TYPE_OBJECT,
+ TYPE_TEMPLATE,
+)
+
+# tests/codegen/ -> parents[0]=codegen, [1]=tests, [2]=python, [3]=server, [4]=repo-root
+CORPUS = (
+ Path(__file__).resolve().parents[4]
+ / "fixtures"
+ / "template-output-render-conformance"
+ / "xpkg-collision-json"
+)
+
+
+def _load_corpus_root() -> MetaRoot:
+ sources = [
+ InMemoryStringSource((CORPUS / f).read_text())
+ for f in ("meta.alpha.json", "meta.beta.json", "meta.app.json")
+ ]
+ res = MetaDataLoader().load(sources)
+ assert res.errors == [], res.errors
+ return res.root
+
+
+def _ctx(root: MetaRoot) -> GenContext:
+ return GenContext(
+ entities=[],
+ loaded_root=root,
+ matches=lambda _e: True,
+ config=GenConfig(out_dir="/tmp/out"),
+ warn=lambda _m: None,
+ )
+
+
+def _all_files(root: MetaRoot) -> list:
+ return (
+ ExtractorGenerator().generate(_ctx(root))
+ + OutputParserGenerator().generate(_ctx(root))
+ + PayloadVoGenerator().generate(_ctx(root))
+ )
+
+
+def _materialize_and_import(files, tmp_path, pkg_name: str):
+ pkg_dir = str(tmp_path / pkg_name)
+ os.makedirs(pkg_dir, exist_ok=True)
+ open(os.path.join(pkg_dir, "__init__.py"), "w").close()
+ for f in files:
+ with open(os.path.join(pkg_dir, f.path), "w") as fh:
+ fh.write(f.content)
+ sys.path.insert(0, str(tmp_path))
+ for k in list(sys.modules):
+ if k == pkg_name or k.startswith(f"{pkg_name}."):
+ del sys.modules[k]
+ return importlib.import_module(pkg_name)
+
+
+# ---------------------------------------------------------------------------
+# output-parser — both colliding mirrors + mappers emitted (bug class 1).
+# ---------------------------------------------------------------------------
+
+
+def test_output_parser_emits_both_colliding_mirrors_and_mappers() -> None:
+ files = [
+ f
+ for f in OutputParserGenerator().generate(_ctx(_load_corpus_root()))
+ if f.path == "digest_doc_output_parser.py"
+ ]
+ assert len(files) == 1
+ src = files[0].content
+
+ # BOTH mirror dataclasses present, collision-scoped — never a shadowed bare
+ # `NoteExtracted` (which would mean the 2nd VO was dropped, or both VOs
+ # collapsed onto one).
+ assert "class AcmeAlphaNoteExtracted:" in src
+ assert "class AcmeBetaNoteExtracted:" in src
+ assert "class NoteExtracted:" not in src
+
+ # BOTH mappers present, each named after its own qualified base — never a
+ # shadowed bare `_from_note_extracted`.
+ assert "def _from_acme_alpha_note_extracted(" in src
+ assert "def _from_acme_beta_note_extracted(" in src
+ assert "def _from_note_extracted(" not in src
+
+ # The root Digest mirror's fields reference the qualified nested mirror types.
+ assert 'fromAlpha: "AcmeAlphaNoteExtracted" | None = None' in src
+ assert 'fromBeta: "AcmeBetaNoteExtracted" | None = None' in src
+
+ # Each mapper reads its OWN field — not the other's (would prove a wrong-node
+ # cross-wire, not just a naming cosmetic).
+ assert 'alphaText=_dlg_str(_read_prop(o, "alphaText"))' in src
+ assert 'betaText=_dlg_str(_read_prop(o, "betaText"))' in src
+
+
+# ---------------------------------------------------------------------------
+# extractor — imports + mappers for both colliding STRICT payload classes
+# (Python's canonical strict artifact).
+# ---------------------------------------------------------------------------
+
+
+def test_extractor_imports_and_maps_both_colliding_strict_payload_classes() -> None:
+ files = [
+ f
+ for f in ExtractorGenerator().generate(_ctx(_load_corpus_root()))
+ if f.path == "digest_doc_extractor.py"
+ ]
+ assert len(files) == 1
+ src = files[0].content
+
+ # Imports BOTH qualified strict payload classes from the sibling payload
+ # module — never a shadowed bare `NotePayload` import (checked as the exact
+ # indented import-list line so `AcmeAlphaNotePayload,` — which CONTAINS
+ # `NotePayload,` as a trailing substring — can't false-positive the negative
+ # assertion).
+ assert " AcmeAlphaNotePayload,\n" in src
+ assert " AcmeBetaNotePayload,\n" in src
+ assert " NotePayload,\n" not in src
+
+ # BOTH mirror->strict mappers present, named after the qualified base.
+ assert "def _to_strict_acme_alpha_note(" in src
+ assert "def _to_strict_acme_beta_note(" in src
+ assert "def _to_strict_note(" not in src
+
+ # Each mapper constructs its OWN payload class from its OWN mirror field.
+ assert "return AcmeAlphaNotePayload(" in src
+ assert "return AcmeBetaNotePayload(" in src
+
+
+def test_payload_module_emits_both_colliding_classes_never_bare_note() -> None:
+ """Sanity: the sibling payload module (imported by the extractor above) emits
+ the SAME two qualified classes — proves the extract tier reuses (not
+ re-derives) the payload tier's own name-map."""
+ files = [
+ f
+ for f in PayloadVoGenerator().generate(_ctx(_load_corpus_root()))
+ if f.path == "digest_doc_payload.py"
+ ]
+ assert len(files) == 1
+ src = files[0].content
+ assert "class AcmeAlphaNotePayload(BaseModel):" in src
+ assert "class AcmeBetaNotePayload(BaseModel):" in src
+ assert "class NotePayload(BaseModel):" not in src
+
+
+# ---------------------------------------------------------------------------
+# Compile + RUN — the strongest proof: each nested VO extracts into ITS OWN
+# shape, never the other's (the #219/ADR-0042 "wrong node" bug, bug class 2).
+# ---------------------------------------------------------------------------
+
+
+def test_extract_and_extract_lenient_run_each_nested_vo_into_its_own_shape(
+ tmp_path,
+) -> None:
+ root = _load_corpus_root()
+ files = _all_files(root)
+ _materialize_and_import(files, tmp_path, "_xpkg_collision_pkg")
+ ex = import_module("_xpkg_collision_pkg.digest_doc_extractor")
+
+ text = json.dumps(
+ {"fromAlpha": {"alphaText": "AA"}, "fromBeta": {"betaText": "BB"}}
+ )
+
+ # Tolerant delegating extract (never raises) — each nested field keeps its
+ # OWN shape.
+ lenient = ex.extract_lenient_digest_doc(root, text)
+ assert lenient.report.has_lost_required() is False
+ assert lenient.data.fromAlpha.alphaText == "AA"
+ assert lenient.data.fromBeta.betaText == "BB"
+
+ # Strict extract — full type fidelity end-to-end (payload module + output
+ # parser + extractor all agree on the SAME qualified classes).
+ strict = ex.extract_digest_doc(root, text)
+ assert strict.fromAlpha.alphaText == "AA"
+ assert strict.fromBeta.betaText == "BB"
+ assert type(strict.fromAlpha).__name__ == "AcmeAlphaNotePayload"
+ assert type(strict.fromBeta).__name__ == "AcmeBetaNotePayload"
+ # Each nested payload carries ONLY its own package's field — proves fromBeta
+ # was never cross-wired onto alpha's shape (the pre-fix wrong-node bug).
+ assert not hasattr(strict.fromAlpha, "betaText")
+ assert not hasattr(strict.fromBeta, "alphaText")
+
+
+# ---------------------------------------------------------------------------
+# no-churn — a non-colliding nested VO keeps bare names (qualification never
+# fires); global constraint: byte-identical when there is no collision.
+# ---------------------------------------------------------------------------
+
+
+def _field(name: str, sub: str, **attrs: object) -> MetaField:
+ f = MetaField(TYPE_FIELD, sub, name)
+ for k, v in attrs.items():
+ f.set_attr(k, v)
+ return f
+
+
+def _value_object(name: str, fields: list[MetaField]) -> MetaObject:
+ obj = MetaObject(TYPE_OBJECT, "value", name)
+ for f in fields:
+ obj.add_child(f)
+ return obj
+
+
+def _output_template(name: str, payload_ref: str) -> MetaTemplate:
+ tmpl = MetaTemplate(TYPE_TEMPLATE, tc.TEMPLATE_SUBTYPE_OUTPUT, name)
+ tmpl.set_attr(tc.TEMPLATE_ATTR_PAYLOAD_REF, payload_ref)
+ tmpl.set_attr(tc.TEMPLATE_ATTR_TEXT_REF, "tpl/output")
+ tmpl.set_attr(tc.TEMPLATE_ATTR_FORMAT, "json")
+ return tmpl
+
+
+def test_no_churn_non_colliding_nested_vo_keeps_bare_names() -> None:
+ detail = _value_object(
+ "Detail",
+ [_field("note", fc.FIELD_SUBTYPE_STRING, **{fc.FIELD_ATTR_REQUIRED: True})],
+ )
+ widget = _value_object(
+ "Widget",
+ [
+ _field(
+ "detail",
+ fc.FIELD_SUBTYPE_OBJECT,
+ **{fc.FIELD_ATTR_OBJECT_REF: "Detail", fc.FIELD_ATTR_REQUIRED: True},
+ )
+ ],
+ )
+ tmpl = _output_template("WidgetOut", "Widget")
+ root = MetaRoot(TYPE_METADATA, SUBTYPE_ROOT, "test")
+ root.package = "acme::demo"
+ for c in (detail, widget, tmpl):
+ root.add_child(c)
+
+ parser_src = OutputParserGenerator().generate(_ctx(root))[0].content
+ extractor_src = ExtractorGenerator().generate(_ctx(root))[0].content
+ payload_src = PayloadVoGenerator().generate(_ctx(root))[0].content
+
+ assert "class DetailExtracted:" in parser_src
+ assert "def _from_detail_extracted(" in parser_src
+ assert "AcmeDemo" not in parser_src
+
+ assert "DetailPayload" in extractor_src
+ assert "def _to_strict_detail(" in extractor_src
+ assert "AcmeDemo" not in extractor_src
+
+ assert "class DetailPayload(BaseModel):" in payload_src
+ assert "AcmeDemo" not in payload_src
+
+
+# ---------------------------------------------------------------------------
+# Fix round 1 (#228 MUST-FIX) — output_parser_generator.py's GENERATED-RUNTIME
+# PAYLOAD_NAME lookup. The emitted `extract_lenient_*_with_loader` resolves its
+# OWN payload at RUNTIME via `for child in root.own_children(): if child.name
+# == PAYLOAD_NAME` — a bare, load-order-dependent first-match scan. When two
+# `template.output`s in different packages declare an OWN `@payloadRef` payload
+# that shares a bare name, that scan could bind whichever object the loader
+# happened to iterate first. Mirrors the TS reference's
+# ReportDocAlpha/ReportDocBeta proof (output-parser.ts's `payloadNameCollides`).
+#
+# Fix round 2 (#228 Important, fable-adjudicated in-scope) — this originally
+# authored each `@payloadRef` as an FQN to sidestep a SEPARATE bug found while
+# writing this test: `payload_vo_generator.resolve_payload_vo` (the BUILD-TIME
+# `@payloadRef` -> object.value resolver — the loader's own `_validate_templates`
+# pass ALREADY resolves this exact ref package-local, so codegen silently
+# emitting a DIFFERENT object than the loader validated was in-charter) was not
+# referrer-package-aware for a BARE ref, resolving to whichever same-bare-named
+# object.value loaded first, regardless of which package the referencing
+# template belonged to. Round 2 fixed `resolve_payload_vo` (now routes through
+# `naming_refs.resolve_object_ref`, package-local, ADR-0042) — so this now
+# authors the REALISTIC common case (a bare, same-package self-reference,
+# exactly like the TS reference's own test) and proves BOTH fixes together:
+# build-time resolution binds each template to ITS OWN package's `Report`
+# (round 2), and the runtime `PAYLOAD_NAME` lookup for that same-bare-name
+# collision correctly bakes the FQN (round 1) — tested under BOTH load orders
+# (`beta_first`), since the pre-round-2 bug was load-order-dependent.
+# ---------------------------------------------------------------------------
+
+
+def _load_two_package_report_collision_root(*, beta_first: bool = False) -> MetaRoot:
+ """Two packages, each declaring its OWN bare-colliding ``Report`` value-object
+ and a ``template.output`` that references it via a BARE (same-package)
+ ``@payloadRef`` — the realistic common case (mirrors the TS reference's
+ ``ReportDocAlpha``/``ReportDocBeta`` test). *beta_first* controls load order
+ (``MetaDataLoader().load()`` processes an in-memory source list in the given
+ order — unlike the CLI's directory scan, there is no filename to sort by)."""
+ alpha = {
+ "metadata.root": {
+ "package": "acme::alpha",
+ "children": [
+ {
+ "object.value": {
+ "name": "Report",
+ "children": [
+ {"field.string": {"name": "alphaVal", "@required": True}}
+ ],
+ }
+ },
+ {
+ "template.output": {
+ "name": "ReportDocAlpha",
+ "@payloadRef": "Report",
+ "@textRef": "unused/a",
+ "@format": "json",
+ }
+ },
+ ],
+ }
+ }
+ beta = {
+ "metadata.root": {
+ "package": "acme::beta",
+ "children": [
+ {
+ "object.value": {
+ "name": "Report",
+ "children": [
+ {"field.string": {"name": "betaVal", "@required": True}}
+ ],
+ }
+ },
+ {
+ "template.output": {
+ "name": "ReportDocBeta",
+ "@payloadRef": "Report",
+ "@textRef": "unused/b",
+ "@format": "json",
+ }
+ },
+ ],
+ }
+ }
+ alpha_src = InMemoryStringSource(json.dumps(alpha))
+ beta_src = InMemoryStringSource(json.dumps(beta))
+ sources = [beta_src, alpha_src] if beta_first else [alpha_src, beta_src]
+ res = MetaDataLoader().load(sources)
+ assert res.errors == [], res.errors
+ return res.root
+
+
+@pytest.mark.parametrize(
+ "beta_first", [False, True], ids=["alpha-loads-first", "beta-loads-first"]
+)
+def test_colliding_own_payload_name_binds_own_package_bakes_fqn(
+ beta_first: bool,
+) -> None:
+ """Bare, same-package ``@payloadRef`` — each template binds ITS OWN
+ package's ``Report`` at BUILD time (round 2's `resolve_payload_vo` fix),
+ regardless of load order, and each bakes ITS OWN FQN for the RUNTIME lookup
+ (round 1's fix) — never the bare ``"Report"``, never the other's FQN."""
+ root = _load_two_package_report_collision_root(beta_first=beta_first)
+ files = OutputParserGenerator().generate(_ctx(root))
+ alpha_src = next(
+ f for f in files if f.path == "report_doc_alpha_output_parser.py"
+ ).content
+ beta_src = next(
+ f for f in files if f.path == "report_doc_beta_output_parser.py"
+ ).content
+
+ # Each template's STRICT parser binds its OWN package's shape — proves
+ # round 2's build-time resolve_payload_vo fix (never load-order-dependent).
+ assert "def parse_report_doc_alpha(text: str) -> ReportDocAlphaPayload:" in alpha_src
+ assert "from .report_doc_alpha_payload import ReportDocAlphaPayload" in alpha_src
+ assert "def parse_report_doc_beta(text: str) -> ReportDocBetaPayload:" in beta_src
+ assert "from .report_doc_beta_payload import ReportDocBetaPayload" in beta_src
+
+ # Each bakes ITS OWN FQN — never the bare "Report", never the other's FQN.
+ assert 'PAYLOAD_NAME = "acme::alpha::Report"' in alpha_src
+ assert 'PAYLOAD_NAME = "acme::beta::Report"' in beta_src
+ assert 'PAYLOAD_NAME = "Report"' not in alpha_src
+ assert 'PAYLOAD_NAME = "Report"' not in beta_src
+
+ # Both resolve via the canonical FQN-exact resolver — never the bare,
+ # load-order-dependent `root.own_children()` scan.
+ assert "from metaobjects.naming_refs import resolve_object_ref" in alpha_src
+ assert "from metaobjects.naming_refs import resolve_object_ref" in beta_src
+ assert 'mo = resolve_object_ref(root, PAYLOAD_NAME, "")' in alpha_src
+ assert 'mo = resolve_object_ref(root, PAYLOAD_NAME, "")' in beta_src
+ assert "root.own_children()" not in alpha_src
+ assert "root.own_children()" not in beta_src
+ # No leftover unused MetaObject import on the FQN-resolve path.
+ assert "meta_object import MetaObject" not in alpha_src
+ assert "meta_object import MetaObject" not in beta_src
+
+
+@pytest.mark.parametrize(
+ "beta_first", [False, True], ids=["alpha-loads-first", "beta-loads-first"]
+)
+def test_colliding_own_payload_names_run_verified_each_extracts_its_own_shape(
+ tmp_path, beta_first: bool
+) -> None:
+ """The strongest proof: generate BOTH output parsers (+ the payload module)
+ from a bare, same-package ``@payloadRef`` on each side, materialize + import
+ + RUN both against ONE shared MetaRoot. Each must extract via ITS OWN
+ payload shape — never the other's, and never dependent on load order (the
+ pre-round-2 build-time bug — and the pre-round-1 runtime bug — would BOTH
+ have made this load-order-dependent: whichever object iterated first would
+ win for BOTH templates)."""
+ root = _load_two_package_report_collision_root(beta_first=beta_first)
+ files = (
+ OutputParserGenerator().generate(_ctx(root))
+ + PayloadVoGenerator().generate(_ctx(root))
+ )
+ _materialize_and_import(files, tmp_path, "_payload_collision_pkg")
+ alpha_mod = import_module(
+ "_payload_collision_pkg.report_doc_alpha_output_parser"
+ )
+ beta_mod = import_module("_payload_collision_pkg.report_doc_beta_output_parser")
+
+ # Strict, throw-only parse (FR-006) — each binds its OWN package's shape.
+ alpha_payload = alpha_mod.parse_report_doc_alpha(json.dumps({"alphaVal": "AV"}))
+ assert alpha_payload.alphaVal == "AV"
+ beta_payload = beta_mod.parse_report_doc_beta(json.dumps({"betaVal": "BV"}))
+ assert beta_payload.betaVal == "BV"
+
+ alpha_result = alpha_mod.extract_lenient_report_doc_alpha_with_loader(
+ root, json.dumps({"alphaVal": "AV"})
+ )
+ assert alpha_result.report.has_lost_required() is False
+ assert alpha_result.data.alphaVal == "AV"
+
+ beta_result = beta_mod.extract_lenient_report_doc_beta_with_loader(
+ root, json.dumps({"betaVal": "BV"})
+ )
+ assert beta_result.report.has_lost_required() is False
+ assert beta_result.data.betaVal == "BV"
+
+ # Neither result carries the OTHER template's field (a cross-wire would
+ # show up as a spurious extra attribute or the wrong value).
+ assert not hasattr(alpha_result.data, "betaVal")
+ assert not hasattr(beta_result.data, "alphaVal")
+ assert not hasattr(alpha_payload, "betaVal")
+ assert not hasattr(beta_payload, "alphaVal")
+
+
+def test_no_churn_unique_payload_name_keeps_bare_scan_no_fqn_resolver() -> None:
+ """Global constraint: a UNIQUE (non-colliding) payload's OWN bare name keeps
+ TODAY'S exact runtime lookup — bare ``PAYLOAD_NAME`` + the
+ ``root.own_children()`` scan — with NO FQN bake and NO
+ ``resolve_object_ref`` import/call. Reuses the xpkg-collision-json corpus's
+ ``DigestDoc`` template, whose OWN payload (``Digest``) is unique (only the
+ NESTED ``Note`` VOs collide — a different, already-fixed concern)."""
+ root = _load_corpus_root()
+ files = [
+ f
+ for f in OutputParserGenerator().generate(_ctx(root))
+ if f.path == "digest_doc_output_parser.py"
+ ]
+ assert len(files) == 1
+ src = files[0].content
+ assert 'PAYLOAD_NAME = "Digest"' in src
+ assert "root.own_children()" in src
+ assert "resolve_object_ref" not in src
+ assert "from metaobjects.naming_refs" not in src
diff --git a/server/python/tests/codegen/test_payload_vo_generator.py b/server/python/tests/codegen/test_payload_vo_generator.py
index 9e85fa75b..411c8b32b 100644
--- a/server/python/tests/codegen/test_payload_vo_generator.py
+++ b/server/python/tests/codegen/test_payload_vo_generator.py
@@ -447,7 +447,11 @@ def test_cross_package_short_name_collision_qualifies_both_nested_classes() -> N
],
package="acme::app",
)
- tmpl = _template("DigestOut", "Digest")
+ # #228 fix round 2: @payloadRef authored FQN — `Digest` is explicitly
+ # package="acme::app" while `tmpl` (added at root, default package
+ # "acme::ai") is NOT in that package; ADR-0042 package-local resolution
+ # requires an FQN ref here (matches how this would actually be authored).
+ tmpl = _template("DigestOut", "acme::app::Digest")
root = _root([alpha, beta, digest, tmpl])
out = render_payload_vo(tmpl, root)
assert out is not None
@@ -478,7 +482,11 @@ def test_nested_of_nested_collision_qualifies_across_depths() -> None:
],
package="acme::app",
)
- tmpl = _template("DigestOut", "Digest")
+ # #228 fix round 2: @payloadRef authored FQN — `Digest` is explicitly
+ # package="acme::app" while `tmpl` (added at root, default package
+ # "acme::ai") is NOT in that package; ADR-0042 package-local resolution
+ # requires an FQN ref here (matches how this would actually be authored).
+ tmpl = _template("DigestOut", "acme::app::Digest")
root = _root([beta_note, gamma_note, outer, digest, tmpl])
out = render_payload_vo(tmpl, root)
assert out is not None
@@ -505,7 +513,11 @@ def test_derived_name_still_colliding_fails_loud() -> None:
],
package="acme::app",
)
- tmpl = _template("DigestOut", "Digest")
+ # #228 fix round 2: @payloadRef authored FQN — `Digest` is explicitly
+ # package="acme::app" while `tmpl` (added at root, default package
+ # "acme::ai") is NOT in that package; ADR-0042 package-local resolution
+ # requires an FQN ref here (matches how this would actually be authored).
+ tmpl = _template("DigestOut", "acme::app::Digest")
root = _root([a, b, digest, tmpl])
with pytest.raises(ValueError) as ei:
render_payload_vo(tmpl, root)
diff --git a/server/python/tests/codegen/test_render_helper_generator.py b/server/python/tests/codegen/test_render_helper_generator.py
index 819ede759..6596f437e 100644
--- a/server/python/tests/codegen/test_render_helper_generator.py
+++ b/server/python/tests/codegen/test_render_helper_generator.py
@@ -399,7 +399,14 @@ def test_snake_case_pascal_to_snake() -> None:
def test_resolve_payload_vo_matches_short_and_fully_qualified_ref() -> None:
"""FR-026 expands @payloadRef to a fully-qualified ``a::b::Name`` while the
- object.value child still carries the short ``name`` — both forms must resolve."""
+ object.value child still carries the short ``name`` — both forms must resolve.
+
+ #228 fix round 2: ``_resolve_payload_vo`` now requires an explicit
+ ``referrer_pkg`` (package-local, ADR-0042) — a SHORT (bare) ref resolves
+ against the referrer's OWN package (here ``"acme::blog"``, the SAME package
+ ``WelcomePayload`` is declared in — the common same-package case this test
+ exercises); an FQN ref resolves exactly regardless of the referrer package
+ (passed ``""`` below to prove that)."""
import json
from metaobjects import InMemoryStringSource, MetaDataFormat, MetaDataLoader
@@ -413,6 +420,6 @@ def test_resolve_payload_vo_matches_short_and_fully_qualified_ref() -> None:
id="m.json", format=MetaDataFormat.JSON,
)
]).root
- assert _resolve_payload_vo(root, "WelcomePayload") is not None # short ref
- assert _resolve_payload_vo(root, "acme::blog::WelcomePayload") is not None # FQN ref
- assert _resolve_payload_vo(root, "acme::blog::Missing") is None
+ assert _resolve_payload_vo(root, "WelcomePayload", "acme::blog") is not None # short ref, same-package referrer
+ assert _resolve_payload_vo(root, "acme::blog::WelcomePayload", "") is not None # FQN ref
+ assert _resolve_payload_vo(root, "acme::blog::Missing", "") is None
diff --git a/server/python/uv.lock b/server/python/uv.lock
index 8b39a310e..86825d3f3 100644
--- a/server/python/uv.lock
+++ b/server/python/uv.lock
@@ -250,7 +250,7 @@ wheels = [
[[package]]
name = "metaobjects"
-version = "0.19.6"
+version = "0.19.8"
source = { editable = "." }
dependencies = [
{ name = "pyyaml" },
diff --git a/server/typescript/packages/codegen-ts/src/generators/barrel.ts b/server/typescript/packages/codegen-ts/src/generators/barrel.ts
index cb97d6488..94c45ab83 100644
--- a/server/typescript/packages/codegen-ts/src/generators/barrel.ts
+++ b/server/typescript/packages/codegen-ts/src/generators/barrel.ts
@@ -13,7 +13,7 @@ export const barrel = function barrel(opts?: BarrelOpts): Generator {
path: "index.ts",
content: await formatTs(
renderBarrel(
- entities.map((e) => ({ name: e.name, package: e.package })),
+ entities.map((e) => ({ name: ctx.renderContext!.valueObjectEmittedName(e), package: e.package })),
ctx.renderContext!.extStyle,
ctx.renderContext!.selfTarget,
ctx.renderContext!.entityModuleTarget,
diff --git a/server/typescript/packages/codegen-ts/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/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/naming/collision-names.ts b/server/typescript/packages/codegen-ts/src/naming/collision-names.ts
new file mode 100644
index 000000000..b2000db3b
--- /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 ErrorCode, type MetaData, PACKAGE_SEPARATOR } from "@metaobjectsdev/metadata";
+
+// ADR-0044 backstop error code — a codegen-time (not loader) error, peer of
+// @metaobjectsdev/render's ERR_VAR_NOT_ON_PAYLOAD. Already promoted to the shared
+// cross-language error-code ledger in 0.19.3 (packages/metadata/src/errors.ts's
+// ERROR_CODES, fixtures/conformance/ERROR-CODES.json, server/python/src/metaobjects/errors.py).
+// That ledger has no per-code named export (only the ERROR_CODES array + the ErrorCode
+// union type), so this stays a local literal — but `satisfies ErrorCode` binds it to the
+// shared ledger's type: a future rename/removal there fails this file's typecheck instead
+// of silently drifting.
+export const ERR_PAYLOAD_NAME_COLLISION = "ERR_PAYLOAD_NAME_COLLISION" satisfies ErrorCode;
+
+function pascalSegment(s: string): string {
+ return s.length > 0 ? s[0]!.toUpperCase() + s.slice(1) : s;
+}
+
+/** 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/src/reference/barrel.ts b/server/typescript/packages/codegen-ts/src/reference/barrel.ts
index ca4ac7b7a..74e080c0f 100644
--- a/server/typescript/packages/codegen-ts/src/reference/barrel.ts
+++ b/server/typescript/packages/codegen-ts/src/reference/barrel.ts
@@ -51,7 +51,7 @@ export const barrel = function barrel(opts?: BarrelOpts): Generator {
path: "index.ts",
content: await formatTs(
renderBarrel(
- entities.map((e) => ({ name: e.name, package: e.package })),
+ entities.map((e) => ({ name: ctx.renderContext!.valueObjectEmittedName(e), package: e.package })),
ctx.renderContext!.extStyle,
ctx.renderContext!.selfTarget,
ctx.renderContext!.entityModuleTarget,
diff --git a/server/typescript/packages/codegen-ts/src/render-context.ts b/server/typescript/packages/codegen-ts/src/render-context.ts
index 14438287b..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,7 @@
// RenderContext — cross-cutting state passed to every template.
-import type { MetaRoot } 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";
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. */
@@ -93,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 {
@@ -129,6 +165,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 +180,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..415eb5c23 100644
--- a/server/typescript/packages/codegen-ts/src/runner.ts
+++ b/server/typescript/packages/codegen-ts/src/runner.ts
@@ -1,7 +1,9 @@
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 { 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";
@@ -143,9 +145,38 @@ 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` 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 (isEmittedValueObject(o)) 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 +223,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..52fced31c 100644
--- a/server/typescript/packages/codegen-ts/src/templates/drizzle-schema.ts
+++ b/server/typescript/packages/codegen-ts/src/templates/drizzle-schema.ts
@@ -9,8 +9,9 @@ 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 { 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";
@@ -386,8 +387,18 @@ 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 emitted = typeof refRaw === "string"
+ ? ctx.resolveValueObjectName(refRaw, fieldDeclaringPackage(field, entityPackage))
+ : 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/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/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 (`Extracted`)
* since the template name may differ from the payload VO name.
*/
-function emitMappers(payloadVo: MetaData, root: MetaData, rootMirror: string): string {
+function emitMappers(payloadVo: MetaData, root: MetaData, rootMirror: string, ctx?: RenderContext): string {
const out: string[] = [];
const seen = new Set();
- emitMapper(payloadVo, root, seen, out, rootMirror);
+ emitMapper(payloadVo, root, seen, out, rootMirror, ctx);
return out.join("\n\n");
}
@@ -177,14 +182,17 @@ function emitMapper(
seen: Set,
out: string[],
mirrorOverride?: string,
+ ctx?: RenderContext,
): void {
- if (seen.has(vo.name)) return;
- seen.add(vo.name);
+ // ADR-0044/#228: dedupe by resolutionKey() — see extract-delegate-emitter's emitMirror for why
+ // bare-name dedupe silently drops the second colliding VO's toStrict mapper.
+ if (seen.has(vo.resolutionKey())) return;
+ seen.add(vo.resolutionKey());
- const fn = mapperName(vo);
- const strict = vo.name;
- const mir = mirrorOverride ?? mirrorName(vo);
- const assigns = fields(vo).map((f) => ` ${f.name}: ${strictArg(f, root, vo.name)},`);
+ const fn = mapperName(vo, ctx);
+ const strict = ctx ? ctx.valueObjectEmittedName(vo) : vo.name;
+ const mir = mirrorOverride ?? mirrorName(vo, ctx);
+ const assigns = fields(vo).map((f) => ` ${f.name}: ${strictArg(f, root, strict, ctx)},`);
out.push(
[
`/** Map the all-nullable \`${mir}\` mirror onto the strict \`${strict}\` payload. Generated. */`,
@@ -199,7 +207,7 @@ 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);
}
}
}
@@ -224,23 +232,29 @@ interface PayloadImportGroup {
* VO's interface AND the aliases for its own enum fields are imported from `./.js` — NOT from a
* single `payloads.ts` (which no generator emits). Deduped, in discovery order, one group per VO.
*/
-function reachablePayloadGroups(vo: MetaData, root: MetaData): PayloadImportGroup[] {
+function reachablePayloadGroups(vo: MetaData, root: MetaData, ctx?: RenderContext): PayloadImportGroup[] {
const groups: PayloadImportGroup[] = [];
const seenVo = new Set();
const seenAlias = new Set();
const visit = (cur: MetaData) => {
- if (seenVo.has(cur.name)) return;
- seenVo.add(cur.name);
- // The VO interface + its OWN enum aliases share the VO's entity module.
- const types: string[] = [cur.name];
+ // ADR-0044/#228: dedupe by resolutionKey() — bare-name dedupe would treat a colliding
+ // second VO as "already seen" and drop its own import group entirely.
+ if (seenVo.has(cur.resolutionKey())) return;
+ seenVo.add(cur.resolutionKey());
+ // The VO interface + its OWN enum aliases share the VO's entity module. The module target
+ // is the ADR-0044/#228 entity-domain EMITTED name (Task 3's entityFile() writes `.ts`),
+ // so a cross-package short-name collision imports from the SAME qualified module the entity
+ // tier emits (e.g. `AcmeAlphaNote` from `./AcmeAlphaNote.js`, never bare `Note`).
+ const emittedName = ctx ? ctx.valueObjectEmittedName(cur) : cur.name;
+ const types: string[] = [emittedName];
for (const f of fields(cur)) {
- const alias = enumAlias(f, cur.name);
+ const alias = enumAlias(f, emittedName);
if (alias !== undefined && !seenAlias.has(alias)) {
seenAlias.add(alias);
types.push(alias);
}
}
- groups.push({ module: cur.name, types });
+ groups.push({ module: emittedName, types });
// Recurse into nested object refs (their interfaces live in their own modules).
for (const f of fields(cur)) {
if (isObjectField(f)) {
@@ -254,16 +268,16 @@ function reachablePayloadGroups(vo: MetaData, root: MetaData): PayloadImportGrou
}
/** Collect the mirror-interface names reachable from `vo` (root mirror + nested VO mirrors). */
-function reachableMirrorTypes(vo: MetaData, root: MetaData, rootMirror: string): string[] {
+function reachableMirrorTypes(vo: MetaData, root: MetaData, rootMirror: string, ctx?: RenderContext): string[] {
const out: string[] = [rootMirror];
- const seen = new Set([vo.name]);
+ const seen = new Set([vo.resolutionKey()]);
const visit = (cur: MetaData) => {
for (const f of fields(cur)) {
if (isObjectField(f)) {
const target = refVo(f, root);
- if (target !== undefined && !seen.has(target.name)) {
- seen.add(target.name);
- out.push(mirrorName(target));
+ if (target !== undefined && !seen.has(target.resolutionKey())) {
+ seen.add(target.resolutionKey());
+ out.push(mirrorName(target, ctx));
visit(target);
}
}
@@ -279,7 +293,7 @@ function reachableMirrorTypes(vo: MetaData, root: MetaData, rootMirror: string):
* or if the target format is not json/xml (the extract tier requires the extract API, which
* only the json/xml output-parsers emit).
*/
-export function renderExtractor(root: MetaData, templateName: string): string {
+export function renderExtractor(root: MetaData, templateName: string, ctx?: RenderContext): string {
const tmpl = findTemplate(root, templateName);
if (!tmpl) {
throw new Error(`template "${templateName}" not found in metadata root`);
@@ -305,16 +319,19 @@ export function renderExtractor(root: MetaData, templateName: string): string {
);
}
- const strictType = vo.name; // the payload VO's interface name (payload-codegen emits the bare VO name)
+ // ADR-0044/#228: the strict payload TYPE name is the entity-domain EMITTED name (Task 3's
+ // `valueObjectEmittedName`) — the SAME name entityFile() declared the interface under, so a
+ // cross-package short-name collision emits e.g. `AcmeAlphaNote`, matching `./AcmeAlphaNote.js`.
+ const strictType = ctx ? ctx.valueObjectEmittedName(vo) : vo.name;
const rootMirror = `${templateName}Extracted`;
const extractLenientWithName = `extractLenient${templateName}WithLoader`; // the nested-capable lenient extract (output-parser)
const extractLenientPublic = `extractLenient${templateName}`; // re-exposed never-throws lenient tier name
const extractName = `extract${templateName}`;
- const rootMapper = mapperName(vo);
+ const rootMapper = mapperName(vo, ctx);
- const payloadGroups = reachablePayloadGroups(vo, root);
- const mirrorTypes = reachableMirrorTypes(vo, root, rootMirror);
- const mappers = emitMappers(vo, root, rootMirror);
+ const payloadGroups = reachablePayloadGroups(vo, root, ctx);
+ const mirrorTypes = reachableMirrorTypes(vo, root, rootMirror, ctx);
+ const mappers = emitMappers(vo, root, rootMirror, ctx);
// One type-only import per VO entity module (the VO interface + its own enum
// union-aliases co-located there). NOT a single non-existent `./payloads.js`.
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..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.
@@ -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);
@@ -214,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;
}
@@ -241,6 +252,10 @@ export function fieldTsTypeString(ownerName: string, field: MetaField): string {
* 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 +267,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, fieldDeclaringPackage(field, entity.package)) : 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 +289,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, fieldDeclaringPackage(field, entity.package)) : 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 +305,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 +340,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 +355,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/output-parser.ts b/server/typescript/packages/codegen-ts/src/templates/output-parser.ts
index 5fae3d679..47730a09d 100644
--- a/server/typescript/packages/codegen-ts/src/templates/output-parser.ts
+++ b/server/typescript/packages/codegen-ts/src/templates/output-parser.ts
@@ -28,6 +28,7 @@ import {
usedHelpers,
hasNested,
} from "./extract-delegate-emitter.js";
+import type { RenderContext } from "../render-context.js";
const SCALAR_ZOD: Record = {
string: "z.string()",
@@ -93,7 +94,7 @@ function renderObjectSchema(vo: MetaData, root: MetaData, seen: ReadonlySetExtracted` name.
- const mirrorDecls = nestedMirrorInterfaces(vo, root, extractedName);
+ // The payload mirror keeps the canonical `Extracted` name. ADR-0044/#228: `ctx`
+ // qualifies a nested mirror's name/dedupe when its VO's bare short name collides across
+ // packages, matching Task 3's entity-domain emitted name (e.g. `AcmeAlphaNoteExtracted`).
+ const mirrorDecls = nestedMirrorInterfaces(vo, root, extractedName, ctx);
// Render-package imports the (single, loader-delegating) extract block needs. Kept minimal so
// the file has no unused imports (tsc noUnusedLocals-safe).
@@ -190,19 +193,41 @@ export function ${safeParseName}(
// graph is then mapped into the typed nullable mirror graph by the generated fromExtracted
// mappers. Codegen-wrapping-runtime (a generated DAO calling the dynamic-metadata runtime).
//
- // The baked PAYLOAD_NAME is the resolved payload VO's SIMPLE name (root.findObject matches on
- // the object's `name`, not its FQN). The root mapper is named for the TEMPLATE (so it returns
- // the canonically-named `Extracted` mirror); nested mappers use their VO names.
+ // The baked PAYLOAD_NAME is normally the resolved payload VO's SIMPLE name (root.findObject
+ // matches on the object's `name`, not its FQN). The root mapper is named for the TEMPLATE (so
+ // it returns the canonically-named `Extracted` mirror); nested mappers use their VO
+ // names (via the entity-domain name map, so they agree with the imported mirror types above).
+ //
+ // ADR-0044/#228 — `root.findObject()` (MetaRoot's public runtime API) is a BARE-name-only,
+ // first-match lookup with no package awareness. If THIS PAYLOAD's own bare name collides with
+ // a same-short-name value-object elsewhere in the run (the identical signal Option A already
+ // computes: `ctx.valueObjectEmittedName(vo)` diverges from the bare name), a bare lookup could
+ // silently resolve to the WRONG package's object at runtime (load-order-dependent — the exact
+ // hazard class ADR-0042 closed everywhere else). When it does collide, bake the FQN
+ // (`resolutionKey()`) instead and resolve it via the SAME canonical ADR-0042 `resolveObjectRef`
+ // this file's own build-time `findObject()` wraps (FQN-exact, load-order-independent). A
+ // non-colliding payload keeps the bare name + `root.findObject()` path — byte-identical to
+ // pre-#228 output.
const payloadName = vo.name;
+ const emittedPayloadName = ctx ? ctx.valueObjectEmittedName(vo) : payloadName;
+ const payloadNameCollides = emittedPayloadName !== payloadName;
+ const bakedPayloadName = payloadNameCollides ? vo.resolutionKey() : payloadName;
const rootMapper = rootMapperName(templateName);
void hasNested;
+ const lookupExpr = payloadNameCollides
+ ? `resolveObjectRef(root, ${payloadFqnConst}, "").node`
+ : `root.findObject(${payloadFqnConst})`;
const delegating = `
-/** Payload value-object name this parser extracts — resolved against a loaded MetaRoot at runtime. */
-export const ${payloadFqnConst} = ${JSON.stringify(payloadName)};
+/** Payload value-object name this parser extracts — resolved against a loaded MetaRoot at runtime.${
+ payloadNameCollides
+ ? " ADR-0042 FQN (this payload's bare name collides with a same-short-name value object elsewhere in the run)."
+ : ""
+ } */
+export const ${payloadFqnConst} = ${JSON.stringify(bakedPayloadName)};
${mirrorDecls}
-${nestedMappers(vo, root, rootMapper, extractedName)}
+${nestedMappers(vo, root, rootMapper, extractedName, ctx)}
${delegateHelpers(usedHelpers(vo, root))}
@@ -221,7 +246,7 @@ export function ${extractLenientWithName}(
text: string,
opts?: Partial | null,
): ExtractionResult<${extractedName}> {
- const mo = root.findObject(${payloadFqnConst});
+ const mo = ${lookupExpr};
if (mo === undefined) {
throw new Error(\`${extractLenientWithName}: payload "\${${payloadFqnConst}}" not found in the supplied MetaRoot\`);
}
@@ -230,8 +255,11 @@ export function ${extractLenientWithName}(
}
`;
- // The delegating overload needs runtime-ts (extractObject) + the MetaRoot type from metadata.
- const metadataImport = `import type { MetaRoot } from "@metaobjectsdev/metadata";\n`;
+ // The delegating overload needs runtime-ts (extractObject) + the MetaRoot type from metadata
+ // (+ resolveObjectRef, ADR-0044/#228, only when this payload's own bare name collides).
+ const metadataImport = payloadNameCollides
+ ? `import type { MetaRoot } from "@metaobjectsdev/metadata";\nimport { resolveObjectRef } from "@metaobjectsdev/metadata";\n`
+ : `import type { MetaRoot } from "@metaobjectsdev/metadata";\n`;
const runtimeImport = `import { extractObject } from "@metaobjectsdev/runtime-ts";\n`;
return (
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/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/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 782d9013b..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.
@@ -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}
`;
}
@@ -446,16 +455,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, fieldDeclaringPackage(field, owner.package)) : 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 +484,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, fieldDeclaringPackage(field, owner.package)) : 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..baed0cfdf
--- /dev/null
+++ b/server/typescript/packages/codegen-ts/test/entity-tier-collision.test.ts
@@ -0,0 +1,301 @@
+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 { barrel } from "../src/generators/barrel.js";
+import { MetaDataLoader, InMemoryStringSource } from "@metaobjectsdev/metadata";
+
+// ADR-0044 / #228 — the ENTITY tier brings the per-value-object entity module
+// (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]