Skip to content

Commit 859b487

Browse files
dmealingclaude
andcommitted
feat(codegen-kotlin): KotlinOutputParserGenerator — typed parser for template.output (FR-006)
Emits <TemplateShortName>Parser.kt beside the existing payload class, with a kotlinx.serialization-backed dual API: - parse<Name>(text): <Name>Payload (throws SerializationException) - safeParse<Name>(text): Result<<Name>Payload> Symmetric with TS (parse/safeParse), C# (Parse/TryParse), Python (parse). ADR-0010 cross-port contract; spec at docs/superpowers/specs/2026-05-25-fr6-template-output-parser-codegen.md. Reuses the @serializable data class emitted by KotlinPayloadGenerator — does not redeclare the payload shape. Tests: 6 unit tests + 1 snapshot fixture (template-output-parser). Snapshot test now dispatches "output-parser" → KotlinOutputParserGenerator alongside the existing generators. Total codegen-kotlin test count: 111 → 118. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 68f45d6 commit 859b487

7 files changed

Lines changed: 473 additions & 0 deletions

File tree

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
package com.metaobjects.generator.kotlin
2+
3+
import com.metaobjects.generator.GeneratorIOWriter
4+
import com.metaobjects.generator.direct.MultiFileDirectGeneratorBase
5+
import com.metaobjects.loader.MetaDataLoader
6+
import com.metaobjects.`object`.MetaObject
7+
import com.metaobjects.template.MetaTemplate
8+
import com.metaobjects.template.OutputTemplate
9+
import java.io.OutputStream
10+
import java.io.PrintWriter
11+
import java.nio.file.Files
12+
import java.nio.file.Path
13+
import java.nio.file.Paths
14+
import org.slf4j.LoggerFactory
15+
16+
/**
17+
* Generator: one `<TemplateShortName>Parser.kt` per `template.output` declaration,
18+
* emitting a typed parser around the `@payloadRef` payload-VO data class produced
19+
* by [KotlinPayloadGenerator] (no payload-shape re-declaration).
20+
*
21+
* <p>FR-006 — the Kotlin port of the cross-language template-output parser
22+
* codegen. See `docs/superpowers/specs/2026-05-25-fr6-template-output-parser-codegen.md`
23+
* and ADR-0010 for the cross-port contract; this generator is the Kotlin sibling
24+
* of TS's `outputParser()`, C#'s `OutputParserGenerator`, and Python's
25+
* `OutputParserGenerator`.
26+
*
27+
* <p>API shape (idiomatic Kotlin dual-API per ADR-0010 §3):
28+
* <pre>
29+
* object &lt;TemplateShortName&gt;Parser {
30+
* private val json: Json = Json { ignoreUnknownKeys = false }
31+
*
32+
* // Throws kotlinx.serialization.SerializationException on bad input.
33+
* fun parse&lt;TemplateShortName&gt;(text: String): &lt;TemplateShortName&gt;Payload
34+
*
35+
* // Result-style — does not throw. Wraps the above in runCatching { }.
36+
* fun safeParse&lt;TemplateShortName&gt;(text: String): Result&lt;&lt;TemplateShortName&gt;Payload&gt;
37+
* }
38+
* </pre>
39+
*
40+
* <p>Skips and defensive cases (mirrors the cross-port behavior):
41+
* <ul>
42+
* <li><b>template.prompt</b> is ignored — only outputs need parsing.</li>
43+
* <li>Missing `@payloadRef` — skipped with a warning (loader's
44+
* validation pass normally rejects this first; defensive only).</li>
45+
* <li>`@payloadRef` resolves to a non-VO target (e.g. `object.entity`) —
46+
* skipped (same contract as [KotlinPayloadGenerator]).</li>
47+
* <li>Outputs are processed in stable name order for deterministic emission.</li>
48+
* </ul>
49+
*
50+
* <p>The emitted file's package matches [KotlinPayloadGenerator]'s
51+
* (`<entity-pkg>.prompts`) so the payload data class import is implicit
52+
* (same-package reference).
53+
*
54+
* <p>Substrate justification (hand-rolled string builder rather than KotlinPoet):
55+
* the parser file is ~25 lines of trivial Kotlin with no generic type machinery,
56+
* so the hand-rolled emit is clearer than the equivalent KotlinPoet dance with
57+
* `%T` placeholders for `Result<Payload>` etc. Same trade-off as
58+
* [KotlinSpringControllerGenerator] / [KotlinExposedTableGenerator].
59+
*
60+
* <p>Args:
61+
* <ul>
62+
* <li>{@code outputDir} (required): output directory root.</li>
63+
* </ul>
64+
*/
65+
class KotlinOutputParserGenerator : MultiFileDirectGeneratorBase<MetaObject>() {
66+
67+
override fun getFilterClass(): Class<MetaObject> = MetaObject::class.java
68+
69+
override fun execute(loader: MetaDataLoader) {
70+
parseArgs()
71+
val outRoot = Paths.get(outDir.absolutePath)
72+
73+
// Stable name order — matches TS/C#/Python deterministic emission.
74+
val outputs = loader.root.children
75+
.filterIsInstance<OutputTemplate>()
76+
.sortedBy { it.name }
77+
78+
for (tmpl in outputs) {
79+
emit(tmpl, loader, outRoot)
80+
}
81+
}
82+
83+
private fun emit(template: MetaTemplate, loader: MetaDataLoader, outRoot: Path) {
84+
val payloadRef = template.payloadRef
85+
if (payloadRef.isNullOrEmpty()) {
86+
// Loader validation normally catches this first; defensive only.
87+
LOG.warn(
88+
"skipping output-parser for {} — missing @payloadRef",
89+
template.name
90+
)
91+
return
92+
}
93+
val payloadVo = resolveViewObject(loader, payloadRef)
94+
if (payloadVo == null) {
95+
// @payloadRef resolves to an object.entity (or nothing) — same contract
96+
// as KotlinPayloadGenerator: payloads MUST be VOs.
97+
LOG.warn(
98+
"skipping output-parser for {} — @payloadRef '{}' does not resolve to an object.value",
99+
template.name, payloadRef
100+
)
101+
return
102+
}
103+
104+
val (templatePkg, templateShort) = PackageMapping.splitFqn(template.name)
105+
val outPkg = if (templatePkg.isEmpty()) "prompts" else "$templatePkg.prompts"
106+
val parserClass = templateShort + "Parser"
107+
val payloadClass = templateShort + "Payload"
108+
val parseFn = "parse$templateShort"
109+
val safeParseFn = "safeParse$templateShort"
110+
111+
val src = buildString {
112+
append("// GENERATED — DO NOT EDIT — parser for template.output `")
113+
append(template.name)
114+
append("`\n")
115+
if (outPkg.isNotEmpty()) {
116+
append("package ")
117+
append(outPkg)
118+
append("\n\n")
119+
}
120+
append("import kotlinx.serialization.SerializationException\n")
121+
append("import kotlinx.serialization.json.Json\n")
122+
append("\n")
123+
append("/** Parser for LLM responses matching the `")
124+
append(templateShort)
125+
append("` template.output. */\n")
126+
append("object ")
127+
append(parserClass)
128+
append(" {\n")
129+
append("\n")
130+
append(" private val json: Json = Json { ignoreUnknownKeys = false }\n")
131+
append("\n")
132+
append(" /**\n")
133+
append(" * Parse an LLM response into a typed [")
134+
append(payloadClass)
135+
append("].\n")
136+
append(" *\n")
137+
append(" * @throws SerializationException when the input is not valid JSON for the payload schema.\n")
138+
append(" */\n")
139+
append(" fun ")
140+
append(parseFn)
141+
append("(text: String): ")
142+
append(payloadClass)
143+
append(" =\n")
144+
append(" json.decodeFromString<")
145+
append(payloadClass)
146+
append(">(text)\n")
147+
append("\n")
148+
append(" /**\n")
149+
append(" * Parse with explicit error handling (Result-style — does not throw).\n")
150+
append(" */\n")
151+
append(" fun ")
152+
append(safeParseFn)
153+
append("(text: String): Result<")
154+
append(payloadClass)
155+
append("> =\n")
156+
append(" runCatching { ")
157+
append(parseFn)
158+
append("(text) }\n")
159+
append("}\n")
160+
}
161+
162+
val outFile = outRoot.resolve(outPkg.replace('.', '/')).resolve("$parserClass.kt")
163+
outFile.parent?.let { Files.createDirectories(it) }
164+
Files.writeString(outFile, src)
165+
}
166+
167+
/** Resolve a `@payloadRef` to its `object.value` (rejects entities — payloads must be VOs). */
168+
private fun resolveViewObject(loader: MetaDataLoader, ref: String): MetaObject? =
169+
KotlinGenUtil.resolveObjectByShortOrFqn(loader, ref)
170+
?.takeIf { it.subType == MetaObject.SUBTYPE_VALUE }
171+
172+
// === MultiFileDirectGeneratorBase abstract-method stubs ====================
173+
override fun writeSingleFile(md: MetaObject, writer: GeneratorIOWriter<*>?) { /* unused */ }
174+
override fun <T : GeneratorIOWriter<*>?> getSingleWriter(
175+
loader: MetaDataLoader?, md: MetaObject?, pw: PrintWriter?
176+
): T? = null
177+
override fun <T : GeneratorIOWriter<*>?> getFinalWriter(
178+
loader: MetaDataLoader?, out: OutputStream?
179+
): T? = null
180+
override fun writeFinalFile(metadata: MutableCollection<MetaObject>?, writer: GeneratorIOWriter<*>?) { /* none */ }
181+
override fun getSingleOutputFilePath(md: MetaObject): String = ""
182+
override fun getSingleOutputFilename(md: MetaObject): String = "${md.name}.kt"
183+
184+
companion object {
185+
private val LOG = LoggerFactory.getLogger(KotlinOutputParserGenerator::class.java)
186+
}
187+
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ class KotlinCodegenSnapshotTest {
5656
"storedproc" -> KotlinStoredProcGenerator()
5757
"controller" -> KotlinSpringControllerGenerator()
5858
"filter-allowlist" -> KotlinFilterAllowlistGenerator()
59+
"output-parser" -> KotlinOutputParserGenerator()
5960
else -> fail("unknown generator name in config: $g")
6061
}
6162
val args = mutableMapOf("outputDir" to outDir.toString())

0 commit comments

Comments
 (0)