From bf08e78971a16dcc3e05c791a10f1540604aa8d3 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Mon, 6 Jul 2026 20:58:16 -0400 Subject: [PATCH 1/3] fix(render,verify): FQN-exact nested @objectRef resolution across ports (ADR-0041) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The verify --templates prompt-drift gate and render-helper payload field-tree derivation resolved a nested field.object @objectRef by BARE short-name, so a fully-qualified ref (pkg::Name) bound the WRONG same-named object.value on a cross-package short-name collision — emptying the element subtree and raising a spurious ERR_VAR_NOT_ON_PAYLOAD on its inner {{fields}}. This ports the Java fix into the three remaining buggy ports and gates it cross-port: - Python: _resolve_nested_object_ref resolves FQN-exact when the ref contains "::". - C#: RenderHelperGenerator.ResolveNestedObjectRef is FQN-exact; the verify field-tree gets a dedicated ResolveObjectRef, kept SEPARATE from the bare FindObject used by record emission so record identifiers stay bare. - TS: the CLI verify (payload-field-tree) and docs-annotator (template-payload-tree) resolvers route through the shared refMatchesObject SSOT; the render-helper resolver was already FQN-safe. Shared gate: a new xpkg-collision/ sub-corpus under fixtures/template-output-render-conformance/ (two packages each declaring an object.value Note; a Digest payload referencing both by FQN @objectRef) drives the render-helper conformance runners in TS/Python/C#/Kotlin and fails on revert. Java is already covered by TemplateVerifyTest's ADR-0041 collision case. Auto-derived has-accessor recognition in verify stays a per-port concern by design — no other port emits has accessors and no shared template uses them. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013VXidfNN755CyTZTg1w8UV --- .../README.md | 42 ++++++++++++- .../templates/xpkg/digest.mustache | 1 + .../xpkg-collision/meta.alpha.json | 15 +++++ .../xpkg-collision/meta.app.json | 25 ++++++++ .../xpkg-collision/meta.beta.json | 15 +++++ .../PayloadCodegenTests.cs | 39 ++++++++++++ .../RenderHelperConformanceTests.cs | 59 +++++++++++++++++++ .../Generators/RenderHelperGenerator.cs | 33 ++++++----- .../MetaObjects.Codegen/PayloadCodegen.cs | 29 +++++++-- .../KotlinRenderHelperConformanceTest.kt | 37 ++++++++++++ .../generators/render_helper_generator.py | 32 ++++++---- .../codegen/test_render_helper_conformance.py | 40 ++++++++++++- .../cli/src/lib/payload-field-tree.ts | 6 +- .../cli/test/unit/payload-field-tree.test.ts | 29 +++++++++ .../src/generators/template-payload-tree.ts | 19 +++++- .../test/render-helper-conformance.test.ts | 54 +++++++++++++++-- .../test/template-payload-tree.test.ts | 37 ++++++++++++ 17 files changed, 470 insertions(+), 42 deletions(-) create mode 100644 fixtures/template-output-render-conformance/templates/xpkg/digest.mustache create mode 100644 fixtures/template-output-render-conformance/xpkg-collision/meta.alpha.json create mode 100644 fixtures/template-output-render-conformance/xpkg-collision/meta.app.json create mode 100644 fixtures/template-output-render-conformance/xpkg-collision/meta.beta.json create mode 100644 server/typescript/packages/codegen-ts/test/template-payload-tree.test.ts diff --git a/fixtures/template-output-render-conformance/README.md b/fixtures/template-output-render-conformance/README.md index 0afe975a9..e2ef1c8c6 100644 --- a/fixtures/template-output-render-conformance/README.md +++ b/fixtures/template-output-render-conformance/README.md @@ -26,6 +26,10 @@ resolves to `templates/emails/welcome.subject.mustache`, and so on. - `nested/meta.json` — a **no-package** sub-corpus carrying the nested/array email case (`Order` over `Customer` + `Item[]`); see "Nested + array email" below. It shares the same `templates/` dir. +- `xpkg-collision/` — a **multi-package** sub-corpus (`meta.alpha.json` + + `meta.beta.json` + `meta.app.json`) gating **FQN-exact** nested `@objectRef` + resolution across a cross-package short-name collision (ADR-0041); see + "Cross-package short-name collision" below. It also shares `templates/`. - `templates/emails/order.subject.mustache` = `Order for {{customer.name}}` - `templates/emails/order.html.mustache` = `

{{customer.name}}

    {{#items}}
  • {{sku}} x{{qty}}
  • {{/items}}
{{> shared/footer}}` @@ -74,8 +78,10 @@ the XML entity set (`<`→`<`, `>`→`>`, `&`→`&`, `"`→`"`, ` @objectRef, isArray) }`, plus an `email` template `OrderEmail` (`@payloadRef=Order`) over the `order.*` part-refs. It has **no package** on purpose: a bare `@objectRef` resolves identically across ports only when there is -no package (TS resolves an objectRef by short name; the JVM expands a *packaged* -ref to an FQN, so bare == FQN only at the root package). The shared +no package: a bare `@objectRef` matches the value-object's short name in every +port, so this sub-corpus isolates the nested/array/partial shape from any +package-resolution concern. (The *packaged*/fully-qualified `@objectRef` case is +gated separately by `xpkg-collision/` below, per ADR-0041.) The shared `templates/emails/order.*` + `templates/shared/footer` mustaches are reused. Rendered with `{ customer: { name: "Ada" }, items: [ { sku: "A1", qty: 2 }, @@ -95,6 +101,38 @@ section-context drift (a `{{bogus}}` not on the `Item` element type the section pushes) FAILS codegen with `ERR_VAR_NOT_ON_PAYLOAD`, proving the gate walks the nested/section context, not just the root. +## Cross-package short-name collision — `xpkg-collision/`, `DigestDoc` + +A **multi-package** sub-corpus (loaded as three sources — `meta.alpha.json` + +`meta.beta.json` + `meta.app.json`, mirroring +`fixtures/conformance/loader-same-name-distinct-packages`) that gates **FQN-exact** +nested `@objectRef` resolution (ADR-0041): + +- `acme::alpha` declares `object.value Note { alphaText: string }`. +- `acme::beta` declares `object.value Note { betaText: string }` — a **colliding + short name** in a different package. +- `acme::app` declares payload `object.value Digest` with two `field.object` + children referencing the two Notes by **fully-qualified** `@objectRef` + (`acme::alpha::Note`, `acme::beta::Note`), and a `document` `template.output` + `DigestDoc` (`@format=html`, `@textRef="xpkg/digest"`, `@payloadRef="Digest"`). +- `templates/xpkg/digest.mustache` = `Alpha={{fromAlpha.alphaText}} Beta={{fromBeta.betaText}}` + +Each port must resolve a fully-qualified `@objectRef` **exactly** on the +package-qualified name — never a bare-tail fallback that binds whichever `Note` +loads first. A bare-tail resolver collapses BOTH refs to one package's `Note`, so +one of `{{fromAlpha.alphaText}}`/`{{fromBeta.betaText}}` lands on the wrong element +type and the build-time drift gate throws `ERR_VAR_NOT_ON_PAYLOAD`. FQN-exact +resolution binds each ref to its own package, so the clean template passes. + +Rendered with `{ fromAlpha: { alphaText: "AA" }, fromBeta: { betaText: "BB" } }`: + +- `renderDigestDoc(...)` = `"Alpha=AA Beta=BB"` + +(The two colliding VOs share the BARE payload-record type name `Note`; each port's +runner hand-authors — or otherwise reconciles — the payload record for that reason. +The record-name collision is an orthogonal concern; this sub-corpus gates the +`@objectRef` **resolver**, not payload-record naming.) + ## Expected build-time drift FAILURE — `drift/` `drift/meta.json` declares the same `Welcome` VO and a `document` diff --git a/fixtures/template-output-render-conformance/templates/xpkg/digest.mustache b/fixtures/template-output-render-conformance/templates/xpkg/digest.mustache new file mode 100644 index 000000000..52997ca73 --- /dev/null +++ b/fixtures/template-output-render-conformance/templates/xpkg/digest.mustache @@ -0,0 +1 @@ +Alpha={{fromAlpha.alphaText}} Beta={{fromBeta.betaText}} \ No newline at end of file diff --git a/fixtures/template-output-render-conformance/xpkg-collision/meta.alpha.json b/fixtures/template-output-render-conformance/xpkg-collision/meta.alpha.json new file mode 100644 index 000000000..dd60ca834 --- /dev/null +++ b/fixtures/template-output-render-conformance/xpkg-collision/meta.alpha.json @@ -0,0 +1,15 @@ +{ + "metadata.root": { + "package": "acme::alpha", + "children": [ + { + "object.value": { + "name": "Note", + "children": [ + { "field.string": { "name": "alphaText", "@required": true } } + ] + } + } + ] + } +} diff --git a/fixtures/template-output-render-conformance/xpkg-collision/meta.app.json b/fixtures/template-output-render-conformance/xpkg-collision/meta.app.json new file mode 100644 index 000000000..d544ff071 --- /dev/null +++ b/fixtures/template-output-render-conformance/xpkg-collision/meta.app.json @@ -0,0 +1,25 @@ +{ + "metadata.root": { + "package": "acme::app", + "children": [ + { + "object.value": { + "name": "Digest", + "children": [ + { "field.object": { "name": "fromAlpha", "@objectRef": "acme::alpha::Note" } }, + { "field.object": { "name": "fromBeta", "@objectRef": "acme::beta::Note" } } + ] + } + }, + { + "template.output": { + "name": "DigestDoc", + "@kind": "document", + "@payloadRef": "Digest", + "@textRef": "xpkg/digest", + "@format": "html" + } + } + ] + } +} diff --git a/fixtures/template-output-render-conformance/xpkg-collision/meta.beta.json b/fixtures/template-output-render-conformance/xpkg-collision/meta.beta.json new file mode 100644 index 000000000..74ddf76fa --- /dev/null +++ b/fixtures/template-output-render-conformance/xpkg-collision/meta.beta.json @@ -0,0 +1,15 @@ +{ + "metadata.root": { + "package": "acme::beta", + "children": [ + { + "object.value": { + "name": "Note", + "children": [ + { "field.string": { "name": "betaText", "@required": true } } + ] + } + } + ] + } +} diff --git a/server/csharp/MetaObjects.Codegen.Tests/PayloadCodegenTests.cs b/server/csharp/MetaObjects.Codegen.Tests/PayloadCodegenTests.cs index 90bf14d24..675cda8b1 100644 --- a/server/csharp/MetaObjects.Codegen.Tests/PayloadCodegenTests.cs +++ b/server/csharp/MetaObjects.Codegen.Tests/PayloadCodegenTests.cs @@ -87,6 +87,45 @@ public void Fully_qualified_objectRef_strips_to_bare_record_type_and_resolves_ne Assert.DoesNotContain("acme::ai::", src); } + // ADR-0041: cross-package short-name collision. Two object.value `Note`s + // (acme::alpha with alphaText, acme::beta with betaText) and a payload `Digest` + // referencing BOTH by FULLY-QUALIFIED @objectRef. The BuildPayloadFieldTree walk (the + // `dotnet meta verify` field-tree) must bind each ref to its OWN package — not + // bare-tail-collapse both to whichever Note loads first (pre-fix: both got alphaText). + // This is the CLI-verify half of the fix; the render-helper half is gated by + // RenderHelperConformanceTests.Document_DigestDoc_resolves_fqn_nested_objectRef_across_collision. + [Fact] + public void BuildPayloadFieldTree_resolves_fqn_nested_objectRef_across_package_collision() + { + const string alpha = """ + { "metadata.root": { "package": "acme::alpha", "children": [ + { "object.value": { "name": "Note", "children": [ { "field.string": { "name": "alphaText" } } ] } } ] } } + """; + const string beta = """ + { "metadata.root": { "package": "acme::beta", "children": [ + { "object.value": { "name": "Note", "children": [ { "field.string": { "name": "betaText" } } ] } } ] } } + """; + const string app = """ + { "metadata.root": { "package": "acme::app", "children": [ + { "object.value": { "name": "Digest", "children": [ + { "field.object": { "name": "fromAlpha", "@objectRef": "acme::alpha::Note" } }, + { "field.object": { "name": "fromBeta", "@objectRef": "acme::beta::Note" } } ] } } ] } } + """; + var root = new MetaDataLoader().Load([ + new InMemoryStringSource(alpha, id: "a.json"), + new InMemoryStringSource(beta, id: "b.json"), + new InMemoryStringSource(app, id: "c.json"), + ]).Root; + + var tree = PayloadCodegen.BuildPayloadFieldTree(root, "Digest"); + + var fromAlpha = Assert.Single(tree, f => f.Name == "fromAlpha"); + var fromBeta = Assert.Single(tree, f => f.Name == "fromBeta"); + // FQN-exact: each ref binds to its OWN package's Note. + Assert.Equal("alphaText", Assert.Single(fromAlpha.Fields!).Name); + Assert.Equal("betaText", Assert.Single(fromBeta.Fields!).Name); + } + [Fact] public void Emits_render_handle_binding_textRef_and_format() { diff --git a/server/csharp/MetaObjects.Codegen.Tests/RenderHelperConformanceTests.cs b/server/csharp/MetaObjects.Codegen.Tests/RenderHelperConformanceTests.cs index 5fb95d943..7daabefa7 100644 --- a/server/csharp/MetaObjects.Codegen.Tests/RenderHelperConformanceTests.cs +++ b/server/csharp/MetaObjects.Codegen.Tests/RenderHelperConformanceTests.cs @@ -50,6 +50,18 @@ private static MetaRoot LoadFromFile(string metaJsonPath) return r.Root; } + // Multi-file (multi-package) load — one InMemoryStringSource per file, merged into a + // single root (mirrors fixtures/conformance/loader-same-name-distinct-packages). + private static MetaRoot LoadFromFiles(params string[] metaJsonPaths) + { + var sources = metaJsonPaths + .Select((p, i) => (IMetaDataSource)new InMemoryStringSource(File.ReadAllText(p), id: $"corpus{i}.json")) + .ToArray(); + var r = new MetaDataLoader().Load(sources); + Assert.Empty(r.Errors); + return r.Root; + } + private static GenContext Ctx(MetaRoot root) => new() { Entities = root.Objects(), @@ -192,6 +204,53 @@ public void Email_OrderEmail_renders_nested_array_loop_and_partial() Assert.Contains("
Sent by Acme", email.HtmlBody); } + // --------------------------------------------------------------------- + // xpkg-collision/ — cross-package short-name collision (ADR-0041). Two packages + // each declare an object.value `Note` (alpha: alphaText, beta: betaText); the + // payload `Digest` references BOTH by FULLY-QUALIFIED @objectRef. A bare-tail + // resolver binds both refs to whichever Note loads first → one of + // {{fromAlpha.alphaText}}/{{fromBeta.betaText}} lands on the wrong element type and + // the build-time drift gate throws ERR_VAR_NOT_ON_PAYLOAD. FQN-exact resolution binds + // each ref to its own package. The two colliding VOs share the BARE record type name + // `Note`, so (like the TS payloads.ts) the payload record is hand-authored with both + // fields — the record-name collision is an orthogonal concern; this test gates the + // render-helper's FQN-exact @objectRef resolver. + // --------------------------------------------------------------------- + + [Fact] + public void Document_DigestDoc_resolves_fqn_nested_objectRef_across_collision() + { + var corpus = Corpus(); + var dir = Path.Combine(corpus, "xpkg-collision"); + var root = LoadFromFiles( + Path.Combine(dir, "meta.alpha.json"), + Path.Combine(dir, "meta.beta.json"), + Path.Combine(dir, "meta.app.json")); + var templates = Path.Combine(corpus, "templates"); + + // Must NOT throw: the FQN refs resolve to their own package's Note. + var file = Assert.Single(new RenderHelperGenerator(templates).Generate(Ctx(root)), f => f.Path == "DigestDoc.render.cs"); + + var payloadSrc = "namespace Acme.Generated;\n" + + "public sealed record Note { public string? alphaText { get; init; } public string? betaText { get; init; } }\n" + + "public sealed record Digest { public Note fromAlpha { get; init; } public Note fromBeta { get; init; } }\n"; + var asm = CompileToAssembly(file.Content, payloadSrc); + + var noteType = asm.GetType("Acme.Generated.Note")!; + var digestType = asm.GetType("Acme.Generated.Digest")!; + var fromAlpha = Activator.CreateInstance(noteType)!; + noteType.GetProperty("alphaText")!.SetValue(fromAlpha, "AA"); + var fromBeta = Activator.CreateInstance(noteType)!; + noteType.GetProperty("betaText")!.SetValue(fromBeta, "BB"); + var digest = Activator.CreateInstance(digestType)!; + digestType.GetProperty("fromAlpha")!.SetValue(digest, fromAlpha); + digestType.GetProperty("fromBeta")!.SetValue(digest, fromBeta); + + var helper = asm.GetType("Acme.Generated.DigestDocRenderHelper")!; + var outText = (string)helper.GetMethod("Render")!.Invoke(null, [digest, new FilesystemProvider(templates)])!; + Assert.Equal("Alpha=AA Beta=BB", outText); + } + // --------------------------------------------------------------------- // drift/ → the generator THROWS ERR_VAR_NOT_ON_PAYLOAD (fails codegen). // --------------------------------------------------------------------- diff --git a/server/csharp/MetaObjects.Codegen/Generators/RenderHelperGenerator.cs b/server/csharp/MetaObjects.Codegen/Generators/RenderHelperGenerator.cs index 51b3b49f2..d7f045997 100644 --- a/server/csharp/MetaObjects.Codegen/Generators/RenderHelperGenerator.cs +++ b/server/csharp/MetaObjects.Codegen/Generators/RenderHelperGenerator.cs @@ -22,10 +22,10 @@ // Reuse, not reimplementation: Renderer.Render (the emitted runtime call), Verify // (the build-time gate), FilesystemProvider (build-time ref resolution), and // EmailDocument (email return type). The payload field tree is walked from the VO -// the same way the other generators walk it — but a nested field.object's -// @objectRef is resolved by BARE short-name (cross-port render-helper consensus: -// TS findObject / Java resolveNestedObjectRef), only recursing into object.value -// (SUBTYPE_VALUE) targets, cycle-guarded. +// the same way the other generators walk it — a nested field.object's @objectRef is +// resolved FQN-exact when fully-qualified (ADR-0041) else by short name (cross-port +// render-helper consensus: TS refMatchesObject / Java+Kotlin resolveNestedObjectRef), +// only recursing into object.value (SUBTYPE_VALUE) targets, cycle-guarded. // // The emitted literal is baked into the RenderRequest.Verify argument // so Renderer's runtime drift check matches the build-time gate that ran here. @@ -231,10 +231,11 @@ private void GateRef(string templateName, string reference, IReadOnlyList DerivePayloadFieldTree( @@ -263,20 +264,24 @@ private static IReadOnlyList DerivePayloadFieldTree( /// /// Resolve a field.object's @objectRef to its target - /// object.value by BARE short-name — mirroring the TS render-helper's - /// findObject(root, ref) (c.type === OBJECT && c.name === ref) - /// and the Java resolveNestedObjectRef. If the ref carries a package, only - /// the segment after the last :: is compared, against each - /// object.value's own short name. Returns null when no match is found. + /// object.value. ADR-0041: a FULLY-QUALIFIED ref (contains ::) resolves + /// EXACTLY on the package-qualified name (ResolutionKey()/Fqn()) — never + /// a bare-tail fallback that would bind a same-named object.value in the WRONG + /// package on a cross-package short-name collision. A bare ref still matches by short + /// name (first-wins). Mirrors the Java/Kotlin resolveNestedObjectRef + TS + /// refMatchesObject. Returns null when no match is found. /// private static MetaData? ResolveNestedObjectRef(MetaRoot root, string reference) { if (string.IsNullOrEmpty(reference)) return null; + bool fqn = reference.Contains("::"); var refShort = CSharpNaming.StripPkg(reference); // ADR-0039: Children() — resolving root scan (behavior-identical; root has no super). return root.Children().FirstOrDefault(c => c.Type == TYPE_OBJECT && c.SubType == OBJECT_SUBTYPE_VALUE && - CSharpNaming.StripPkg(c.Name) == refShort); + (fqn + ? c.ResolutionKey() == reference || c.Fqn() == reference + : CSharpNaming.StripPkg(c.Name) == refShort)); } /// diff --git a/server/csharp/MetaObjects.Codegen/PayloadCodegen.cs b/server/csharp/MetaObjects.Codegen/PayloadCodegen.cs index a8fc84f60..b8d0f56ec 100644 --- a/server/csharp/MetaObjects.Codegen/PayloadCodegen.cs +++ b/server/csharp/MetaObjects.Codegen/PayloadCodegen.cs @@ -43,8 +43,23 @@ public static class PayloadCodegen private static MetaData? FindObject(MetaData root, string name) => // ADR-0039: Children() — resolving root scan (behavior-identical; root has no super). + // Record emission resolves the payload VO by BARE short name (a C# record identifier + // is always bare, and EmitRecord derives the record name from this same voName). root.Children().FirstOrDefault(c => c.Type == TYPE_OBJECT && c.Name == name); + // ADR-0041: the verify field-tree resolver — a FULLY-QUALIFIED ref (contains ::) resolves + // EXACTLY on the package-qualified name (ResolutionKey()/Fqn()), never a bare-tail fallback + // that would bind a same-named object.value in the WRONG package on a cross-package + // short-name collision. A bare ref matches by short name (first-wins). Kept SEPARATE from + // FindObject so record emission (bare identifiers) is unaffected. Mirrors the render-helper + // ResolveNestedObjectRef + Java/Kotlin + TS refMatchesObject. + private static MetaData? ResolveObjectRef(MetaData root, string reference) + { + bool fqn = reference.Contains("::"); + return root.Children().FirstOrDefault(c => c.Type == TYPE_OBJECT && + (fqn ? c.ResolutionKey() == reference || c.Fqn() == reference : c.Name == reference)); + } + // ADR-0039: resolve array-ness through the super chain (isArray is a native // property, not an attr; the former OwnAttr("isArray") clause was dead code). private static bool IsArrayField(MetaData field) => field.ResolvedIsArray(); @@ -55,9 +70,10 @@ private static (string Type, string? RefVo) FieldType(MetaData owner, MetaData f { // ADR-0039: resolving — @objectRef may be inherited via extends. var refAttr = field.Attr(FIELD_ATTR_OBJECT_REF); - // @objectRef may be authored fully-qualified (acme::sales::Brief) or bare; - // the generated record type + the nested-record lookup key are the BARE - // short name (FindObject matches bare names — every other caller strips too). + // @objectRef may be authored fully-qualified (acme::sales::Brief) or bare; the + // generated record TYPE name is the BARE short name (StripPkg). (The verify + // field-tree path — BuildTree — resolves the FULL ref FQN-exact via ResolveObjectRef + // per ADR-0041; record emission here stays bare: one C# type per short name.) string refName = refAttr is string s ? CSharpNaming.StripPkg(s) : "object"; string? refVo = refAttr is string r ? CSharpNaming.StripPkg(r) : null; return (IsArrayField(field) ? $"IReadOnlyList<{refName}>" : refName, refVo); @@ -155,14 +171,17 @@ public static IReadOnlyList BuildPayloadFieldTree(MetaData root, s private static IReadOnlyList BuildTree(MetaData root, string voName, HashSet visiting) { - var vo = FindObject(root, voName); + // ADR-0041: FQN-exact resolution for the verify field-tree (NOT the bare FindObject + // used by record emission) so a fully-qualified nested @objectRef binds its own package. + var vo = ResolveObjectRef(root, voName); if (vo is null || !visiting.Add(voName)) return []; var fields = new List(); foreach (var f in vo.Children().Where(c => c.Type == TYPE_FIELD)) { // ADR-0039: resolving — @objectRef may be inherited via extends (TS reads f.attr). + // ADR-0041: pass the FULL (possibly FQN) ref — ResolveObjectRef resolves it exactly. if (f.SubType == FIELD_SUBTYPE_OBJECT && f.Attr(FIELD_ATTR_OBJECT_REF) is string refName) - fields.Add(new PayloadField(f.Name, BuildTree(root, CSharpNaming.StripPkg(refName), visiting))); + fields.Add(new PayloadField(f.Name, BuildTree(root, refName, visiting))); else fields.Add(new PayloadField(f.Name)); } diff --git a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinRenderHelperConformanceTest.kt b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinRenderHelperConformanceTest.kt index e6f86ee3b..71cca504b 100644 --- a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinRenderHelperConformanceTest.kt +++ b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinRenderHelperConformanceTest.kt @@ -201,6 +201,43 @@ class KotlinRenderHelperConformanceTest { } } + // ------------------------------------------------------------------------- + // xpkg-collision/ → FQN-exact nested @objectRef resolution across a cross-package + // short-name collision (ADR-0041). Two packages each declare an object.value `Note` + // (alpha: alphaText, beta: betaText); the payload `Digest` references BOTH by + // FULLY-QUALIFIED @objectRef. A bare-tail resolver binds both refs to whichever Note + // loads first → one of {{fromAlpha.alphaText}}/{{fromBeta.betaText}} lands on the wrong + // element type and the drift gate throws ERR_VAR_NOT_ON_PAYLOAD. Kotlin's render helper + // already resolves FQN-exact, so generation must NOT throw and must emit the helper. + // Loaded as three sources (multi-package), mirroring loader-same-name-distinct-packages. + // ------------------------------------------------------------------------- + @Test fun `xpkg collision resolves FQN nested objectRef`() { + val outDir = Files.createTempDirectory("krhc-xpkg-") + val dir = corpus.resolve("xpkg-collision") + val templates = corpus.resolve("templates") + try { + val loader = com.metaobjects.loader.MetaDataLoader.createManual(false, "rh-conf-xpkg") + loader.init() + loader.load(listOf( + com.metaobjects.loader.InMemoryStringSource(Files.readString(dir.resolve("meta.alpha.json")), "alpha"), + com.metaobjects.loader.InMemoryStringSource(Files.readString(dir.resolve("meta.beta.json")), "beta"), + com.metaobjects.loader.InMemoryStringSource(Files.readString(dir.resolve("meta.app.json")), "app"), + )) + loader.register() + // Must NOT throw: the FQN refs resolve to their own package's Note. + KotlinRenderHelperGenerator().apply { + setArgs(mapOf("outputDir" to outDir.toString(), "templateRoot" to templates.toString())) + }.execute(loader) + val produced = Files.walk(outDir).filter { it.isRegularFile() }.toList() + assertTrue( + produced.any { it.fileName.toString() == "DigestDocRenderHelper.kt" }, + "DigestDocRenderHelper.kt must be generated; files=$produced", + ) + } finally { + outDir.toFile().deleteRecursively() + } + } + // ------------------------------------------------------------------------- // drift/ → the GENERATOR throws GeneratorException (ERR_VAR_NOT_ON_PAYLOAD). // ------------------------------------------------------------------------- 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 1811a7b61..55f44deba 100644 --- a/server/python/src/metaobjects/codegen/generators/render_helper_generator.py +++ b/server/python/src/metaobjects/codegen/generators/render_helper_generator.py @@ -26,10 +26,10 @@ Reuse, not reimplementation: ``render`` (the emitted runtime call), ``verify`` (the build-time gate), ``FilesystemProvider`` (build-time ref resolution), and ``EmailDocument`` (email return type). The payload field tree is walked from the VO -the same way the other generators walk it — but a nested ``field.object``'s -``@objectRef`` is resolved by BARE short-name (cross-port render-helper consensus: -TS ``findObject`` / Java ``resolveNestedObjectRef`` / C# ``ResolveNestedObjectRef``), -only recursing into ``object.value`` targets, cycle-guarded. +the same way the other generators walk it — a nested ``field.object``'s ``@objectRef`` +is resolved FQN-exact when fully-qualified (ADR-0041) else by short name (cross-port +render-helper consensus: TS ``refMatchesObject`` / Java+Kotlin ``resolveNestedObjectRef`` +/ C# ``ResolveNestedObjectRef``), only recursing into ``object.value`` targets, cycle-guarded. Python divergence vs TS / Java / C#: Python's ``RenderRequest`` has NO ``verify`` field — the Python render engine does not run a runtime drift pass — so the emitted @@ -75,19 +75,24 @@ def _py_str(s: str) -> str: # --------------------------------------------------------------------------- -# Payload field-tree walk — nested @objectRef resolved by BARE short-name -# (cross-port render-helper consensus). Object-ref fields recurse into their -# target object.value (SUBTYPE_VALUE only); a `seen` set guards reference cycles. +# Payload field-tree walk — nested @objectRef resolved FQN-exact (ADR-0041). +# Object-ref fields recurse into their target object.value (SUBTYPE_VALUE only); +# a `seen` set guards reference cycles. # --------------------------------------------------------------------------- def _resolve_nested_object_ref(root: MetaData, reference: str) -> MetaObject | None: - """Resolve a ``field.object``'s ``@objectRef`` to its target ``object.value`` by BARE - short-name — mirroring the C# ``ResolveNestedObjectRef`` / TS ``findObject`` / Java - ``resolveNestedObjectRef``. If the ref carries a package, only the segment after the - last ``::`` is compared, against each ``object.value``'s own short name.""" + """Resolve a ``field.object``'s ``@objectRef`` to its target ``object.value``. + + ADR-0041: a FULLY-QUALIFIED ref (contains ``::``) resolves EXACTLY on the + package-qualified name (``resolution_key()``/``fqn()``) — never a bare-tail + fallback that would bind a same-named ``object.value`` in the WRONG package on a + cross-package short-name collision. A bare ref still matches by short name + (first-wins). Mirrors the Java/Kotlin ``resolveNestedObjectRef`` + TS + ``refMatchesObject``.""" if not reference: return None + fqn = PACKAGE_SEP in reference ref_short = reference.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(): @@ -95,7 +100,10 @@ def _resolve_nested_object_ref(root: MetaData, reference: str) -> MetaObject | N continue if child.sub_type != OBJECT_SUBTYPE_VALUE: continue - if child.name.rsplit(PACKAGE_SEP, 1)[-1] == ref_short: + if fqn: + if child.resolution_key() == reference or child.fqn() == reference: + return child + elif child.name.rsplit(PACKAGE_SEP, 1)[-1] == ref_short: return child return None diff --git a/server/python/tests/codegen/test_render_helper_conformance.py b/server/python/tests/codegen/test_render_helper_conformance.py index a35cca664..0ea6cbd26 100644 --- a/server/python/tests/codegen/test_render_helper_conformance.py +++ b/server/python/tests/codegen/test_render_helper_conformance.py @@ -27,7 +27,7 @@ import pytest import metaobjects.core_types # noqa: F401 — side-effect: registers attr classes -from metaobjects import MetaDataLoader +from metaobjects import InMemoryStringSource, MetaDataLoader from metaobjects.codegen.config import GenConfig from metaobjects.codegen.generator import GenContext from metaobjects.codegen.generators.render_helper_generator import RenderHelperGenerator @@ -45,6 +45,15 @@ def _load_root(meta_json: Path): return res.root +def _load_root_from_files(*meta_jsons: Path): + # Multi-file (multi-package) load — one InMemoryStringSource per file, merged into a + # single root (mirrors fixtures/conformance/loader-same-name-distinct-packages). + sources = [InMemoryStringSource(p.read_text()) for p in meta_jsons] + res = MetaDataLoader().load(sources) + assert res.errors == [], res.errors + return res.root + + def _ctx(root) -> GenContext: return GenContext( entities=[], @@ -156,6 +165,35 @@ def test_email_order_email_renders_nested_array_loop_and_partial(tmp_path) -> No assert "
Sent by Acme" in doc.html_body +# --------------------------------------------------------------------------- +# xpkg-collision/ — cross-package short-name collision (ADR-0041). Two packages +# each declare an object.value `Note` (alpha: alphaText, beta: betaText); the +# payload `Digest` references BOTH by FULLY-QUALIFIED @objectRef. A bare-tail +# resolver binds both refs to whichever Note loads first → one field lands on the +# wrong element type → the drift gate raises. FQN-exact resolution renders both. +# --------------------------------------------------------------------------- + + +def test_document_digest_doc_resolves_fqn_nested_object_ref_across_collision(tmp_path) -> None: + dir_root = CORPUS / "xpkg-collision" + root = _load_root_from_files( + dir_root / "meta.alpha.json", + dir_root / "meta.beta.json", + dir_root / "meta.app.json", + ) + templates = str(CORPUS / "templates") + # Must NOT raise: the FQN refs resolve to their own package's Note. + files = [f for f in RenderHelperGenerator(templates).generate(_ctx(root)) + if f.path == "digest_doc_render_helper.py"] + assert len(files) == 1 + _materialize_and_import(files, tmp_path) + helper = import_module("_rh_conf_pkg.digest_doc_render_helper") + + payload = {"fromAlpha": {"alphaText": "AA"}, "fromBeta": {"betaText": "BB"}} + out = helper.render_digest_doc(payload, FilesystemProvider(templates)) + assert out == "Alpha=AA Beta=BB" + + # --------------------------------------------------------------------------- # drift/ → codegen FAILS (ValueError) with ERR_VAR_NOT_ON_PAYLOAD. # --------------------------------------------------------------------------- diff --git a/server/typescript/packages/cli/src/lib/payload-field-tree.ts b/server/typescript/packages/cli/src/lib/payload-field-tree.ts index 79f3eb76b..babe62f88 100644 --- a/server/typescript/packages/cli/src/lib/payload-field-tree.ts +++ b/server/typescript/packages/cli/src/lib/payload-field-tree.ts @@ -11,12 +11,16 @@ import { TYPE_FIELD, FIELD_SUBTYPE_OBJECT, FIELD_ATTR_OBJECT_REF, + refMatchesObject, } from "@metaobjectsdev/metadata"; import type { PayloadField } from "@metaobjectsdev/render"; function findObject(root: MetaData, name: string): MetaData | undefined { // ADR-0039: effective children — resolve rather than rely on root being unextended. - return root.children().find((c) => c.type === TYPE_OBJECT && c.name === name); + // ADR-0041: FQN-exact — a "::"-qualified @objectRef binds the exact package (never a + // bare-tail fallback), so a same-named object.value in another package can't be wrongly + // bound. The SAME resolver render-helper.ts's findObject uses (the two must not drift). + return root.children().find((c) => c.type === TYPE_OBJECT && refMatchesObject(c, name)); } /** diff --git a/server/typescript/packages/cli/test/unit/payload-field-tree.test.ts b/server/typescript/packages/cli/test/unit/payload-field-tree.test.ts index c398d92d4..ff2cdbd58 100644 --- a/server/typescript/packages/cli/test/unit/payload-field-tree.test.ts +++ b/server/typescript/packages/cli/test/unit/payload-field-tree.test.ts @@ -10,6 +10,14 @@ async function loadRoot(children: unknown[]) { return res.root; } +// Multi-file (multi-package) load — one InMemoryStringSource per doc, merged into a +// single root (mirrors fixtures/conformance/loader-same-name-distinct-packages). +async function loadRootFromDocs(...docs: unknown[]) { + const res = await new MetaDataLoader().load(docs.map((d) => new InMemoryStringSource(JSON.stringify(d)))); + expect(res.errors).toEqual([]); + return res.root; +} + const model = [ { "object.value": { name: "PostBrief", children: [{ "field.string": { name: "title" } }] } }, { @@ -46,4 +54,25 @@ describe("derivePayloadFieldTree — mirrors the payload-codegen VO walk", () => const root = await loadRoot(model); expect(derivePayloadFieldTree(root, "Nope")).toEqual([]); }); + + // ADR-0041: two packages each declare an object.value `Note` (alpha: alphaText, beta: + // betaText), and `Digest` references BOTH by FULLY-QUALIFIED @objectRef. Pre-fix the + // resolver matched the ref by raw bare name, so an FQN ref matched NOTHING and the nested + // subtree was empty; now it binds the exact package. + test("FQN nested @objectRef binds the exact package across a cross-package short-name collision", async () => { + const root = await loadRootFromDocs( + { "metadata.root": { package: "acme::alpha", children: [ + { "object.value": { name: "Note", children: [{ "field.string": { name: "alphaText" } }] } } ] } }, + { "metadata.root": { package: "acme::beta", children: [ + { "object.value": { name: "Note", children: [{ "field.string": { name: "betaText" } }] } } ] } }, + { "metadata.root": { package: "acme::app", children: [ + { "object.value": { name: "Digest", children: [ + { "field.object": { name: "fromAlpha", "@objectRef": "acme::alpha::Note" } }, + { "field.object": { name: "fromBeta", "@objectRef": "acme::beta::Note" } } ] } } ] } }, + ); + expect(derivePayloadFieldTree(root, "Digest")).toEqual([ + { name: "fromAlpha", fields: [{ name: "alphaText" }] }, + { name: "fromBeta", fields: [{ name: "betaText" }] }, + ]); + }); }); diff --git a/server/typescript/packages/codegen-ts/src/generators/template-payload-tree.ts b/server/typescript/packages/codegen-ts/src/generators/template-payload-tree.ts index 7523de057..0224c0db2 100644 --- a/server/typescript/packages/codegen-ts/src/generators/template-payload-tree.ts +++ b/server/typescript/packages/codegen-ts/src/generators/template-payload-tree.ts @@ -21,13 +21,25 @@ import { type MetaObject, type MetaRoot, + TYPE_OBJECT, FIELD_SUBTYPE_OBJECT, FIELD_ATTR_OBJECT_REF, stripPackage, + refMatchesObject, } from "@metaobjectsdev/metadata"; import { isFieldRequired, neutralTypeStr } from "./docs-data-builder.js"; import type { AnnotatePayloadField } from "./template-source-annotate.js"; +// ADR-0041: FQN-exact object resolution — a "::"-qualified @objectRef binds the exact +// package (never a bare-tail fallback), so a same-named object.value in another package +// can't be wrongly bound (which would attach the wrong owner/type/link to a doc node). +// Mirrors render-helper.ts's findObject + the CLI payload-field-tree resolver. +function findObject(root: MetaRoot, ref: string): MetaObject | undefined { + return root.children().find((c) => c.type === TYPE_OBJECT && refMatchesObject(c, ref)) as + | MetaObject + | undefined; +} + /** * Walk the payload VO `voName` into an enriched `AnnotatePayloadField[]`. Each * node carries `owner` (the short name of the VO that DECLARES the field — the @@ -45,7 +57,7 @@ export function buildEnrichedPayloadTree( seen: ReadonlySet = new Set(), ): AnnotatePayloadField[] { if (seen.has(voName)) return []; - const vo: MetaObject | undefined = root.findObject(voName); + const vo: MetaObject | undefined = findObject(root, voName); if (vo === undefined) return []; const owner = stripPackage(vo.name); const nextSeen = new Set(seen).add(voName); @@ -61,8 +73,9 @@ export function buildEnrichedPayloadTree( const ref = f.attr(FIELD_ATTR_OBJECT_REF); if (typeof ref === "string" && ref.length > 0) { // Owner switches to the nested VO for its fields (the recursion below - // re-derives `owner` from the resolved VO's own name). - node.fields = buildEnrichedPayloadTree(root, stripPackage(ref), nextSeen); + // re-derives `owner` from the resolved VO's own name). Pass the FULL ref + // (not stripPackage) so findObject resolves it FQN-exact (ADR-0041). + node.fields = buildEnrichedPayloadTree(root, ref, nextSeen); } } out.push(node); diff --git a/server/typescript/packages/codegen-ts/test/render-helper-conformance.test.ts b/server/typescript/packages/codegen-ts/test/render-helper-conformance.test.ts index c35b410bf..739561b5d 100644 --- a/server/typescript/packages/codegen-ts/test/render-helper-conformance.test.ts +++ b/server/typescript/packages/codegen-ts/test/render-helper-conformance.test.ts @@ -34,6 +34,15 @@ async function loadRootFromFile(metaJsonPath: string) { return res.root; } +// Multi-file (multi-package) load — one InMemoryStringSource per file, merged into +// a single root (mirrors fixtures/conformance/loader-same-name-distinct-packages). +async function loadRootFromFiles(...metaJsonPaths: string[]) { + const sources = metaJsonPaths.map((p) => new InMemoryStringSource(readFileSync(p, "utf-8"))); + const res = await new MetaDataLoader().load(sources); + expect(res.errors).toEqual([]); + return res.root; +} + describe("render-helper conformance — shared cross-port corpus", () => { test("document WelcomePage renders \"Hello Ada\"", async () => { const root = await loadRootFromFile(join(CORPUS, "meta.json")); @@ -97,10 +106,10 @@ describe("render-helper conformance — shared cross-port corpus", () => { // builder + build-time drift gate handle the nested/array shape (clean → no // throw) and that section loops + partials render for email. test("email OrderEmail renders nested customer + array items loop + partial footer", async () => { - // The nested/array VOs live in nested/meta.json (a NO-PACKAGE sub-corpus) so - // a BARE @objectRef resolves identically in both ports' render-helper field-tree - // walks (TS resolves objectRef by short name; the JVM expands a packaged ref to - // an FQN — bare == FQN only when there is no package). Both share templates/. + // The nested/array VOs live in nested/meta.json (a NO-PACKAGE sub-corpus) so a + // BARE @objectRef resolves by short name identically across ports — isolating the + // nested/array/partial shape from package resolution. (The fully-qualified @objectRef + // case is gated separately by xpkg-collision/ below, per ADR-0041.) Both share templates/. const root = await loadRootFromFile(join(CORPUS, "nested", "meta.json")); const provider = new FileSystemProvider(join(CORPUS, "templates")); // The clean nested template must pass the build-time drift gate (no throw). @@ -131,6 +140,43 @@ describe("render-helper conformance — shared cross-port corpus", () => { expect(email.htmlBody).toContain("
Sent by Acme"); }); + // Cross-package short-name collision (ADR-0041): xpkg-collision/ declares an + // object.value `Note` in BOTH acme::alpha (field alphaText) and acme::beta (field + // betaText), and a payload `Digest` whose two field.object children reference them + // by FULLY-QUALIFIED @objectRef (acme::alpha::Note / acme::beta::Note). A bare-tail + // resolver strips both refs to `Note` and binds BOTH to whichever package's Note + // loads first, so exactly one of {{fromAlpha.alphaText}}/{{fromBeta.betaText}} lands + // on the wrong element type and the build-time drift gate throws ERR_VAR_NOT_ON_PAYLOAD. + // FQN-exact resolution binds each ref to its own package, so the clean template passes + // and renders both fields. Render-tier analogue of loader-same-name-distinct-packages. + test("document DigestDoc resolves FQN nested @objectRef across a cross-package short-name collision", async () => { + const dirRoot = join(CORPUS, "xpkg-collision"); + const root = await loadRootFromFiles( + join(dirRoot, "meta.alpha.json"), + join(dirRoot, "meta.beta.json"), + join(dirRoot, "meta.app.json"), + ); + const provider = new FileSystemProvider(join(CORPUS, "templates")); + // Must NOT throw: the FQN refs resolve to their own package's Note. + const src = renderRenderHelper(root, "DigestDoc", provider); + + const dir = mkdtempSync(join(import.meta.dir, "rh-conf-xpkg-")); + TEMP_DIRS.push(dir); + writeFileSync( + join(dir, "payloads.ts"), + "export interface Note { alphaText?: string; betaText?: string; }\n" + + "export interface Digest { fromAlpha: Note; fromBeta: Note; }\n", + ); + writeFileSync(join(dir, "DigestDoc.render.ts"), src); + + const mod = await import(join(dir, "DigestDoc.render.ts")); + const out: string = mod.renderDigestDoc( + { fromAlpha: { alphaText: "AA" }, fromBeta: { betaText: "BB" } }, + provider, + ); + expect(out).toBe("Alpha=AA Beta=BB"); + }); + test("drift case THROWS ERR_VAR_NOT_ON_PAYLOAD (fails codegen)", async () => { const root = await loadRootFromFile(join(CORPUS, "drift", "meta.json")); const provider = new FileSystemProvider(join(CORPUS, "drift", "templates")); diff --git a/server/typescript/packages/codegen-ts/test/template-payload-tree.test.ts b/server/typescript/packages/codegen-ts/test/template-payload-tree.test.ts new file mode 100644 index 000000000..99160a713 --- /dev/null +++ b/server/typescript/packages/codegen-ts/test/template-payload-tree.test.ts @@ -0,0 +1,37 @@ +import { describe, test, expect } from "bun:test"; +import { MetaDataLoader, InMemoryStringSource } from "@metaobjectsdev/metadata"; +import { buildEnrichedPayloadTree } from "../src/generators/template-payload-tree.js"; + +// Multi-file (multi-package) load — one InMemoryStringSource per doc, merged into a +// single root (mirrors fixtures/conformance/loader-same-name-distinct-packages). +async function loadRootFromDocs(...docs: unknown[]) { + const res = await new MetaDataLoader().load(docs.map((d) => new InMemoryStringSource(JSON.stringify(d)))); + expect(res.errors).toEqual([]); + return res.root; +} + +describe("buildEnrichedPayloadTree — docs annotator payload walk", () => { + // ADR-0041: two packages each declare an object.value `Note` (alpha: alphaText, beta: + // betaText), and `Digest` references BOTH by FULLY-QUALIFIED @objectRef. Pre-fix the + // annotator did stripPackage(ref) then a bare root.findObject, binding BOTH refs to + // whichever Note loaded first — so the doc node for fromBeta showed alpha's field. + // FQN-exact resolution binds each ref to its own package. + test("FQN nested @objectRef binds the exact package across a cross-package short-name collision", async () => { + const root = await loadRootFromDocs( + { "metadata.root": { package: "acme::alpha", children: [ + { "object.value": { name: "Note", children: [{ "field.string": { name: "alphaText" } }] } } ] } }, + { "metadata.root": { package: "acme::beta", children: [ + { "object.value": { name: "Note", children: [{ "field.string": { name: "betaText" } }] } } ] } }, + { "metadata.root": { package: "acme::app", children: [ + { "object.value": { name: "Digest", children: [ + { "field.object": { name: "fromAlpha", "@objectRef": "acme::alpha::Note" } }, + { "field.object": { name: "fromBeta", "@objectRef": "acme::beta::Note" } } ] } } ] } }, + ); + const tree = buildEnrichedPayloadTree(root, "Digest"); + const alpha = tree.find((n) => n.name === "fromAlpha"); + const beta = tree.find((n) => n.name === "fromBeta"); + // The inner field name comes from the correctly-bound VO (pre-fix both were alphaText). + expect(alpha?.fields?.map((f) => f.name)).toEqual(["alphaText"]); + expect(beta?.fields?.map((f) => f.name)).toEqual(["betaText"]); + }); +}); From 4e0e63a55f3c76afc711ab61a6e3cb008373932f Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Mon, 6 Jul 2026 21:19:46 -0400 Subject: [PATCH 2/3] no-mistakes(review): key render-helper cycle guard by FQN not bare name --- .../MetaObjects.Codegen/Generators/RenderHelperGenerator.cs | 2 +- .../metaobjects/codegen/generators/render_helper_generator.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/server/csharp/MetaObjects.Codegen/Generators/RenderHelperGenerator.cs b/server/csharp/MetaObjects.Codegen/Generators/RenderHelperGenerator.cs index d7f045997..50ee9ad17 100644 --- a/server/csharp/MetaObjects.Codegen/Generators/RenderHelperGenerator.cs +++ b/server/csharp/MetaObjects.Codegen/Generators/RenderHelperGenerator.cs @@ -241,7 +241,7 @@ private void GateRef(string templateName, string reference, IReadOnlyList DerivePayloadFieldTree( MetaRoot root, MetaData vo, HashSet seen) { - if (vo is null || !seen.Add(vo.Name)) return []; + if (vo is null || !seen.Add(vo.ResolutionKey())) return []; var fields = new List(); foreach (var f in vo.Children().Where(c => c.Type == TYPE_FIELD)) { 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 55f44deba..f469c279f 100644 --- a/server/python/src/metaobjects/codegen/generators/render_helper_generator.py +++ b/server/python/src/metaobjects/codegen/generators/render_helper_generator.py @@ -115,9 +115,9 @@ def _derive_payload_field_tree( resolvable ``@objectRef`` to an ``object.value`` becomes a context-pushing node whose children are the target VO's tree (recursed, cycle-guarded). Every other field is a leaf.""" - if vo is None or vo.name in seen: + if vo is None or vo.resolution_key() in seen: return [] - next_seen = seen | {vo.name} + next_seen = seen | {vo.resolution_key()} fields: list[PayloadField] = [] for f in vo.children(): if f.type != TYPE_FIELD or not isinstance(f, MetaField): From 0c6fcea3ff98af3e6ff93620506d8b3c387d5f53 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Mon, 6 Jul 2026 21:43:28 -0400 Subject: [PATCH 3/3] no-mistakes(document): sync ADR-0041 consequences with non-JVM render/verify FQN fix --- spec/decisions/ADR-0041-cross-package-reference-resolution.md | 1 + 1 file changed, 1 insertion(+) diff --git a/spec/decisions/ADR-0041-cross-package-reference-resolution.md b/spec/decisions/ADR-0041-cross-package-reference-resolution.md index 636890e5f..db8079338 100644 --- a/spec/decisions/ADR-0041-cross-package-reference-resolution.md +++ b/spec/decisions/ADR-0041-cross-package-reference-resolution.md @@ -34,6 +34,7 @@ The "referrer's package" is the package of the node carrying the reference. This - **New error code `ERR_AMBIGUOUS_REF`**, registered cross-port (added to each port's error-code set and `fixtures/conformance/ERROR-CODES.json`). - **Resolvers become referrer-package-aware.** Each port routes object-reference resolution through one shared helper (`resolveObjectRef(root, ref, referrerPkg)` in TS) so relationship/reference/origin/extends/template resolution behave identically; the referrer's package is threaded to each call site. Java's bare-tail-before-FQN ordering (`SymbolTable.nameMatches`, `ValidationPhase.nameMatches`) and the Java codegen/runtime helpers that shared it (`SpringM2mSupport.findEntity`, `KotlinRenderHelperGenerator`, and the M:N FK-derivation SSOT `M2MFields` + runtime `M2MResolver`) are corrected to resolve FQNs exactly; `SpringPayloadGenerator`/`KotlinGenUtil` were already FQN-safe and unchanged. The remaining *bare*-collision same-package preference in codegen/runtime is a documented follow-up (#174). +- **Render/verify codegen tier (non-JVM follow-on, 2026-07-06).** The loader-level fix above corrected the JVM render helper (`KotlinRenderHelperGenerator`); the equivalent nested-`@objectRef` field-tree resolvers on the **non-JVM** ports still bare-tail-collapsed a *fully-qualified* nested `@objectRef` — latent until a cross-package short-name collision. They now resolve FQN-exact too: the render-helper generator (C# `RenderHelperGenerator`, Python `render_helper_generator`), the `verify` payload-field-tree (TS `cli` `payload-field-tree`, C# `PayloadCodegen.BuildTree`), and the TS docs-annotator (`codegen-ts` `template-payload-tree`). TS routes the two buggy resolvers through the shared `refMatchesObject` SSOT (its render helper was already FQN-safe); C# keeps a `verify`-only `ResolveObjectRef` *separate* from the bare `FindObject` used by payload-record emission, so generated record identifiers stay bare. Gated by the new `xpkg-collision/` multi-package sub-corpus in `fixtures/template-output-render-conformance/` (Java is already covered by `TemplateVerifyTest`'s own ADR-0041 collision case, so its heavy javac runner is unchanged). Only the FQN-exact half ports here; the *bare*-collision same-package preference remains the #174 follow-up. - **Conformance-gated.** New `fixtures/conformance/` fixtures cover every ref-bearing attribute cross-package (unique-name working cases) plus the collision contract: FQN-exact-across-collision (via `extends`, observable in effective serialization), bare same-package preference, and bare-ambiguous → `ERR_AMBIGUOUS_REF`. All five ports must serialize/err byte-identically. - **Breaking only for models that today rely on a bare reference silently binding to an arbitrarily-chosen same-named entity in another package** — previously undefined behavior, now an explicit `ERR_AMBIGUOUS_REF` that is fixed by qualifying the reference. No change for unique names or explicit FQNs.