From 4622d3c970402ba5b27362de72809046c0d26c74 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Fri, 10 Jul 2026 14:07:15 -0400 Subject: [PATCH] test(codegen): pin FQN @objectRef payload codegen strips to bare names (Python + Kotlin) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cross-port coverage follow-up to the TS promptRender FQN fix (54433a0b). An investigation of all four non-TS ports found the FQN-leak is TS-only — Java/C#/Kotlin/Python payload-VO codegen already strip the package (splitFqn / StripPkg / KotlinPoet ClassName(pkg,name)) — but Python and Kotlin lacked a test forcing a real package-qualified @objectRef through that path. - Python: test_plain_object_field_strips_fqn_to_bare_nested_payload — a plain field.object @objectRef="acme::ai::Note" isArray on a payload VO emits list[NotePayload] + class NotePayload(BaseModel), never the FQN. - Kotlin: a two-package fixture (Note in acme::ai referenced from Report in acme::demo) forces a real prefix through PackageMapping.splitFqn; asserts val notes: List + data class NotePayload, no '::' in any emitted identifier. Test-only, no product-code change (all four ports were already correct). Java + C# already had equivalent FQN regression tests. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01FaRaYFjvWVV8D6h33ejj1m --- .../kotlin/KotlinPayloadGeneratorTest.kt | 80 +++++++++++++++++++ .../codegen/test_payload_vo_generator.py | 58 ++++++++++++++ 2 files changed, 138 insertions(+) diff --git a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinPayloadGeneratorTest.kt b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinPayloadGeneratorTest.kt index d9bdbb202..81be80413 100644 --- a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinPayloadGeneratorTest.kt +++ b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinPayloadGeneratorTest.kt @@ -1,5 +1,6 @@ package com.metaobjects.generator.kotlin +import com.metaobjects.metadata.ktx.loadDirectory import com.metaobjects.metadata.ktx.loadString import java.nio.file.Files import kotlin.test.Test @@ -249,4 +250,83 @@ class KotlinPayloadGeneratorTest { outDir.toFile().deleteRecursively() } } + + @Test fun `nested field-object objectRef given as a cross-package FQN emits bare payload names, no package-qualified identifier`() { + // Regression companion to the TS promptRender FQN-leak fix (0.15.17): a payload VO whose + // naked `field.object @objectRef` points at ANOTHER object.value declared in a DIFFERENT + // package. The target's resolved name is a real FQN (`acme::ai::Note`), so + // PackageMapping.splitFqn must strip the package for BOTH the emitted property type and + // the nested data-class name — an FQN contains `::`, which is not a valid Kotlin + // identifier. (The TS port leaked `List` / `data class acme::ai::Note`; + // Kotlin's ClassName(pkg, simpleName) construction is FQN-safe — this pins it.) + // + // Two packages ⇒ two files (a single loadString supports only one root/package), so the + // prefix is genuinely non-empty when it reaches splitFqn. + val noteJson = """{ + "metadata.root": { "package": "acme::ai", "children": [ + { "object.value": { "name": "Note", "children": [ + { "field.string": { "name": "text" } } + ] } } + ] } + }""".trimIndent() + val reportJson = """{ + "metadata.root": { "package": "acme::demo", "children": [ + { "object.value": { "name": "Report", "children": [ + { "field.string": { "name": "title" } }, + { "field.object": { "name": "notes", + "@objectRef": "acme::ai::Note", "isArray": true } } + ] } }, + { "template.prompt": { "name": "ReportPrompt", + "@payloadRef": "Report", "@textRef": "demo/report" } } + ] } + }""".trimIndent() + + val srcDir = Files.createTempDirectory("kpay-fqn-src-") + val outDir = Files.createTempDirectory("kpay-fqn-out-") + try { + // note.json sorts before report.json — but cross-file @objectRef resolution is + // load-order-independent (ADR-0041), so ordering is not load-bearing here. + Files.writeString(srcDir.resolve("note.json"), noteJson) + Files.writeString(srcDir.resolve("report.json"), reportJson) + + val gen = KotlinPayloadGenerator() + gen.setArgs(mapOf("outputDir" to outDir.toString())) + gen.execute(loadDirectory("test-fqn", srcDir)) + + // Nested payload for a cross-package target co-locates in the REFERRING template's + // prompts package (acme.demo.prompts), keyed by the target's bare short name. + val parentFile = outDir.resolve("acme/demo/prompts/ReportPromptPayload.kt") + val nestedFile = outDir.resolve("acme/demo/prompts/NotePayload.kt") + assertTrue(Files.exists(parentFile), + "expected $parentFile; files=${Files.walk(outDir).toList()}") + assertTrue(Files.exists(nestedFile), + "expected $nestedFile; files=${Files.walk(outDir).toList()}") + + val parentSrc = Files.readString(parentFile) + // Bare, FQN-stripped array-of-nested-payload type — NOT `List`. + assertTrue("val notes: List" in parentSrc, parentSrc) + assertTrue("val title: String" in parentSrc, parentSrc) + + val nestedSrc = Files.readString(nestedFile) + // Bare data-class name — NOT `data class acme::ai::Note`. + assertTrue("data class NotePayload" in nestedSrc, nestedSrc) + assertTrue("val text: String" in nestedSrc, nestedSrc) + assertTrue("package acme.demo.prompts" in nestedSrc, nestedSrc) + + // No emitted IDENTIFIER may contain `::`. The FQN legitimately survives only in KDoc + // comment lines (which document the source object `acme::ai::Note`); every other line + // must be `::`-free. Filter comment lines (leading `*` or `/`), then assert clean. + for ((label, src) in listOf("parent" to parentSrc, "nested" to nestedSrc)) { + val offenders = src.lines().filter { line -> + val t = line.trimStart() + "::" in line && !t.startsWith("*") && !t.startsWith("/") + } + assertTrue(offenders.isEmpty(), + "$label: no emitted identifier may contain '::'; offenders=$offenders\n$src") + } + } finally { + srcDir.toFile().deleteRecursively() + outDir.toFile().deleteRecursively() + } + } } diff --git a/server/python/tests/codegen/test_payload_vo_generator.py b/server/python/tests/codegen/test_payload_vo_generator.py index d28aed6ee..37b456fb7 100644 --- a/server/python/tests/codegen/test_payload_vo_generator.py +++ b/server/python/tests/codegen/test_payload_vo_generator.py @@ -65,6 +65,16 @@ def _field_with_origin(name: str, sub: str, origin: MetaOrigin) -> MetaField: return f +def _object_field(name: str, object_ref: str, *, is_array: bool = False) -> MetaField: + """A plain ``field.object`` carrying an ``@objectRef`` (no origin child). The + ref is passed in the FQN form the loader produces post-ADR-0041 (e.g. + ``acme::ai::Note``) so the generator's package-stripping is exercised.""" + f = MetaField(TYPE_FIELD, fc.FIELD_SUBTYPE_OBJECT, name) + f.set_attr(fc.FIELD_ATTR_OBJECT_REF, object_ref) + f.is_array = is_array + return f + + def _passthrough(from_ref: str) -> MetaOrigin: o = MetaOrigin(TYPE_ORIGIN, ORIGIN_SUBTYPE_PASSTHROUGH, "from") o.set_attr(ORIGIN_ATTR_FROM, from_ref) @@ -370,6 +380,54 @@ def test_origin_collection_nested_payload_deduped_within_file() -> None: assert "drafts: list[PostPayload]" in out +# --------------------------------------------------------------------------- +# Plain field.object @objectRef (no origin) — the FQN must be stripped to the +# bare name for BOTH the emitted field annotation AND the nested class +# declaration. Cross-port regression guard: the TS payload generator once +# emitted the raw ``@objectRef`` FQN verbatim (``notes: acme::ai::Note[]`` and +# ``interface acme::ai::Note``). Python resolves the ref to the target +# MetaObject and names off ``target.name`` (always bare), so this must stay +# clean — this test locks that in. +# --------------------------------------------------------------------------- + + +def test_plain_object_field_strips_fqn_to_bare_nested_payload() -> None: + """A ``field.object`` whose ``@objectRef`` is a package-qualified FQN + (``acme::ai::Note`` — the form the loader emits post-ADR-0041) must type as + ``list[NotePayload]`` and emit ``class NotePayload(BaseModel):`` — the BARE + name — never the raw FQN. Guards the cross-port payload FQN leak that was + TS-only (``notes: acme::ai::Note[]`` / ``interface acme::ai::Note``).""" + note = _value_object( + "Note", [_field("text", fc.FIELD_SUBTYPE_STRING)], package="acme::ai" + ) + report = _value_object( + "Report", + [_object_field("notes", "acme::ai::Note", is_array=True)], + package="acme::ai", + ) + tmpl = _template("ReportOutput", "Report") + root = _root([note, report, tmpl]) + out = render_payload_vo(tmpl, root) + assert out is not None + # Field annotation types to the BARE nested-payload class, wrapped as a list. + assert "notes: list[NotePayload]" in out + # Nested payload class declared under its BARE name, before the primary class. + assert "class NotePayload(BaseModel):" in out + assert out.find("class NotePayload(BaseModel):") < out.find( + "class ReportOutputPayload(BaseModel):" + ) + assert "text: str" in out + # __all__ carries both BARE class names. + assert '__all__ = ["ReportOutputPayload", "NotePayload"]' in out + # The referenced VO's FQN must NOT leak anywhere in the emitted source. + assert "acme::ai::Note" not in out + # No package separator in the emitted CODE. (The generated header comment + # legitimately carries the module's OWN FQN — ``acme::ai::ReportOutput`` — + # so the ``::``-free check is scoped to non-comment lines.) + code = "\n".join(ln for ln in out.splitlines() if not ln.lstrip().startswith("#")) + assert "::" not in code + + # --------------------------------------------------------------------------- # Resolution edge cases. # ---------------------------------------------------------------------------