Skip to content

Commit fbe0d2e

Browse files
dmealingclaude
andcommitted
feat(kotlin): generator stable-name registry + conformance to the canonical manifest (ADR-0021 D3)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 6d538c5 commit fbe0d2e

2 files changed

Lines changed: 278 additions & 0 deletions

File tree

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
// ADR-0021 D3 — stable-name generator registry (Kotlin port of the TS reference
2+
// server/typescript/packages/codegen-ts/src/generator-registry.ts).
3+
//
4+
// Generators are identified by a STABLE string id (e.g. `entity`, `routes`,
5+
// `render-helper`) rather than by a language-specific class import / hardcoded
6+
// suite. The id is the cross-port contract: the same logical generator carries
7+
// the same stable name in every port. This is the discoverability + identity
8+
// surface behind a `--list`.
9+
//
10+
// It is ADDITIVE: it powers `--list` and a stable identity. It does NOT change
11+
// what any generator emits, nor how the Maven plugin currently constructs the
12+
// suite. Wiring selection-by-name into the Maven/CLI path is a staged follow-on.
13+
//
14+
// Stable names mirror the canonical manifest exactly for the kotlin slice:
15+
// entity, routes, output-parser, output-prompt, render-helper, extractor,
16+
// filter-allowlist, payload, exposed-table, relations, spring-config,
17+
// stored-proc, validator. (Kotlin has NO `template` generator — the manifest
18+
// deliberately omits kotlin from it.)
19+
20+
package com.metaobjects.generator.kotlin
21+
22+
import com.metaobjects.generator.direct.MultiFileDirectGeneratorBase
23+
24+
/** Tier of a registered generator (ADR-0020 / ADR-0021 D1). */
25+
enum class GeneratorTier {
26+
/** Recommended Tier-1 `gen` suite (idiomatic emission). */
27+
NATIVE,
28+
29+
/** Tier-2 artifact owned by the neutral docs engine. */
30+
NEUTRAL,
31+
}
32+
33+
/**
34+
* One registry entry: stable id + one-line description + tier + a refactor-safe
35+
* factory. The factory constructs the generator with its no-arg constructor
36+
* (each Kotlin generator is configured via [MultiFileDirectGeneratorBase.setArgs]
37+
* at run time, never via the constructor), so calling it must NOT throw —
38+
* `--list` relies on that.
39+
*/
40+
data class GeneratorInfo(
41+
/** Stable, cross-port-consistent id. Equals the registry map key. */
42+
val name: String,
43+
/** One-line (no newline) human description for `--list`. */
44+
val description: String,
45+
/** NATIVE = recommended `gen` suite; NEUTRAL = `docs`-owned. */
46+
val tier: GeneratorTier,
47+
/** Constructs the generator with sensible defaults. Calling it must not throw. */
48+
val factory: () -> MultiFileDirectGeneratorBase<*>,
49+
)
50+
51+
/**
52+
* The stable-name generator registry. Registers every Kotlin generator that
53+
* exists so all are discoverable (`--list`) and (in a follow-on) selectable by
54+
* stable name. Insertion order is the natural default-suite order.
55+
*
56+
* Factories reference the generator classes directly (via their constructors)
57+
* for refactor-safety — renaming/moving a generator class is a compile error
58+
* here, not a silent registry rot.
59+
*/
60+
val GENERATOR_REGISTRY: Map<String, GeneratorInfo> = linkedMapOf(
61+
"entity" to GeneratorInfo(
62+
name = "entity",
63+
description = "Per-entity Kotlin data class (the entity module).",
64+
tier = GeneratorTier.NATIVE,
65+
factory = ::KotlinEntityGenerator,
66+
),
67+
"routes" to GeneratorInfo(
68+
name = "routes",
69+
description = "Per-entity Spring REST controller (CRUD endpoint surface).",
70+
tier = GeneratorTier.NATIVE,
71+
factory = ::KotlinSpringControllerGenerator,
72+
),
73+
"output-parser" to GeneratorInfo(
74+
name = "output-parser",
75+
description = "Per-template tolerant output parser (recover-on-receipt).",
76+
tier = GeneratorTier.NATIVE,
77+
factory = ::KotlinOutputParserGenerator,
78+
),
79+
"output-prompt" to GeneratorInfo(
80+
name = "output-prompt",
81+
description = "Per-template output-format prompt fragment generator.",
82+
tier = GeneratorTier.NATIVE,
83+
factory = ::KotlinOutputPromptGenerator,
84+
),
85+
"render-helper" to GeneratorInfo(
86+
name = "render-helper",
87+
description = "Per-template.output render helper (document/email typed wrappers).",
88+
tier = GeneratorTier.NATIVE,
89+
factory = ::KotlinRenderHelperGenerator,
90+
),
91+
"extractor" to GeneratorInfo(
92+
name = "extractor",
93+
description = "Per-template strict typed extract<Name> helper (strict payload extraction).",
94+
tier = GeneratorTier.NATIVE,
95+
factory = ::KotlinExtractorGenerator,
96+
),
97+
"filter-allowlist" to GeneratorInfo(
98+
name = "filter-allowlist",
99+
description = "Per-entity REST filter allowlist (queryable-field guard).",
100+
tier = GeneratorTier.NATIVE,
101+
factory = ::KotlinFilterAllowlistGenerator,
102+
),
103+
"payload" to GeneratorInfo(
104+
name = "payload",
105+
description = "Per-template payload value object (the strict payload type).",
106+
tier = GeneratorTier.NATIVE,
107+
factory = ::KotlinPayloadGenerator,
108+
),
109+
"exposed-table" to GeneratorInfo(
110+
name = "exposed-table",
111+
description = "Per-entity Kotlin Exposed table object.",
112+
tier = GeneratorTier.NATIVE,
113+
factory = ::KotlinExposedTableGenerator,
114+
),
115+
"relations" to GeneratorInfo(
116+
name = "relations",
117+
description = "Cross-entity relationship helpers.",
118+
tier = GeneratorTier.NATIVE,
119+
factory = ::KotlinRelationsGenerator,
120+
),
121+
"spring-config" to GeneratorInfo(
122+
name = "spring-config",
123+
description = "Spring wiring/configuration for the generated surface.",
124+
tier = GeneratorTier.NATIVE,
125+
factory = ::KotlinSpringConfigGenerator,
126+
),
127+
"stored-proc" to GeneratorInfo(
128+
name = "stored-proc",
129+
description = "Stored-procedure binding helpers.",
130+
tier = GeneratorTier.NATIVE,
131+
factory = ::KotlinStoredProcGenerator,
132+
),
133+
"validator" to GeneratorInfo(
134+
name = "validator",
135+
description = "Per-entity input validator.",
136+
tier = GeneratorTier.NATIVE,
137+
factory = ::KotlinValidatorGenerator,
138+
),
139+
)
140+
141+
/** All entries, native-first then neutral, alphabetical within tier. */
142+
fun list(): List<GeneratorInfo> {
143+
val byName = compareBy<GeneratorInfo> { it.name }
144+
val all = GENERATOR_REGISTRY.values
145+
return all.filter { it.tier == GeneratorTier.NATIVE }.sortedWith(byName) +
146+
all.filter { it.tier == GeneratorTier.NEUTRAL }.sortedWith(byName)
147+
}
148+
149+
/** Resolve an entry by its stable id, or null if unknown. */
150+
fun getGenerator(id: String): GeneratorInfo? = GENERATOR_REGISTRY[id]
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
package com.metaobjects.generator.kotlin
2+
3+
import com.fasterxml.jackson.databind.JsonNode
4+
import com.fasterxml.jackson.databind.ObjectMapper
5+
import java.nio.file.Files
6+
import java.nio.file.Path
7+
import kotlin.test.Test
8+
import kotlin.test.assertEquals
9+
import kotlin.test.assertTrue
10+
11+
/**
12+
* Cross-port conformance for the Kotlin generator registry (ADR-0021 D3). Validates
13+
* [GENERATOR_REGISTRY] against the CANONICAL stable-name manifest at
14+
* `fixtures/generator-registry-conformance/registry.json` — the single cross-port
15+
* source of truth every port's registry is checked against (TS reference:
16+
* `server/typescript/packages/codegen-ts/src/generator-registry.ts`).
17+
*
18+
* Contract for the `kotlin` port:
19+
* (1) every stable name the Kotlin registry exposes appears in the manifest;
20+
* (2) presence both ways — every manifest entry whose `ports` includes `kotlin`
21+
* IS in the Kotlin registry, and the Kotlin registry exposes NO name whose
22+
* manifest `ports` omits `kotlin` (i.e. the two sets are EQUAL);
23+
* (3) tier agreement — every native manifest name is NATIVE in the registry
24+
* (Kotlin has no neutral generators per the manifest).
25+
*
26+
* On mismatch this REPORTS the exact diff (missing / extra / tier) so the manifest
27+
* and registry can be reconciled. It never mutates either. Repo-root resolution
28+
* mirrors the sibling Kotlin conformance tests (walk up from `user.dir`).
29+
*/
30+
class GeneratorRegistryConformanceTest {
31+
32+
private val port = "kotlin"
33+
34+
private val manifestPath: Path = run {
35+
var p: Path? = Path.of(System.getProperty("user.dir")).toAbsolutePath()
36+
while (p != null && !Files.exists(p.resolve("fixtures/generator-registry-conformance/registry.json"))) {
37+
p = p.parent
38+
}
39+
assertTrue(
40+
p != null,
41+
"could not locate fixtures/generator-registry-conformance/registry.json from user.dir",
42+
)
43+
p!!.resolve("fixtures/generator-registry-conformance/registry.json")
44+
}
45+
46+
private data class ManifestEntry(val name: String, val tier: String, val ports: Set<String>)
47+
48+
private fun loadManifest(): List<ManifestEntry> {
49+
val root: JsonNode = ObjectMapper().readTree(Files.readString(manifestPath))
50+
val generators = root.get("generators")
51+
return generators.fieldNames().asSequence().map { name ->
52+
val node = generators.get(name)
53+
ManifestEntry(
54+
name = name,
55+
tier = node.get("tier").asText(),
56+
ports = node.get("ports").map { it.asText() }.toSet(),
57+
)
58+
}.toList()
59+
}
60+
61+
/** (1)+(2): the manifest's kotlin slice equals the Kotlin registry's stable names. */
62+
@Test
63+
fun `registry stable names equal the manifest kotlin slice`() {
64+
val manifest = loadManifest()
65+
66+
val expected = manifest.filter { port in it.ports }.map { it.name }.toSortedSet()
67+
val actual = GENERATOR_REGISTRY.keys.toSortedSet()
68+
69+
val missing = (expected - actual).sorted()
70+
val extras = (actual - expected).sorted()
71+
72+
assertTrue(
73+
missing.isEmpty() && extras.isEmpty(),
74+
"Kotlin generator registry disagrees with the canonical manifest's `$port` slice " +
75+
"(fixtures/generator-registry-conformance/registry.json).\n" +
76+
" MISSING (manifest says `$port` exposes it, but the registry does not): $missing\n" +
77+
" EXTRA (registry exposes it, but the manifest omits `$port` for it): $extras\n" +
78+
"Reconcile by editing the manifest AND the registry together — do not force one to match.",
79+
)
80+
}
81+
82+
/** (3): every native manifest name is NATIVE in the Kotlin registry. */
83+
@Test
84+
fun `tiers agree with the manifest`() {
85+
val manifest = loadManifest()
86+
87+
val tierMismatches = manifest
88+
.filter { port in it.ports }
89+
.mapNotNull { entry ->
90+
val reg = GENERATOR_REGISTRY[entry.name] ?: return@mapNotNull null
91+
val expectedTier = when (entry.tier) {
92+
"native" -> GeneratorTier.NATIVE
93+
"neutral" -> GeneratorTier.NEUTRAL
94+
else -> null
95+
}
96+
if (expectedTier != null && reg.tier != expectedTier) {
97+
"${entry.name}: manifest tier=`${entry.tier}` but registry tier=`${reg.tier}`"
98+
} else null
99+
}
100+
101+
assertTrue(
102+
tierMismatches.isEmpty(),
103+
"Kotlin generator registry tier disagrees with the canonical manifest:\n " +
104+
tierMismatches.joinToString("\n "),
105+
)
106+
}
107+
108+
/** Sanity: every registered factory constructs a generator without throwing (powers `--list`). */
109+
@Test
110+
fun `every registered factory constructs without throwing`() {
111+
for ((name, info) in GENERATOR_REGISTRY) {
112+
val gen = info.factory()
113+
assertTrue(true, "factory for `$name` produced ${gen::class.simpleName}")
114+
}
115+
}
116+
117+
/** list() returns every entry, native-first then neutral, alphabetical within tier. */
118+
@Test
119+
fun `list returns all entries native-first alphabetical within tier`() {
120+
val listed = list()
121+
assertEquals(GENERATOR_REGISTRY.size, listed.size, "list() must return every entry")
122+
assertEquals(
123+
GENERATOR_REGISTRY.keys.toSortedSet(),
124+
listed.map { it.name }.toSortedSet(),
125+
"list() names must equal the registry keys",
126+
)
127+
}
128+
}

0 commit comments

Comments
 (0)