Skip to content

Commit 24a7d4e

Browse files
dmealingclaude
andauthored
test(codegen): pin FQN @objectref payload codegen strips to bare names (Python + Kotlin) (#190)
Cross-port coverage follow-up to the TS promptRender FQN fix (54433a0). 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<NotePayload> + 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. Claude-Session: https://claude.ai/code/session_01FaRaYFjvWVV8D6h33ejj1m Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 54433a0 commit 24a7d4e

2 files changed

Lines changed: 138 additions & 0 deletions

File tree

server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinPayloadGeneratorTest.kt

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.metaobjects.generator.kotlin
22

3+
import com.metaobjects.metadata.ktx.loadDirectory
34
import com.metaobjects.metadata.ktx.loadString
45
import java.nio.file.Files
56
import kotlin.test.Test
@@ -249,4 +250,83 @@ class KotlinPayloadGeneratorTest {
249250
outDir.toFile().deleteRecursively()
250251
}
251252
}
253+
254+
@Test fun `nested field-object objectRef given as a cross-package FQN emits bare payload names, no package-qualified identifier`() {
255+
// Regression companion to the TS promptRender FQN-leak fix (0.15.17): a payload VO whose
256+
// naked `field.object @objectRef` points at ANOTHER object.value declared in a DIFFERENT
257+
// package. The target's resolved name is a real FQN (`acme::ai::Note`), so
258+
// PackageMapping.splitFqn must strip the package for BOTH the emitted property type and
259+
// the nested data-class name — an FQN contains `::`, which is not a valid Kotlin
260+
// identifier. (The TS port leaked `List<acme::ai::Note>` / `data class acme::ai::Note`;
261+
// Kotlin's ClassName(pkg, simpleName) construction is FQN-safe — this pins it.)
262+
//
263+
// Two packages ⇒ two files (a single loadString supports only one root/package), so the
264+
// prefix is genuinely non-empty when it reaches splitFqn.
265+
val noteJson = """{
266+
"metadata.root": { "package": "acme::ai", "children": [
267+
{ "object.value": { "name": "Note", "children": [
268+
{ "field.string": { "name": "text" } }
269+
] } }
270+
] }
271+
}""".trimIndent()
272+
val reportJson = """{
273+
"metadata.root": { "package": "acme::demo", "children": [
274+
{ "object.value": { "name": "Report", "children": [
275+
{ "field.string": { "name": "title" } },
276+
{ "field.object": { "name": "notes",
277+
"@objectRef": "acme::ai::Note", "isArray": true } }
278+
] } },
279+
{ "template.prompt": { "name": "ReportPrompt",
280+
"@payloadRef": "Report", "@textRef": "demo/report" } }
281+
] }
282+
}""".trimIndent()
283+
284+
val srcDir = Files.createTempDirectory("kpay-fqn-src-")
285+
val outDir = Files.createTempDirectory("kpay-fqn-out-")
286+
try {
287+
// note.json sorts before report.json — but cross-file @objectRef resolution is
288+
// load-order-independent (ADR-0041), so ordering is not load-bearing here.
289+
Files.writeString(srcDir.resolve("note.json"), noteJson)
290+
Files.writeString(srcDir.resolve("report.json"), reportJson)
291+
292+
val gen = KotlinPayloadGenerator()
293+
gen.setArgs(mapOf("outputDir" to outDir.toString()))
294+
gen.execute(loadDirectory("test-fqn", srcDir))
295+
296+
// Nested payload for a cross-package target co-locates in the REFERRING template's
297+
// prompts package (acme.demo.prompts), keyed by the target's bare short name.
298+
val parentFile = outDir.resolve("acme/demo/prompts/ReportPromptPayload.kt")
299+
val nestedFile = outDir.resolve("acme/demo/prompts/NotePayload.kt")
300+
assertTrue(Files.exists(parentFile),
301+
"expected $parentFile; files=${Files.walk(outDir).toList()}")
302+
assertTrue(Files.exists(nestedFile),
303+
"expected $nestedFile; files=${Files.walk(outDir).toList()}")
304+
305+
val parentSrc = Files.readString(parentFile)
306+
// Bare, FQN-stripped array-of-nested-payload type — NOT `List<acme::ai::Note>`.
307+
assertTrue("val notes: List<NotePayload>" in parentSrc, parentSrc)
308+
assertTrue("val title: String" in parentSrc, parentSrc)
309+
310+
val nestedSrc = Files.readString(nestedFile)
311+
// Bare data-class name — NOT `data class acme::ai::Note`.
312+
assertTrue("data class NotePayload" in nestedSrc, nestedSrc)
313+
assertTrue("val text: String" in nestedSrc, nestedSrc)
314+
assertTrue("package acme.demo.prompts" in nestedSrc, nestedSrc)
315+
316+
// No emitted IDENTIFIER may contain `::`. The FQN legitimately survives only in KDoc
317+
// comment lines (which document the source object `acme::ai::Note`); every other line
318+
// must be `::`-free. Filter comment lines (leading `*` or `/`), then assert clean.
319+
for ((label, src) in listOf("parent" to parentSrc, "nested" to nestedSrc)) {
320+
val offenders = src.lines().filter { line ->
321+
val t = line.trimStart()
322+
"::" in line && !t.startsWith("*") && !t.startsWith("/")
323+
}
324+
assertTrue(offenders.isEmpty(),
325+
"$label: no emitted identifier may contain '::'; offenders=$offenders\n$src")
326+
}
327+
} finally {
328+
srcDir.toFile().deleteRecursively()
329+
outDir.toFile().deleteRecursively()
330+
}
331+
}
252332
}

server/python/tests/codegen/test_payload_vo_generator.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,16 @@ def _field_with_origin(name: str, sub: str, origin: MetaOrigin) -> MetaField:
6565
return f
6666

6767

68+
def _object_field(name: str, object_ref: str, *, is_array: bool = False) -> MetaField:
69+
"""A plain ``field.object`` carrying an ``@objectRef`` (no origin child). The
70+
ref is passed in the FQN form the loader produces post-ADR-0041 (e.g.
71+
``acme::ai::Note``) so the generator's package-stripping is exercised."""
72+
f = MetaField(TYPE_FIELD, fc.FIELD_SUBTYPE_OBJECT, name)
73+
f.set_attr(fc.FIELD_ATTR_OBJECT_REF, object_ref)
74+
f.is_array = is_array
75+
return f
76+
77+
6878
def _passthrough(from_ref: str) -> MetaOrigin:
6979
o = MetaOrigin(TYPE_ORIGIN, ORIGIN_SUBTYPE_PASSTHROUGH, "from")
7080
o.set_attr(ORIGIN_ATTR_FROM, from_ref)
@@ -370,6 +380,54 @@ def test_origin_collection_nested_payload_deduped_within_file() -> None:
370380
assert "drafts: list[PostPayload]" in out
371381

372382

383+
# ---------------------------------------------------------------------------
384+
# Plain field.object @objectRef (no origin) — the FQN must be stripped to the
385+
# bare name for BOTH the emitted field annotation AND the nested class
386+
# declaration. Cross-port regression guard: the TS payload generator once
387+
# emitted the raw ``@objectRef`` FQN verbatim (``notes: acme::ai::Note[]`` and
388+
# ``interface acme::ai::Note``). Python resolves the ref to the target
389+
# MetaObject and names off ``target.name`` (always bare), so this must stay
390+
# clean — this test locks that in.
391+
# ---------------------------------------------------------------------------
392+
393+
394+
def test_plain_object_field_strips_fqn_to_bare_nested_payload() -> None:
395+
"""A ``field.object`` whose ``@objectRef`` is a package-qualified FQN
396+
(``acme::ai::Note`` — the form the loader emits post-ADR-0041) must type as
397+
``list[NotePayload]`` and emit ``class NotePayload(BaseModel):`` — the BARE
398+
name — never the raw FQN. Guards the cross-port payload FQN leak that was
399+
TS-only (``notes: acme::ai::Note[]`` / ``interface acme::ai::Note``)."""
400+
note = _value_object(
401+
"Note", [_field("text", fc.FIELD_SUBTYPE_STRING)], package="acme::ai"
402+
)
403+
report = _value_object(
404+
"Report",
405+
[_object_field("notes", "acme::ai::Note", is_array=True)],
406+
package="acme::ai",
407+
)
408+
tmpl = _template("ReportOutput", "Report")
409+
root = _root([note, report, tmpl])
410+
out = render_payload_vo(tmpl, root)
411+
assert out is not None
412+
# Field annotation types to the BARE nested-payload class, wrapped as a list.
413+
assert "notes: list[NotePayload]" in out
414+
# Nested payload class declared under its BARE name, before the primary class.
415+
assert "class NotePayload(BaseModel):" in out
416+
assert out.find("class NotePayload(BaseModel):") < out.find(
417+
"class ReportOutputPayload(BaseModel):"
418+
)
419+
assert "text: str" in out
420+
# __all__ carries both BARE class names.
421+
assert '__all__ = ["ReportOutputPayload", "NotePayload"]' in out
422+
# The referenced VO's FQN must NOT leak anywhere in the emitted source.
423+
assert "acme::ai::Note" not in out
424+
# No package separator in the emitted CODE. (The generated header comment
425+
# legitimately carries the module's OWN FQN — ``acme::ai::ReportOutput`` —
426+
# so the ``::``-free check is scoped to non-comment lines.)
427+
code = "\n".join(ln for ln in out.splitlines() if not ln.lstrip().startswith("#"))
428+
assert "::" not in code
429+
430+
373431
# ---------------------------------------------------------------------------
374432
# Resolution edge cases.
375433
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)