args = new HashMap<>();
+ args.put("outputDir", tmp.getRoot().getAbsolutePath());
+ args.put("outputFilename", "old.html");
+ generator.setArgs(args);
+ MetaDataLoader loader = new MetaDataLoader(com.metaobjects.loader.LoaderOptions.create(false, false, true),
+ MetaDataLoader.SUBTYPE_MANUAL, "htmlHookCompat");
+ loader.init();
+
+ generator.execute(loader);
+
+ assertEquals("the old hook ran once", 1, generator.hookCalls);
+ assertTrue(new java.io.File(tmp.getRoot(), "old.html").length() > 0);
+ }
+}
diff --git a/server/java/codegen-base/src/test/java/com/metaobjects/generator/direct/object/JavaCodegenWriteGuardTest.java b/server/java/codegen-base/src/test/java/com/metaobjects/generator/direct/object/JavaCodegenWriteGuardTest.java
index f65355b25..0a846cb41 100644
--- a/server/java/codegen-base/src/test/java/com/metaobjects/generator/direct/object/JavaCodegenWriteGuardTest.java
+++ b/server/java/codegen-base/src/test/java/com/metaobjects/generator/direct/object/JavaCodegenWriteGuardTest.java
@@ -37,7 +37,7 @@
*
* Deliberately NOT covered here, because they cannot carry a marker and guarding them
* would freeze them after the first run: the {@code META-INF/services} registration (its
- * whole content is a provider FQN), {@code TemplateScopeGenerator}, {@code DocsMojo}, and
+ * whole content is a provider FQN), {@code TemplateScopeGenerator}, the {@code api-docs} generators, and
* {@code MustacheTemplateGenerator}. See {@code GeneratedFileWriter}'s class javadoc.
*/
public class JavaCodegenWriteGuardTest {
diff --git a/server/java/codegen-base/src/test/java/com/metaobjects/generator/direct/object/StandaloneExtractorGeneratorTest.java b/server/java/codegen-base/src/test/java/com/metaobjects/generator/direct/object/StandaloneExtractorGeneratorTest.java
index 8a8ab7696..6feae1c2c 100644
--- a/server/java/codegen-base/src/test/java/com/metaobjects/generator/direct/object/StandaloneExtractorGeneratorTest.java
+++ b/server/java/codegen-base/src/test/java/com/metaobjects/generator/direct/object/StandaloneExtractorGeneratorTest.java
@@ -1,6 +1,5 @@
package com.metaobjects.generator.direct.object;
-import com.metaobjects.generator.EmitsObjectExtractors;
import com.metaobjects.generator.Generator;
import com.metaobjects.generator.GeneratorException;
import com.metaobjects.generator.direct.object.javacode.ExtractorCodeGenerator;
@@ -30,13 +29,13 @@
import static org.junit.Assert.fail;
/**
- * The {@code extractor} generator wired on its own.
+ * The {@code extractor} generator: the one thing that writes a {@code Extractor}.
*
- * It used to exist only as a step inside {@code entity}: no {@code } entry
- * could name it, so it could not be owned or drift-checked apart from {@code entity}. These
- * tests hold the three things that make it a generator in its own right: it writes the same
- * files {@code entity} writes, {@code entity} can be told to leave them to it, and with
- * nothing told, {@code entity} still writes them as it always has.
+ * Through 8.0 the files were a step inside {@code entity}, written for a pom that declared
+ * no such thing. ADR-0034 Amendment 9 rules out output no {@code } entry declares,
+ * so these tests hold both halves: {@code entity} writes none, and this generator writes one
+ * per concrete object {@code entity} wrote a class for, named by {@code entity}'s naming args
+ * and narrowed by its filters.
*/
public class StandaloneExtractorGeneratorTest {
@@ -103,46 +102,57 @@ private static Map extractors(Path root) throws IOException {
return out;
}
- @Test
- public void entityStillWritesTheExtractorsWhenNothingElseDoes() throws Exception {
- Path out = tmp.newFolder("fused").toPath();
- run(new JavaObjectCodeGenerator(), entityArgs(out, "pojoAware"), loadMeta());
-
- assertEquals(List.of("acme/shapes/CircleExtractor.java", "acme/shapes/DrawingExtractor.java"),
- List.copyOf(extractors(out).keySet()));
+ /** {@code entity} and {@code extractor} over one output directory, as a pom lists them. */
+ private Path entityAndExtractor(String dir, String flavor, Map naming, List filters)
+ throws Exception {
+ Path out = tmp.newFolder(dir).toPath();
+ JavaObjectCodeGenerator entity = new JavaObjectCodeGenerator();
+ ExtractorCodeGenerator extractor = new ExtractorCodeGenerator();
+ entity.setFilters(filters);
+ extractor.setFilters(filters);
+ Map entityArgs = entityArgs(out, flavor);
+ entityArgs.putAll(naming);
+ Map extractorArgs = new HashMap<>(naming);
+ extractorArgs.put("outputDir", out.toString());
+ MetaDataLoader loader = loadMeta();
+ run(entity, entityArgs, loader);
+ run(extractor, extractorArgs, loader);
+ return out;
}
@Test
- public void entityLeavesTheExtractorsAloneWhenToldASeparateGeneratorWritesThem() throws Exception {
- Path out = tmp.newFolder("split").toPath();
- Map args = entityArgs(out, "pojoAware");
- args.put(EmitsObjectExtractors.ARG_EMIT_EXTRACTORS, "false");
- run(new JavaObjectCodeGenerator(), args, loadMeta());
-
- assertTrue("entity wrote extractors it was told to leave: " + extractors(out).keySet(),
- extractors(out).isEmpty());
- // The rest of entity's output is untouched by the arg.
- assertTrue(Files.exists(out.resolve("acme/shapes/Circle.java")));
- assertTrue(Files.exists(out.resolve(
- "com/metaobjects/generated/GeneratedObjectClassBindingProvider.java")));
+ public void entityWritesNoExtractorInEitherConcreteFlavor() throws Exception {
+ for (String flavor : List.of("pojoAware", "valueObject")) {
+ Path out = tmp.newFolder("entity-" + flavor).toPath();
+ run(new JavaObjectCodeGenerator(), entityArgs(out, flavor), loadMeta());
+ ObjectClassRegistry.resetGlobal();
+
+ assertTrue(flavor + ": entity wrote an extractor no entry declared: "
+ + extractors(out).keySet(), extractors(out).isEmpty());
+ // The rest of entity's output is what it was.
+ assertTrue(Files.exists(out.resolve("acme/shapes/Circle.java")));
+ assertTrue(Files.exists(out.resolve(
+ "com/metaobjects/generated/GeneratedObjectClassBindingProvider.java")));
+ }
}
@Test
- public void onItsOwnItWritesTheSameFilesEntityWrites() throws Exception {
- Path fused = tmp.newFolder("fused").toPath();
- run(new JavaObjectCodeGenerator(), entityArgs(fused, "valueObject"), loadMeta());
-
- Path standalone = tmp.newFolder("standalone").toPath();
+ public void itWritesOneExtractorPerConcreteObjectAndNothingElse() throws Exception {
+ Path out = tmp.newFolder("standalone").toPath();
Map args = new HashMap<>();
- args.put("outputDir", standalone.toString());
+ args.put("outputDir", out.toString());
run(new ExtractorCodeGenerator(), args, loadMeta());
- assertFalse(extractors(fused).isEmpty());
- assertEquals(extractors(fused), extractors(standalone));
- // And nothing but extractors: the abstract Shape gets none, and no class is written.
- try (Stream walk = Files.walk(standalone)) {
+ // The abstract Shape gets none, and no class is written.
+ assertEquals(List.of("acme/shapes/CircleExtractor.java", "acme/shapes/DrawingExtractor.java"),
+ List.copyOf(extractors(out).keySet()));
+ try (Stream walk = Files.walk(out)) {
assertEquals(2, walk.filter(Files::isRegularFile).count());
}
+ String circle = extractors(out).get("acme/shapes/CircleExtractor.java");
+ assertTrue(circle, circle.contains("package acme.shapes;"));
+ assertTrue(circle, circle.contains("public final class CircleExtractor {"));
+ assertTrue(circle, circle.contains("public static Circle extract("));
}
@Test
@@ -153,39 +163,23 @@ public void itNamesTheExtractorsByTheSameArgsEntityNamesItsClassesBy() throws Ex
naming.put("namePrefix", "My");
naming.put("nameSuffix", "Vo");
- Path fused = tmp.newFolder("fused").toPath();
- Map entity = entityArgs(fused, "pojoAware");
- entity.putAll(naming);
- run(new JavaObjectCodeGenerator(), entity, loadMeta());
-
- Path standalone = tmp.newFolder("standalone").toPath();
- Map args = new HashMap<>(naming);
- args.put("outputDir", standalone.toString());
- run(new ExtractorCodeGenerator(), args, loadMeta());
+ Path out = entityAndExtractor("named", "pojoAware", naming, List.of());
- assertTrue(extractors(standalone).containsKey(
- "com/example/acme/shapes/model/MyCircleVoExtractor.java"));
- assertEquals(extractors(fused), extractors(standalone));
+ // The extractor sits beside the class entity wrote, and casts to it.
+ assertTrue(Files.exists(out.resolve("com/example/acme/shapes/model/MyCircleVo.java")));
+ String extractor = extractors(out).get("com/example/acme/shapes/model/MyCircleVoExtractor.java");
+ assertTrue("no extractor beside MyCircleVo: " + extractors(out).keySet(), extractor != null);
+ assertTrue(extractor, extractor.contains("package com.example.acme.shapes.model;"));
+ assertTrue(extractor, extractor.contains("public static MyCircleVo extract("));
}
@Test
public void givenEntitysFiltersItWritesAnExtractorForExactlyTheClassesEntityWrites() throws Exception {
- List filters = List.of(PKG + "::Drawing");
+ Path out = entityAndExtractor("filtered", "pojoAware", Map.of(), List.of(PKG + "::Drawing"));
- Path fused = tmp.newFolder("fused").toPath();
- JavaObjectCodeGenerator entity = new JavaObjectCodeGenerator();
- entity.setFilters(filters);
- run(entity, entityArgs(fused, "pojoAware"), loadMeta());
-
- Path standalone = tmp.newFolder("standalone").toPath();
- ExtractorCodeGenerator extractor = new ExtractorCodeGenerator();
- extractor.setFilters(filters);
- Map args = new HashMap<>();
- args.put("outputDir", standalone.toString());
- run(extractor, args, loadMeta());
-
- assertEquals(List.of("acme/shapes/DrawingExtractor.java"), List.copyOf(extractors(standalone).keySet()));
- assertEquals(extractors(fused), extractors(standalone));
+ assertEquals(List.of("acme/shapes/DrawingExtractor.java"), List.copyOf(extractors(out).keySet()));
+ assertTrue(Files.exists(out.resolve("acme/shapes/Drawing.java")));
+ assertFalse(Files.exists(out.resolve("acme/shapes/Circle.java")));
}
@Test
@@ -197,27 +191,4 @@ public void itRefusesToRunWithNoOutputDirectory() {
assertTrue(e.getMessage(), e.getMessage().contains("outputDir"));
}
}
-
- @Test
- public void aRunDerivesTheArgFromTheGeneratorsInIt() {
- Map args = Map.of("outputDir", "out");
-
- // No separate extractor in the run: the arg stays unset, so entity keeps its default.
- assertFalse(EmitsObjectExtractors.deriveEmitExtractors(
- args, List.of(new JavaObjectCodeGenerator()))
- .containsKey(EmitsObjectExtractors.ARG_EMIT_EXTRACTORS));
-
- // A separate extractor in the run: entity is told to leave the files to it.
- assertEquals("false", EmitsObjectExtractors.deriveEmitExtractors(
- args, List.of(new JavaObjectCodeGenerator(), new ExtractorCodeGenerator()))
- .get(EmitsObjectExtractors.ARG_EMIT_EXTRACTORS));
-
- // An explicit "true" does not bring the second emitter back: with a separate
- // extractor in the run, entity writing them too is the duplicate.
- Map pinned = new HashMap<>(args);
- pinned.put(EmitsObjectExtractors.ARG_EMIT_EXTRACTORS, "true");
- assertEquals("false", EmitsObjectExtractors.deriveEmitExtractors(
- pinned, List.of(new JavaObjectCodeGenerator(), new ExtractorCodeGenerator()))
- .get(EmitsObjectExtractors.ARG_EMIT_EXTRACTORS));
- }
}
diff --git a/server/java/codegen-kotlin/pom.xml b/server/java/codegen-kotlin/pom.xml
index e4adc85b9..74895cc9f 100644
--- a/server/java/codegen-kotlin/pom.xml
+++ b/server/java/codegen-kotlin/pom.xml
@@ -244,6 +244,24 @@
KotlinStoredProcGenerator.kt
KotlinValidatorGenerator.kt
KotlinRequirementTestsGenerator.kt
+
+ KotlinEnumEmitter.kt
+ KotlinExtractSchemaEmitter.kt
+ KotlinOutputFormatSpecEmitter.kt
+
+
+
+
+ src/main/kotlin/com/metaobjects/generator/kotlin/apidocs
+
+ KotlinApiDocsGenerator.kt
+ KotlinApiModel.kt
+ KotlinApiModelBuilder.kt
+ KotlinApiDocsRenderer.kt
+ DocsPaths.kt
diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/Fr019SharedEnum.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/Fr019SharedEnum.kt
index 3fdedca55..bb08d1940 100644
--- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/Fr019SharedEnum.kt
+++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/Fr019SharedEnum.kt
@@ -19,6 +19,12 @@ import com.squareup.kotlinpoet.ClassName
* per-port-configured namespace (ADR-0001 — never an FQN in metadata). The namespace binds to
* the enum's declaring metadata package via a package→namespace map + a single fallback, both
* generator args every generator that types an enum reads through [ProvidedEnumConfig.fromArgs].
+ *
+ * This object writes no output. It answers "what is this enum called, and where does it live",
+ * and the type mapper, the generator utilities and the M:N support take its [ProvidedEnumConfig]
+ * as an argument, so it is the engine (ADR-0034 Amendment 8, ruling 2) and `mvn metaobjects:eject`
+ * leaves it in the package. The file an enum is written to is [KotlinEnumEmitter]'s, and that one
+ * is handed over with `entity`.
*/
object Fr019SharedEnum {
@@ -38,14 +44,14 @@ object Fr019SharedEnum {
data class ProvidedEnumConfig(
val fallbackNamespace: String?,
val packageNamespaces: Map,
- internal val providedInDeclarationPackage: Boolean = false,
+ val providedInDeclarationPackage: Boolean = false,
) {
companion object {
/**
* The config every deprecated helper form that takes none delegates with, so a
* generator ejected before [fromArgs] existed keeps the answer it had then.
*/
- internal val DECLARATION_PACKAGE: ProvidedEnumConfig =
+ val DECLARATION_PACKAGE: ProvidedEnumConfig =
ProvidedEnumConfig(null, emptyMap(), providedInDeclarationPackage = true)
/**
diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/GeneratorRegistry.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/GeneratorRegistry.kt
index 2e8431c52..bfc3c0c03 100644
--- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/GeneratorRegistry.kt
+++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/GeneratorRegistry.kt
@@ -21,6 +21,11 @@ package com.metaobjects.generator.kotlin
import com.metaobjects.generator.Generator
import com.metaobjects.generator.direct.MultiFileDirectGeneratorBase
+import com.metaobjects.generator.kotlin.apidocs.DocsPaths
+import com.metaobjects.generator.kotlin.apidocs.KotlinApiDocsGenerator
+import com.metaobjects.generator.kotlin.apidocs.KotlinApiDocsRenderer
+import com.metaobjects.generator.kotlin.apidocs.KotlinApiModel
+import com.metaobjects.generator.kotlin.apidocs.KotlinApiModelBuilder
/** Tier of a registered generator (ADR-0020 / ADR-0021 D1). */
enum class GeneratorTier {
@@ -72,7 +77,7 @@ enum class GeneratorLayer {
* at run time, never via the constructor), so calling it must NOT throw —
* `--list` relies on that.
*/
-data class GeneratorInfo(
+data class GeneratorInfo @JvmOverloads constructor(
/** Stable, cross-port-consistent id. Equals the registry map key. */
val name: String,
/** One-line (no newline) human description for `--list`. */
@@ -94,6 +99,14 @@ data class GeneratorInfo(
* `docs/superpowers/specs/2026-09-22-eject-in-every-port-design.md` (JVM section).
*/
val ejectResourcePath: String? = null,
+ /**
+ * Fully-qualified names of the classes that hold the rest of this generator's emit, copied
+ * by `mvn metaobjects:eject` into the same owned package as the generator: the emitters it
+ * writes through. Each is shipped as `.kt`. A class two
+ * generators share is copied once. The engine (naming, type mapping, model walks, the
+ * generator base) is not listed: it stays in the package (ADR-0034 Amendment 8).
+ */
+ val ejectWith: List = emptyList(),
)
/** Classpath root every ejectable Kotlin generator's reference source is shipped under. */
@@ -102,6 +115,22 @@ const val EJECT_RESOURCE_ROOT: String = "META-INF/metaobjects/reference/kotlin/"
/** `/.kt` for an ejectable generator. */
private fun ejectPath(simpleClassName: String): String = EJECT_RESOURCE_ROOT + simpleClassName + ".kt"
+/** The enum file writer `entity` calls. */
+private val ENUM_EMIT: List = listOf(KotlinEnumEmitter::class.java.name)
+
+/** The rest of `api-docs`: the page IR (one file, `KotlinApiModel.kt`, which also holds the unit, symbol, field
+ * and kind types), the builder that fills it, the renderer and the page paths. */
+private val API_DOCS_EMIT: List = listOf(
+ KotlinApiModel::class.java.name, KotlinApiModelBuilder::class.java.name,
+ KotlinApiDocsRenderer::class.java.name, DocsPaths::class.java.name,
+)
+
+/** The extraction-mirror writer `output-parser` calls, and the prompt-format-spec writer `output-prompt`
+ * calls (which delegates its literals to the mirror writer). */
+private val PARSER_EMIT: List = listOf(KotlinExtractSchemaEmitter::class.java.name)
+private val PROMPT_EMIT: List =
+ listOf(KotlinOutputFormatSpecEmitter::class.java.name, KotlinExtractSchemaEmitter::class.java.name)
+
/**
* The stable-name generator registry. Registers every Kotlin generator that
* exists so all are discoverable (`--list`) and (in a follow-on) selectable by
@@ -119,6 +148,7 @@ val GENERATOR_REGISTRY: Map = linkedMapOf(
layer = GeneratorLayer.MODEL,
factory = ::KotlinEntityGenerator,
ejectResourcePath = ejectPath("KotlinEntityGenerator"),
+ ejectWith = ENUM_EMIT,
),
"routes" to GeneratorInfo(
name = "routes",
@@ -147,6 +177,7 @@ val GENERATOR_REGISTRY: Map = linkedMapOf(
layer = GeneratorLayer.CAPABILITY,
factory = ::KotlinOutputParserGenerator,
ejectResourcePath = ejectPath("KotlinOutputParserGenerator"),
+ ejectWith = PARSER_EMIT,
),
"output-prompt" to GeneratorInfo(
name = "output-prompt",
@@ -155,6 +186,7 @@ val GENERATOR_REGISTRY: Map = linkedMapOf(
layer = GeneratorLayer.CAPABILITY,
factory = ::KotlinOutputPromptGenerator,
ejectResourcePath = ejectPath("KotlinOutputPromptGenerator"),
+ ejectWith = PROMPT_EMIT,
),
"render-helper" to GeneratorInfo(
name = "render-helper",
@@ -229,6 +261,15 @@ val GENERATOR_REGISTRY: Map = linkedMapOf(
factory = ::KotlinValidatorGenerator,
ejectResourcePath = ejectPath("KotlinValidatorGenerator"),
),
+ "api-docs" to GeneratorInfo(
+ name = "api-docs",
+ description = "Per-entity/template SDK API reference (generated-code API; human + agent forms).",
+ tier = GeneratorTier.NATIVE,
+ layer = GeneratorLayer.DOCS,
+ factory = ::KotlinApiDocsGenerator,
+ ejectResourcePath = ejectPath("KotlinApiDocsGenerator"),
+ ejectWith = API_DOCS_EMIT,
+ ),
"requirement-tests" to GeneratorInfo(
name = "requirement-tests",
description = "Per-package JUnit Jupiter tests, one per declared requirement, each calling a " +
diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinEnumEmitter.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinEnumEmitter.kt
index cc488b3c5..66dd8fd1e 100644
--- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinEnumEmitter.kt
+++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinEnumEmitter.kt
@@ -1,5 +1,12 @@
package com.metaobjects.generator.kotlin
+// Eject note (ADR-0034 Amendment 8): `mvn metaobjects:eject` copies this file with the generator
+// that calls it. The engine siblings stay in com.metaobjects.generator.kotlin, so each is imported
+// by name — a bare same-package reference would not resolve from the copy.
+import com.metaobjects.generator.kotlin.Fr019SharedEnum
+import com.metaobjects.generator.kotlin.KotlinPoetFileWriter
+import com.metaobjects.generator.kotlin.KotlinTypeMapper
+
import com.metaobjects.field.EnumField
import com.squareup.kotlinpoet.ClassName
import com.squareup.kotlinpoet.FileSpec
diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinExtractSchemaEmitter.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinExtractSchemaEmitter.kt
index f32395a59..ed7e4e294 100644
--- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinExtractSchemaEmitter.kt
+++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinExtractSchemaEmitter.kt
@@ -1,5 +1,14 @@
package com.metaobjects.generator.kotlin
+// Eject note (ADR-0034 Amendment 8): `mvn metaobjects:eject` copies this file with the generator
+// that calls it. The engine siblings stay in com.metaobjects.generator.kotlin, so each is imported
+// by name — a bare same-package reference would not resolve from the copy.
+import com.metaobjects.generator.kotlin.Fr019SharedEnum
+import com.metaobjects.generator.kotlin.KotlinGenUtil
+import com.metaobjects.generator.kotlin.KotlinNaming
+import com.metaobjects.generator.kotlin.KotlinTypeMapper
+import com.metaobjects.generator.kotlin.PackageMapping
+
import com.metaobjects.field.BooleanField
import com.metaobjects.field.DoubleField
import com.metaobjects.field.EnumField
diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinOutputFormatSpecEmitter.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinOutputFormatSpecEmitter.kt
index 1c5ee9272..941ed6127 100644
--- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinOutputFormatSpecEmitter.kt
+++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinOutputFormatSpecEmitter.kt
@@ -1,5 +1,11 @@
package com.metaobjects.generator.kotlin
+// Eject note (ADR-0034 Amendment 8): `mvn metaobjects:eject` copies this file with the generator
+// that calls it. The engine siblings stay in com.metaobjects.generator.kotlin, so each is imported
+// by name — a bare same-package reference would not resolve from the copy.
+import com.metaobjects.generator.kotlin.FindInbound
+import com.metaobjects.generator.kotlin.KotlinExtractSchemaEmitter
+
import com.metaobjects.field.EnumField
import com.metaobjects.field.MetaField
import com.metaobjects.field.MapField
diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/apidocs/KotlinApiDocsGenerator.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/apidocs/KotlinApiDocsGenerator.kt
new file mode 100644
index 000000000..5e47f4b4e
--- /dev/null
+++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/apidocs/KotlinApiDocsGenerator.kt
@@ -0,0 +1,77 @@
+package com.metaobjects.generator.kotlin.apidocs
+
+// Eject note (ADR-0034 Amendment 8): `mvn metaobjects:eject -Dnames=api-docs` copies this file with the
+// api reference's IR, builder, renderer and page paths. The siblings below stay in their packages as
+// engine, so each is imported by name — a bare same-package reference would not resolve from the copy.
+import com.metaobjects.generator.FileEmittingGenerator
+import com.metaobjects.generator.FileEmittingGenerator.EmittedFile
+import com.metaobjects.generator.GeneratorException
+import com.metaobjects.generator.kotlin.Fr019SharedEnum
+import com.metaobjects.loader.MetaDataLoader
+
+/**
+ * `api-docs` — the Kotlin SDK api reference (the `api/kotlin` surface of the cross-port SDK-docs
+ * contract): one page per entity, value object, projection, served report and template, an index,
+ * and the agent-facing `AGENT-API.md`.
+ *
+ * An ordinary generator: a `` entry in the list `mvn metaobjects:generate` runs, and
+ * nothing writes these pages for a pom that does not list it (ADR-0034 Amendment 9). Its files
+ * land under `outputDir` in [ARG_API_SUBDIR]; the project label, the layout and the model
+ * back-link base are the other `` below, and where a `@provided` enum is referenced from
+ * comes from the same enum args every Kotlin generator takes ([Fr019SharedEnum.ProvidedEnumConfig]).
+ * Give it an `outputDir` that holds generated files only: `mvn metaobjects:verify` compares each
+ * output directory as one tree, so a hand-written page there reads as stale.
+ */
+class KotlinApiDocsGenerator : FileEmittingGenerator() {
+
+ override fun generate(loader: MetaDataLoader): List {
+ val project = getArg(ARG_PROJECT_NAME, "project")
+ val subDir = getArg(ARG_API_SUBDIR, DEFAULT_API_SUBDIR)
+ val modelBaseUrl = if (hasArg(ARG_MODEL_BASE_URL)) getArg(ARG_MODEL_BASE_URL) else null
+ val layout = when (val value = getArg(ARG_LAYOUT, LAYOUT_FLAT)) {
+ LAYOUT_FLAT -> DocsPaths.Layout.FLAT
+ LAYOUT_PACKAGE -> DocsPaths.Layout.PACKAGE
+ // A misspelt layout would otherwise write every page somewhere its links do not point.
+ else -> throw GeneratorException(
+ "api-docs: <$ARG_LAYOUT> must be $LAYOUT_FLAT or $LAYOUT_PACKAGE (got \"$value\")")
+ }
+
+ val model = KotlinApiModelBuilder(Fr019SharedEnum.ProvidedEnumConfig.fromArgs(args)).build(loader, project)
+ val renderer = KotlinApiDocsRenderer()
+
+ val pages = mutableListOf()
+ val paths = HashSet()
+ fun add(pagePath: String, content: String) {
+ if (!paths.add(pagePath)) throw GeneratorException("api-docs: duplicate api page output path: $pagePath")
+ pages.add(EmittedFile("$subDir/$pagePath", content))
+ }
+ for (unit in model.units) {
+ val pagePath = DocsPaths.docPageOutputPath(layout, unit.pkg, unit.node)
+ val modelHref = DocsPaths.modelCrossHref("$subDir/$pagePath", pagePath, modelBaseUrl)
+ add(pagePath, renderer.renderUnitPage(unit, modelHref))
+ }
+ add("README.md", renderer.renderIndex(model, layout))
+ add("AGENT-API.md", renderer.renderAgentApi(model))
+ return pages
+ }
+
+ companion object {
+ /** Arg: the label the agent page carries in its header. Default `project`. */
+ const val ARG_PROJECT_NAME: String = "projectName"
+
+ /** Arg: where under `outputDir` the pages land. Default [DEFAULT_API_SUBDIR]. */
+ const val ARG_API_SUBDIR: String = "apiSubDir"
+
+ /** Arg: the page layout, `flat` (one file per unit, the default) or `package`. */
+ const val ARG_LAYOUT: String = "layout"
+
+ /** Arg: when set, the base URL the pages' links to the model pages are written against. */
+ const val ARG_MODEL_BASE_URL: String = "modelBaseUrl"
+
+ /** The api-surface subdirectory of the cross-port docs contract. */
+ const val DEFAULT_API_SUBDIR: String = "api/kotlin"
+
+ private const val LAYOUT_FLAT = "flat"
+ private const val LAYOUT_PACKAGE = "package"
+ }
+}
diff --git a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/codegen/kotlin/apidocs/ApiDocsCrossPortConformanceKtTest.kt b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/codegen/kotlin/apidocs/ApiDocsCrossPortConformanceKtTest.kt
index 9f5eda4fd..110795fe1 100644
--- a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/codegen/kotlin/apidocs/ApiDocsCrossPortConformanceKtTest.kt
+++ b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/codegen/kotlin/apidocs/ApiDocsCrossPortConformanceKtTest.kt
@@ -27,7 +27,7 @@ import kotlin.test.assertTrue
*
* This loads the SAME input metadata the other ports load, builds the [KotlinApiModel] via
* [KotlinApiModelBuilder], and for each manifest unit asserts — using the EXACT path/href math the
- * `metaobjects:docs` goal uses (computed via [DocsPaths], never re-derived here) — that:
+ * `api-docs` generator ([com.metaobjects.generator.kotlin.apidocs.KotlinApiDocsGenerator]) uses (computed via [DocsPaths], never re-derived here) — that:
* - `apiKotlinSubDir + "/" + docPageOutputPath(PACKAGE, pkg, node)` equals the manifest `apiKotlinPath`;
* - `modelCrossHref(apiKotlinPath, pagePath, null)` equals the manifest `apiKotlinToModel`;
* - the rendered unit page actually carries the contract back-link
diff --git a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/EjectContractTest.kt b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/EjectContractTest.kt
index 6f2e298fc..fe6958c3c 100644
--- a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/EjectContractTest.kt
+++ b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/EjectContractTest.kt
@@ -39,7 +39,8 @@ import kotlin.test.assertTrue
* entry that passes fails here, and so does a gap that is not listed.
*
* The copy is read from the shipped resource, not through the mojo: `maven-plugin` depends on
- * this module, not the reverse. The one edit eject makes to a Kotlin copy is its package line.
+ * this module, not the reverse. The two edits eject makes to a Kotlin copy are its package line and
+ * its imports of the classes copied with it, and this test makes the same two.
*/
@OptIn(org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi::class)
class EjectContractTest {
@@ -146,7 +147,10 @@ class EjectContractTest {
@Test
fun `completeness - the shipped reference sources are exactly the ones the registry names`() {
- val named = GENERATOR_REGISTRY.values.mapNotNull { it.ejectResourcePath }.toSortedSet()
+ val named = GENERATOR_REGISTRY.values.flatMap { info ->
+ // The generator, and each class eject copies with it.
+ listOfNotNull(info.ejectResourcePath) + info.ejectWith.map { withResource(it) }
+ }.toSortedSet()
assertEquals(named, shipped(EJECT_RESOURCE_ROOT))
}
@@ -293,19 +297,41 @@ class EjectContractTest {
private fun simpleName(info: GeneratorInfo): String =
info.ejectResourcePath!!.substringAfterLast('/').substringBeforeLast('.')
+ /** The shipped resource of a class eject copies WITH a generator. */
+ private fun withResource(classname: String): String = EJECT_RESOURCE_ROOT + classname.substringAfterLast('.') + ".kt"
+
+ private fun readResource(resource: String, name: String): String =
+ EjectContractTest::class.java.classLoader.getResourceAsStream(resource)
+ ?.use { String(it.readAllBytes(), Charsets.UTF_8) }
+ ?: throw AssertionError("$name: $resource is not shipped")
+
+ /**
+ * One owned file as `mvn metaobjects:eject` writes it: the package line renamed, and every
+ * import of a class copied with the generator pointed at its copy.
+ */
+ private fun owned(reference: String, name: String, copied: List): String {
+ val line = PACKAGE_LINE.find(reference) ?: throw AssertionError("$name has no package line")
+ var out = reference.replaceRange(line.range, "package $OWNED_PACKAGE")
+ for (classname in copied) {
+ out = out.replace(Regex("(?m)^import\\s+" + Regex.escape(classname) + "[ \\t]*$"),
+ "import $OWNED_PACKAGE.${classname.substringAfterLast('.')}")
+ }
+ return out
+ }
+
private val compiled = mutableMapOf()
/** The owned copy of one generator: the shipped reference source with its package line
* rewritten, compiled with nothing else beside it. Compiled once per generator. */
private fun ownedCopy(name: String): KotlinCompilation.Result = compiled.getOrPut(name) {
val info = GENERATOR_REGISTRY.getValue(name)
- val reference = EjectContractTest::class.java.classLoader.getResourceAsStream(info.ejectResourcePath!!)
- ?.use { String(it.readAllBytes(), Charsets.UTF_8) }
- ?: throw AssertionError("$name: ${info.ejectResourcePath} is not shipped")
- val line = PACKAGE_LINE.find(reference) ?: throw AssertionError("$name has no package line")
+ // The generator and what eject copies with it, and nothing else beside them.
+ val files = listOf(info.ejectResourcePath!!) + info.ejectWith.map { withResource(it) }
val result = KotlinCompilation().apply {
- sources = listOf(SourceFile.kotlin("${simpleName(info)}.kt",
- reference.replaceRange(line.range, "package $OWNED_PACKAGE")))
+ sources = files.map { resource ->
+ SourceFile.kotlin(resource.substringAfterLast('/'),
+ owned(readResource(resource, name), name, info.ejectWith))
+ }
inheritClassPath = true
messageOutputStream = java.io.OutputStream.nullOutputStream()
}.compile()
diff --git a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/EjectedGeneratorsCompileTest.kt b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/EjectedGeneratorsCompileTest.kt
index 6b8a8ea73..010d4958b 100644
--- a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/EjectedGeneratorsCompileTest.kt
+++ b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/EjectedGeneratorsCompileTest.kt
@@ -27,8 +27,11 @@ import kotlin.test.assertTrue
class EjectedGeneratorsCompileTest {
/** Every reference this module ships, read off the registry so the list cannot fall behind it. */
- private val ejectableSimpleNames = GENERATOR_REGISTRY.values.mapNotNull { it.ejectResourcePath }
- .map { it.substringAfterLast('/').substringBeforeLast('.') }
+ private val ejectableSimpleNames = GENERATOR_REGISTRY.values.flatMap { info ->
+ // The generator, and the classes eject copies with it (an emitter, a page model).
+ listOfNotNull(info.ejectResourcePath).map { it.substringAfterLast('/').substringBeforeLast('.') } +
+ info.ejectWith.map { it.substringAfterLast('.') }
+ }.distinct()
private val packageLine = Regex("(?m)^package\\s+[\\w.]+\\s*$")
@@ -43,9 +46,14 @@ class EjectedGeneratorsCompileTest {
val srcDir = Path.of("src/main/kotlin/com/metaobjects/generator/kotlin")
assertTrue(Files.isDirectory(srcDir), "expected ${srcDir.toAbsolutePath()} to exist")
+ // A generator may sit in a subpackage's directory (api-docs does); eject ships them all flat.
+ val byName = Files.walk(srcDir).use { walk -> walk.filter { it.toString().endsWith(".kt") }.toList() }
+ .associateBy { it.fileName.toString().removeSuffix(".kt") }
+
val sources = ejectableSimpleNames.map { simpleName ->
- val original = srcDir.resolve("$simpleName.kt")
- assertTrue(Files.exists(original), "expected $original to exist")
+ val original = byName[simpleName]
+ assertTrue(original != null && Files.exists(original), "expected $simpleName.kt under $srcDir")
+ original!!
val rewritten = rewritePackage(Files.readString(original), "com.acme.owned")
SourceFile.kotlin("$simpleName.kt", rewritten)
}
diff --git a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/ReportingInertTest.kt b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/ReportingInertTest.kt
index 69914f0ff..b7f68fa5e 100644
--- a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/ReportingInertTest.kt
+++ b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/ReportingInertTest.kt
@@ -111,6 +111,13 @@ class ReportingInertTest {
return if (differing.isEmpty()) null else "$label: $differing differ once reporting nodes are declared"
}
+ /**
+ * Every generator that writes code. `api-docs` writes the api reference, not code, and is
+ * held to the same inertness by [api docs gain exactly the view-backed report's page], which
+ * compares its pages unit by unit.
+ */
+ private val codeGenerators get() = GENERATOR_REGISTRY.values.filter { it.name != "api-docs" }
+
/** What a generator may add for the with-model: one file of the view-backed report, from five generators. */
private fun allowedFor(info: GeneratorInfo): Set = setOfNotNull(STORE_TOTALS_FILES[info.name])
@@ -129,7 +136,7 @@ class ReportingInertTest {
fun `only five generators emit for a report, each one file, for the view-backed one`() {
// Every generator is compared before anything is asserted, so one red run names
// every leak rather than the first.
- val leaks = GENERATOR_REGISTRY.values.mapNotNull { info ->
+ val leaks = codeGenerators.mapNotNull { info ->
sameOrLeak(info.name, emit("without", listOf(info)), emit("with", listOf(info)), allowedFor(info))
}
assertTrue(leaks.isEmpty(), leaks.joinToString("\n"))
@@ -155,7 +162,7 @@ class ReportingInertTest {
@Test
fun `a sourceless report appears in no generated file`() {
- val files = emit("with", GENERATOR_REGISTRY.values.toList())
+ val files = emit("with", codeGenerators.toList())
assertFalse(THREW in files, "the combined suite threw: ${files[THREW]}")
for (report in SOURCELESS_REPORTS) {
val hits = files.filter { (path, text) -> report in path || report in text }.keys
@@ -171,13 +178,13 @@ class ReportingInertTest {
// Each is compared above on its error message alone, which proves nothing about its
// output. Pinned by name so a generator that starts throwing cannot drop out
// silently; the list may only shrink.
- val threw = GENERATOR_REGISTRY.values.filter { THREW in emit("without", listOf(it)) }.map { it.name }.sorted()
+ val threw = codeGenerators.filter { THREW in emit("without", listOf(it)) }.map { it.name }.sorted()
assertEquals(emptyList(), threw)
}
@Test
fun `every runnable generator in one run emits the same files`() {
- val runnable = GENERATOR_REGISTRY.values.filter { THREW !in emit("without", listOf(it)) }
+ val runnable = codeGenerators.filter { THREW !in emit("without", listOf(it)) }
val expected = emit("without", runnable)
assertFalse(THREW in expected, "the combined suite threw: ${expected[THREW]}")
assertTrue(expected.size > 10, "only ${expected.size} files — the suite barely ran")
@@ -191,16 +198,21 @@ class ReportingInertTest {
* sourceless one has none.
*/
private fun apiDocs(variant: String): Map {
- val model = KotlinApiModelBuilder(com.metaobjects.generator.kotlin.Fr019SharedEnum.ProvidedEnumConfig.fromArgs(null)).build(load(variant), "shop")
- val renderer = KotlinApiDocsRenderer()
- val pages = TreeMap()
- for (unit in model.units) {
- pages[DocsPaths.docPageOutputPath(DocsPaths.Layout.PACKAGE, unit.pkg, unit.node)] =
- renderer.renderUnitPage(unit, null)
+ val out = Files.createTempDirectory("reporting-inert-docs-")
+ try {
+ val docs = GENERATOR_REGISTRY.getValue("api-docs").factory()
+ docs.setArgs(mapOf("outputDir" to out.toString(), "projectName" to "shop", "layout" to "package"))
+ docs.execute(load(variant))
+ val pages = TreeMap()
+ Files.walk(out).use { s ->
+ s.filter { it.isRegularFile() }.forEach {
+ pages[out.resolve("api/kotlin").relativize(it).toString()] = it.readText()
+ }
+ }
+ return pages
+ } finally {
+ out.toFile().deleteRecursively()
}
- pages["README.md"] = renderer.renderIndex(model, DocsPaths.Layout.PACKAGE)
- pages["AGENT-API.md"] = renderer.renderAgentApi(model)
- return pages
}
@Test
diff --git a/server/java/codegen-plantuml/pom.xml b/server/java/codegen-plantuml/pom.xml
index e068e0896..a8c093950 100644
--- a/server/java/codegen-plantuml/pom.xml
+++ b/server/java/codegen-plantuml/pom.xml
@@ -43,6 +43,36 @@
+
+
+ org.apache.maven.plugins
+ maven-resources-plugin
+
+
+ ship-eject-reference-source
+ generate-resources
+
+ copy-resources
+
+
+ ${project.build.outputDirectory}/META-INF/metaobjects/reference/java
+
+
+ src/main/java/com/metaobjects/generator/direct/plantuml
+
+ PlantUMLGenerator.java
+ PlantUMLWriter.java
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/server/java/codegen-spring/pom.xml b/server/java/codegen-spring/pom.xml
index f86a59298..b8303d80d 100644
--- a/server/java/codegen-spring/pom.xml
+++ b/server/java/codegen-spring/pom.xml
@@ -205,6 +205,50 @@
LlmTraceHelperGenerator.java
SpringNamesGenerator.java
JUnitRequirementTestsGenerator.java
+
+ Fr019SharedEnum.java
+ SpringRecordBuilder.java
+ OutputFormatSpecEmitter.java
+
+
+
+
+ src/main/java/com/metaobjects/generator/apidocs
+
+ JavaApiDocsGenerator.java
+ ApiSymbol.java
+ ApiSymbolKind.java
+ ApiUnit.java
+ ClasspathTemplateProvider.java
+ DocsPaths.java
+ FieldShape.java
+ JavaApiDocsRenderer.java
+ JavaApiModel.java
+ JavaApiModelBuilder.java
+ JavaFieldShapes.java
+ UnitExample.java
+
+
+
+
+
+
+
+ ship-eject-template-source
+ generate-resources
+
+ copy-resources
+
+
+ ${project.build.outputDirectory}/META-INF/metaobjects/reference/java/templates/api
+
+
+ src/main/resources/templates/api
+
+ *.md.mustache
diff --git a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/GeneratorRegistry.java b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/GeneratorRegistry.java
index 8a9c056ac..09e23d176 100644
--- a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/GeneratorRegistry.java
+++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/GeneratorRegistry.java
@@ -1,8 +1,23 @@
package com.metaobjects.generator;
+import com.metaobjects.generator.apidocs.ApiSymbol;
+import com.metaobjects.generator.apidocs.ApiSymbolKind;
+import com.metaobjects.generator.apidocs.ApiUnit;
+import com.metaobjects.generator.apidocs.ClasspathTemplateProvider;
+import com.metaobjects.generator.apidocs.DocsPaths;
+import com.metaobjects.generator.apidocs.FieldShape;
+import com.metaobjects.generator.apidocs.JavaApiDocsGenerator;
+import com.metaobjects.generator.apidocs.JavaApiDocsRenderer;
+import com.metaobjects.generator.apidocs.JavaApiModel;
+import com.metaobjects.generator.apidocs.JavaApiModelBuilder;
+import com.metaobjects.generator.apidocs.JavaFieldShapes;
+import com.metaobjects.generator.apidocs.UnitExample;
import com.metaobjects.generator.direct.metadata.ai.MetaDataAIDocumentationGenerator;
+import com.metaobjects.generator.direct.metadata.ai.MetaDataAIDocumentationWriter;
import com.metaobjects.generator.direct.metadata.file.json.MetaDataFileJsonSchemaGenerator;
+import com.metaobjects.generator.direct.metadata.file.json.MetaDataFileSchemaWriter;
import com.metaobjects.generator.direct.metadata.html.MetaDataHtmlDocumentationGenerator;
+import com.metaobjects.generator.direct.metadata.html.MetaDataHtmlDocumentationWriter;
import com.metaobjects.generator.direct.object.BaseObjectCodeGenerator;
import com.metaobjects.generator.direct.object.BaseObjectCodeWriter;
import com.metaobjects.generator.direct.object.javacode.ExtractorCodeGenerator;
@@ -11,14 +26,17 @@
import com.metaobjects.generator.direct.object.javacode.JavaObjectCodeGenerator;
import com.metaobjects.generator.direct.object.javacode.PojoAwareCodeWriter;
import com.metaobjects.generator.direct.object.javacode.ValueObjectCodeWriter;
+import com.metaobjects.generator.spring.Fr019SharedEnum;
import com.metaobjects.generator.spring.JUnitRequirementTestsGenerator;
import com.metaobjects.generator.spring.LlmTraceHelperGenerator;
+import com.metaobjects.generator.spring.OutputFormatSpecEmitter;
import com.metaobjects.generator.spring.SpringControllerGenerator;
import com.metaobjects.generator.spring.SpringDtoGenerator;
import com.metaobjects.generator.spring.SpringFilterAllowlistGenerator;
import com.metaobjects.generator.spring.SpringNamesGenerator;
import com.metaobjects.generator.spring.SpringOutputParserGenerator;
import com.metaobjects.generator.spring.SpringOutputPromptGenerator;
+import com.metaobjects.generator.spring.SpringRecordBuilder;
import com.metaobjects.generator.spring.SpringRenderHelperGenerator;
import com.metaobjects.generator.spring.SpringRepositoryGenerator;
import com.metaobjects.generator.spring.SpringValueObjectGenerator;
@@ -104,6 +122,7 @@ public static final class GeneratorInfo {
private final List ejectRuntime;
private final String ejectNote;
private final List ejectWith;
+ private final List ejectResources;
public GeneratorInfo(String stableName, String classname, String description,
Tier tier, Layer layer) {
@@ -152,6 +171,23 @@ public GeneratorInfo(String stableName, String classname, String description,
public GeneratorInfo(String stableName, String classname, String description,
Tier tier, Layer layer, String ejectResourcePath,
List ejectRuntime, String ejectNote, List ejectWith) {
+ this(stableName, classname, description, tier, layer, ejectResourcePath,
+ ejectRuntime, ejectNote, ejectWith, List.of());
+ }
+
+ /**
+ * @param ejectResources the template references this GENERATOR's emit reads from the
+ * classpath, each the path under {@link #TEMPLATE_RESOURCE_PREFIX} without its
+ * {@code .md.mustache} extension (e.g. {@code api/index}). {@code mvn
+ * metaobjects:eject} copies each into the adopter's module, under the owned
+ * package's path, and points the owned template provider there, so the page text
+ * is the adopter's too. Each is shipped at {@link #EJECT_TEMPLATE_RESOURCE_ROOT}.
+ */
+ public GeneratorInfo(String stableName, String classname, String description,
+ Tier tier, Layer layer, String ejectResourcePath,
+ List ejectRuntime, String ejectNote, List ejectWith,
+ List ejectResources) {
+ this.ejectResources = List.copyOf(ejectResources);
this.stableName = stableName;
this.classname = classname;
this.description = description;
@@ -212,6 +248,12 @@ public List ejectWith() {
return ejectWith;
}
+ /** Template references copied with this generator into the adopter's resources (see
+ * the constructor). */
+ public List ejectResources() {
+ return ejectResources;
+ }
+
@Override
public String toString() {
return "GeneratorInfo{" + stableName + " -> " + classname
@@ -237,11 +279,35 @@ public String toString() {
BaseObjectCodeWriter.class.getName(), JavaCodeWriter.class.getName(),
PojoAwareCodeWriter.class.getName(), ValueObjectCodeWriter.class.getName());
+ /** The shared and externally-provided enum writer, and the builder a generated record
+ * carries: emit code the DTO and value-object generators each call, so each takes a copy. */
+ private static final List RECORD_EMIT = List.of(
+ Fr019SharedEnum.class.getName(), SpringRecordBuilder.class.getName());
+
+ /** The enum writer the output parser calls. */
+ private static final List ENUM_EMIT = List.of(Fr019SharedEnum.class.getName());
+
+ /** The rest of {@code api-docs}: the page IR, the builder that fills it, the renderer, the page
+ * paths and the template provider. {@code GeneratorApplicability} is the engine. */
+ private static final List API_DOCS_EMIT = List.of(
+ ApiSymbol.class.getName(), ApiSymbolKind.class.getName(), ApiUnit.class.getName(),
+ ClasspathTemplateProvider.class.getName(), DocsPaths.class.getName(), FieldShape.class.getName(),
+ JavaApiDocsRenderer.class.getName(), JavaApiModel.class.getName(),
+ JavaApiModelBuilder.class.getName(), JavaFieldShapes.class.getName(), UnitExample.class.getName());
+
+ /** The page text of {@code api-docs}, read from the classpath. */
+ private static final List API_DOCS_TEMPLATES = List.of("api/entity-api", "api/index", "api/agent-api");
+
+ /** The prompt-tier writer behind {@code output-prompt}: the format-spec literal. */
+ private static final List PROMPT_EMIT = List.of(OutputFormatSpecEmitter.class.getName());
+
/** The two generators that live in modules this one does not depend on
* ({@code codegen-plantuml}, {@code codegen-mustache}), so they are named, not
* referenced. {@code GeneratorRegistryConformanceTest} loads every registered class. */
private static final String PLANTUML_GENERATOR =
"com.metaobjects.generator.direct.plantuml.PlantUMLGenerator";
+ private static final String PLANTUML_WRITER =
+ "com.metaobjects.generator.direct.plantuml.PlantUMLWriter";
private static final String MUSTACHE_GENERATOR =
"com.metaobjects.generator.mustache.MustacheTemplateGenerator";
@@ -271,6 +337,14 @@ public String toString() {
* imports them from. */
public static final String SUPPORT_PACKAGE = "com.metaobjects.generator.spring";
+ /** The classpath prefix the packaged api-docs templates are read from, and an owned copy's
+ * template provider is pointed away from (see {@link GeneratorInfo#ejectResources()}). */
+ public static final String TEMPLATE_RESOURCE_PREFIX = "templates/";
+
+ /** Classpath root the template sources are shipped under, one {@code [.md.mustache} per
+ * reference, for {@code mvn metaobjects:eject} to copy. */
+ public static final String EJECT_TEMPLATE_RESOURCE_ROOT = EJECT_RESOURCE_ROOT + "templates/";
+
/** Classpath root the support sources are shipped under, one {@code .java}
* per class, for {@code mvn metaobjects:eject} to copy. */
public static final String EJECT_SUPPORT_RESOURCE_ROOT = EJECT_RESOURCE_ROOT + "support/";
@@ -301,32 +375,29 @@ private static Map buildRegistry() {
+ "com.metaobjects.object.extract, so the consuming module needs a "
+ "`metaobjects-om` dependency — without it the generated parser does not "
+ "compile and nothing in the build says why.]", Tier.NATIVE, Layer.CAPABILITY,
- ejectPath(SpringOutputParserGenerator.class));
+ ejectPath(SpringOutputParserGenerator.class), List.of(), null, ENUM_EMIT);
register(m, "output-prompt", SpringOutputPromptGenerator.class.getName(),
"Per-template output-format prompt fragment generator.", Tier.NATIVE, Layer.CAPABILITY,
- ejectPath(SpringOutputPromptGenerator.class));
+ ejectPath(SpringOutputPromptGenerator.class), List.of(), null, PROMPT_EMIT);
register(m, "render-helper", SpringRenderHelperGenerator.class.getName(),
"Per-renderable-template render helper (typed wrappers; document/email for a template.output, the document shape for a template.prompt).", Tier.NATIVE, Layer.CAPABILITY,
ejectPath(SpringRenderHelperGenerator.class));
- // "extractor" is a generator of its own AND a step "entity" runs when no separate
- // one is in the run (EmitsObjectExtractors), so an existing pom that wires only
- // "entity" emits what it always did. The note is what eject prints: unlike every
- // other generator there may be no entry to change, only one to add.
+ // "extractor" is the one thing that writes the Extractor classes. Through 8.0
+ // "entity" wrote them for a pom that listed no extractor; a file nothing declares is
+ // what ADR-0034 Amendment 9 rules out. The note is what eject prints: a pom carried
+ // over from 8.0 may have no entry to change, only one to add.
register(m, "extractor", ExtractorCodeGenerator.class.getName(),
"Per-object typed Extractor: tolerant recovery of the typed, flavored object "
- + "graph from dirty model text; throws only when a @required field is lost. `entity` "
- + "writes these itself with a concrete flavor, unless this generator is in "
- + " too.", Tier.NATIVE, Layer.CAPABILITY,
+ + "graph from dirty model text; throws only when a @required field is lost. Needs "
+ + "`entity` with a concrete flavor in the same run.", Tier.NATIVE, Layer.CAPABILITY,
ejectPath(ExtractorCodeGenerator.class), List.of(),
- "extractor: `entity` writes the Extractor classes itself unless a separate "
- + "extractor generator is in . If yours has no entry for one, add "
+ "extractor: only this generator writes the Extractor classes; `entity` does "
+ + "not. If your has no entry for it, add "
+ "{owned} with the same outputDir, "
+ "pkgPrefix, pkgSuffix, namePrefix and nameSuffix args, and the same , as "
+ "your entity generator: an extractor for an object entity wrote no class for does "
- + "not compile. `entity` then leaves those files to it. A pom with more than "
- + "one entity entry needs one extractor entry per entity entry, each with that "
- + "entry's outputDir, naming args and : a single extractor generator in "
- + "the run stops every entity entry writing its extractors.",
+ + "not compile. A pom with more than one entity entry needs one extractor entry "
+ + "per entity entry, each with that entry's outputDir, naming args and .",
OBJECT_NAMING);
// "template" stays registered but is NOT ejectable: it is already a generic,
// declaratively-configured primitive (Mustache template + output pattern) with no
@@ -343,12 +414,12 @@ private static Map buildRegistry() {
ejectPath(SpringRepositoryGenerator.class), List.of("FilterPredicate"));
register(m, "dto", SpringDtoGenerator.class.getName(),
"Per-entity Spring DTO record.", Tier.NATIVE, Layer.MODEL,
- ejectPath(SpringDtoGenerator.class), List.of("PatchValidationException"));
+ ejectPath(SpringDtoGenerator.class), List.of("PatchValidationException"), null, RECORD_EMIT);
register(m, "value-object", SpringValueObjectGenerator.class.getName(),
"Per-value-object Spring record with jakarta constraints — the typed "
+ "component the DTO/Patch bind for a field.object @storage:jsonb column, "
+ "and a template's payload/response type.", Tier.NATIVE, Layer.MODEL,
- ejectPath(SpringValueObjectGenerator.class));
+ ejectPath(SpringValueObjectGenerator.class), List.of(), null, RECORD_EMIT);
register(m, "trace-helper", LlmTraceHelperGenerator.class.getName(),
"Per-entity typed record LLM-trace helper (extract + buildLlmCallRow + persist; "
+ "LlmCallBase-derived entities only).", Tier.NATIVE, Layer.CAPABILITY,
@@ -363,20 +434,31 @@ private static Map buildRegistry() {
+ "for a hand-written consumer to reference instead of a string literal.", Tier.NATIVE, Layer.MODEL,
ejectPath(SpringNamesGenerator.class));
// The generators below were wirable by class name and in no registry, so no catalog,
- // eject command or test saw them (ADR-0034 Amendment 8). They are registered so the
- // eject contract covers them. None ships eject source yet; "mustache" never will: it
- // is deprecated in favour of "template".
+ // eject command or test saw them (ADR-0034 Amendment 8). Each is a generator and the writer
+ // that emits its file; eject copies both. "mustache" never ejects: it is deprecated in
+ // favour of "template".
+ register(m, "api-docs", JavaApiDocsGenerator.class.getName(),
+ "Per-entity/template SDK API reference (generated-code API; human + agent forms).",
+ Tier.NATIVE, Layer.DOCS, ejectPath(JavaApiDocsGenerator.class), List.of(), null,
+ API_DOCS_EMIT, API_DOCS_TEMPLATES);
register(m, "plantuml", PLANTUML_GENERATOR,
- "PlantUML class diagram of the object model.", Tier.NATIVE, Layer.DOCS);
+ "PlantUML class diagram of the object model.", Tier.NATIVE, Layer.DOCS,
+ EJECT_RESOURCE_ROOT + "PlantUMLGenerator.java", List.of(), null, List.of(PLANTUML_WRITER));
register(m, "html-docs", MetaDataHtmlDocumentationGenerator.class.getName(),
"Single-page HTML reference of the registered metamodel types, read from the "
- + "type registry.", Tier.NATIVE, Layer.DOCS);
+ + "type registry.", Tier.NATIVE, Layer.DOCS,
+ ejectPath(MetaDataHtmlDocumentationGenerator.class), List.of(), null,
+ List.of(MetaDataHtmlDocumentationWriter.class.getName()));
register(m, "ai-docs", MetaDataAIDocumentationGenerator.class.getName(),
"JSON reference of the registered metamodel types written for an AI reader, "
- + "read from the type registry.", Tier.NATIVE, Layer.DOCS);
+ + "read from the type registry.", Tier.NATIVE, Layer.DOCS,
+ ejectPath(MetaDataAIDocumentationGenerator.class), List.of(), null,
+ List.of(MetaDataAIDocumentationWriter.class.getName()));
register(m, "json-schema", MetaDataFileJsonSchemaGenerator.class.getName(),
"JSON Schema that validates metadata files, read from the type registry.",
- Tier.NATIVE, Layer.CAPABILITY);
+ Tier.NATIVE, Layer.CAPABILITY,
+ ejectPath(MetaDataFileJsonSchemaGenerator.class), List.of(), null,
+ List.of(MetaDataFileSchemaWriter.class.getName()));
register(m, "mustache", MUSTACHE_GENERATOR,
"The pre-`template` Mustache generator, kept so a pom that names its class keeps "
+ "building. Deprecated: use `template`.", Tier.NATIVE, Layer.CAPABILITY);
@@ -405,8 +487,16 @@ private static void register(Map m, String stableName,
String classname, String description, Tier tier, Layer layer,
String ejectResourcePath, List ejectRuntime,
String ejectNote, List ejectWith) {
+ register(m, stableName, classname, description, tier, layer, ejectResourcePath,
+ ejectRuntime, ejectNote, ejectWith, List.of());
+ }
+
+ private static void register(Map m, String stableName,
+ String classname, String description, Tier tier, Layer layer,
+ String ejectResourcePath, List ejectRuntime,
+ String ejectNote, List ejectWith, List ejectResources) {
if (m.put(stableName, new GeneratorInfo(stableName, classname, description, tier, layer,
- ejectResourcePath, ejectRuntime, ejectNote, ejectWith)) != null) {
+ ejectResourcePath, ejectRuntime, ejectNote, ejectWith, ejectResources)) != null) {
throw new IllegalStateException("duplicate generator stable name: " + stableName);
}
}
diff --git a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/apidocs/GeneratorApplicability.java b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/apidocs/GeneratorApplicability.java
new file mode 100644
index 000000000..2009bdd71
--- /dev/null
+++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/apidocs/GeneratorApplicability.java
@@ -0,0 +1,55 @@
+package com.metaobjects.generator.apidocs;
+
+import com.metaobjects.generator.spring.LlmTraceHelperGenerator;
+import com.metaobjects.generator.spring.SpringControllerGenerator;
+import com.metaobjects.generator.spring.SpringDtoGenerator;
+import com.metaobjects.generator.spring.SpringFilterAllowlistGenerator;
+import com.metaobjects.generator.spring.SpringOutputParserGenerator;
+import com.metaobjects.generator.spring.SpringOutputPromptGenerator;
+import com.metaobjects.generator.spring.SpringRenderHelperGenerator;
+import com.metaobjects.loader.MetaDataLoader;
+import com.metaobjects.MetaData;
+import com.metaobjects.object.MetaObject;
+
+import java.util.function.BiPredicate;
+import java.util.function.Predicate;
+
+/**
+ * What the api reference asks of the generators it documents: would you emit for this?
+ *
+ * ]The reference documents the code the OTHER generators emit, and it decides what to document
+ * by asking each of them the static {@code appliesTo} every one of those generators exposes. The
+ * docs generator does not name those generators. It reads the answers from here, so the docs
+ * generator, packaged or ejected, depends on nothing but this engine surface, and the reference
+ * describes what the packaged generators emit for, including in a project that owns a copy of one
+ * of them. {@link #packaged()} carries the packaged generators' own predicates.
+ *
+ * @param dto {@code dto}: the object's DTO record
+ * @param routes {@code routes}: the object's controller
+ * @param filterAllowlist {@code filter-allowlist}: the object's filter allowlist
+ * @param traceHelper {@code trace-helper}: the object's LLM-trace helper
+ * @param renderHelper {@code render-helper}: the template's render helper
+ * @param outputPrompt {@code output-prompt}: the template's response-format fragment
+ * @param outputParser {@code output-parser}: the template's reply parser
+ */
+public record GeneratorApplicability(
+ Predicate dto,
+ Predicate routes,
+ Predicate filterAllowlist,
+ Predicate traceHelper,
+ BiPredicate renderHelper,
+ BiPredicate outputPrompt,
+ BiPredicate outputParser) {
+
+ /** The packaged generators' own predicates. */
+ public static GeneratorApplicability packaged() {
+ return new GeneratorApplicability(
+ SpringDtoGenerator::appliesTo,
+ SpringControllerGenerator::appliesTo,
+ SpringFilterAllowlistGenerator::appliesTo,
+ LlmTraceHelperGenerator::appliesTo,
+ SpringRenderHelperGenerator::appliesTo,
+ SpringOutputPromptGenerator::appliesTo,
+ SpringOutputParserGenerator::appliesTo);
+ }
+}
diff --git a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/apidocs/JavaApiDocsGenerator.java b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/apidocs/JavaApiDocsGenerator.java
new file mode 100644
index 000000000..ef7f2764c
--- /dev/null
+++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/apidocs/JavaApiDocsGenerator.java
@@ -0,0 +1,95 @@
+package com.metaobjects.generator.apidocs;
+
+// Eject note (ADR-0034 Amendment 8): `mvn metaobjects:eject -Dnames=api-docs` copies this file with the
+// api reference's IR, builder, renderer and templates. The siblings below stay in their packages as
+// engine, so each is imported by name — a bare same-package reference would not resolve from the copy.
+import com.metaobjects.generator.FileEmittingGenerator;
+import com.metaobjects.generator.GeneratorException;
+import com.metaobjects.generator.apidocs.GeneratorApplicability;
+import com.metaobjects.loader.MetaDataLoader;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * {@code api-docs} — the Java SDK api reference (the {@code api/java} surface of the cross-port
+ * SDK-docs contract): one page per entity, value object, projection, served report and template,
+ * an index, and the agent-facing {@code AGENT-API.md}.
+ *
+ * An ordinary generator: a {@code } entry in the list {@code mvn
+ * metaobjects:generate} runs, and nothing writes these pages for a pom that does not list it
+ * (ADR-0034 Amendment 9). Its files land under {@code outputDir} in {@link #ARG_API_SUBDIR}; the
+ * project label, the layout and the model back-link base are the other {@code } below.
+ * Give it an {@code outputDir} that holds generated files only: {@code mvn metaobjects:verify}
+ * compares each output directory as one tree, so a hand-written page there reads as stale.
+ *
+ * What each documented generator emits for comes from {@link #applicability}, so this
+ * generator names no emitting generator. The default is the packaged generators' answers. A
+ * project that owns a copy of one of them changes that one line of its copy of this class to ask
+ * its own.
+ */
+public final class JavaApiDocsGenerator extends FileEmittingGenerator {
+
+ /** Arg: the label the agent page carries in its header. Default {@code project}. */
+ public static final String ARG_PROJECT_NAME = "projectName";
+
+ /** Arg: where under {@code outputDir} the pages land. Default {@link #DEFAULT_API_SUBDIR}. */
+ public static final String ARG_API_SUBDIR = "apiSubDir";
+
+ /** Arg: the page layout, {@code flat} (one file per unit, the default) or {@code package}. */
+ public static final String ARG_LAYOUT = "layout";
+
+ /** Arg: when set, the base URL the pages' links to the model pages are written against. */
+ public static final String ARG_MODEL_BASE_URL = "modelBaseUrl";
+
+ /** The api-surface subdirectory of the cross-port docs contract. */
+ public static final String DEFAULT_API_SUBDIR = "api/java";
+
+ private static final String LAYOUT_FLAT = "flat";
+ private static final String LAYOUT_PACKAGE = "package";
+
+ /** What each documented generator emits for. Replace it to describe an owned copy. */
+ private final GeneratorApplicability applicability = GeneratorApplicability.packaged();
+
+ @Override
+ protected List generate(MetaDataLoader loader) {
+ String project = getArg(ARG_PROJECT_NAME, "project");
+ String subDir = getArg(ARG_API_SUBDIR, DEFAULT_API_SUBDIR);
+ String modelBaseUrl = hasArg(ARG_MODEL_BASE_URL) ? getArg(ARG_MODEL_BASE_URL) : null;
+ DocsPaths.Layout layout = layoutOf(getArg(ARG_LAYOUT, LAYOUT_FLAT));
+
+ JavaApiModel model = new JavaApiModelBuilder(applicability).build(loader, project);
+ JavaApiDocsRenderer renderer = new JavaApiDocsRenderer();
+
+ List pages = new ArrayList<>();
+ Set paths = new HashSet<>();
+ for (ApiUnit unit : model.units()) {
+ String pagePath = DocsPaths.docPageOutputPath(layout, unit.pkg(), unit.node());
+ String modelHref = DocsPaths.modelCrossHref(subDir + "/" + pagePath, pagePath, modelBaseUrl);
+ add(pages, paths, subDir, pagePath, renderer.renderUnitPage(unit, modelHref));
+ }
+ add(pages, paths, subDir, "README.md", renderer.renderIndex(model, layout));
+ add(pages, paths, subDir, "AGENT-API.md", renderer.renderAgentApi(model));
+ return pages;
+ }
+
+ /** The layout a {@link #ARG_LAYOUT} value names. A value that names none is refused: a
+ * misspelt layout would otherwise write every page somewhere its links do not point. */
+ private static DocsPaths.Layout layoutOf(String value) {
+ if (LAYOUT_FLAT.equals(value)) return DocsPaths.Layout.FLAT;
+ if (LAYOUT_PACKAGE.equals(value)) return DocsPaths.Layout.PACKAGE;
+ throw new GeneratorException("api-docs: <" + ARG_LAYOUT + "> must be " + LAYOUT_FLAT + " or "
+ + LAYOUT_PACKAGE + " (got \"" + value + "\")");
+ }
+
+ /** Add one page, failing on a path two units resolve to. */
+ private static void add(List pages, Set paths, String subDir, String pagePath,
+ String content) {
+ if (!paths.add(pagePath)) {
+ throw new GeneratorException("api-docs: duplicate api page output path: " + pagePath);
+ }
+ pages.add(new EmittedFile(subDir + "/" + pagePath, content));
+ }
+}
diff --git a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/apidocs/JavaApiModelBuilder.java b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/apidocs/JavaApiModelBuilder.java
index ea5b7c156..9bc854ebd 100644
--- a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/apidocs/JavaApiModelBuilder.java
+++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/apidocs/JavaApiModelBuilder.java
@@ -1,15 +1,11 @@
package com.metaobjects.generator.apidocs;
+// Eject note (ADR-0034 Amendment 8): `mvn metaobjects:eject` copies this file with the rest of the
+// api reference. The siblings below stay in their packages as engine, so each is imported by name.
+import com.metaobjects.generator.apidocs.GeneratorApplicability;
import com.metaobjects.generator.spring.FindInbound;
-import com.metaobjects.generator.spring.LlmTraceHelperGenerator;
-import com.metaobjects.generator.spring.SpringControllerGenerator;
-import com.metaobjects.generator.spring.SpringDtoGenerator;
-import com.metaobjects.generator.spring.SpringFilterAllowlistGenerator;
import com.metaobjects.generator.spring.SpringM2mSupport;
import com.metaobjects.generator.spring.SpringNaming;
-import com.metaobjects.generator.spring.SpringOutputParserGenerator;
-import com.metaobjects.generator.spring.SpringOutputPromptGenerator;
-import com.metaobjects.generator.spring.SpringRenderHelperGenerator;
import com.metaobjects.generator.spring.SpringGeneratorSupport;
import com.metaobjects.io.util.IOUtil;
import com.metaobjects.generator.util.GeneratorUtil;
@@ -82,6 +78,18 @@
*/
public final class JavaApiModelBuilder {
+ private final GeneratorApplicability applies;
+
+ /** A builder that documents what the packaged generators emit for. */
+ public JavaApiModelBuilder() {
+ this(GeneratorApplicability.packaged());
+ }
+
+ /** @param applies what each documented generator emits for; see {@link GeneratorApplicability}. */
+ public JavaApiModelBuilder(GeneratorApplicability applies) {
+ this.applies = applies;
+ }
+
/** Build the SDK-surface model for {@code project} from the loaded {@code loader}. */
public JavaApiModel build(MetaDataLoader loader, String project) {
List units = new ArrayList<>();
@@ -140,7 +148,7 @@ shortName, ApiSymbolKind.MODEL, fqn(javaPkg, shortName),
// DTO — the wire shape. Emitted for any concrete object.entity OR concrete
// object.projection (a projection is a read-only wire model). The inclusion
// decision is delegated to the generator's appliesTo so documented == generated.
- if (SpringDtoGenerator.appliesTo(obj)) {
+ if (applies.dto().test(obj)) {
String dto = SpringNaming.dtoName(shortName);
List dtoFields = JavaFieldShapes.dtoFields(obj);
symbols.add(symbolWithFields(
@@ -183,12 +191,12 @@ repo, ApiSymbolKind.DATA_ACCESS, fqn(javaPkg, repo),
}
// REST — one symbol per verb+path the controller registers.
- if (SpringControllerGenerator.appliesTo(obj)) {
+ if (applies.routes().test(obj)) {
addRestSymbols(symbols, obj, javaPkg, shortName, loader);
}
// FILTER — the per-entity sort/filter allowlist.
- if (SpringFilterAllowlistGenerator.appliesTo(obj)) {
+ if (applies.filterAllowlist().test(obj)) {
String filter = SpringNaming.filterAllowlistName(shortName);
symbols.add(symbol(
filter, ApiSymbolKind.FILTER, fqn(javaPkg, filter),
@@ -198,7 +206,7 @@ filter, ApiSymbolKind.FILTER, fqn(javaPkg, filter),
// TRACE — the LLM-trace persistence helper (only when a prompt with
// @responseRef is present, per the generator's appliesTo).
- if (LlmTraceHelperGenerator.appliesTo(obj)) {
+ if (applies.traceHelper().test(obj)) {
String trace = SpringNaming.traceHelperName(shortName);
symbols.add(symbol(
trace, ApiSymbolKind.TRACE, fqn(javaPkg, trace),
@@ -306,7 +314,7 @@ private ApiUnit buildTemplateUnit(MetaTemplate tmpl, MetaDataLoader loader) {
"the typed payload value object the template renders",
JavaFieldShapes.payloadFieldsOf(payloadVo, loader)));
}
- if (SpringRenderHelperGenerator.appliesTo(tmpl, loader)) {
+ if (applies.renderHelper().test(tmpl, loader)) {
String render = SpringNaming.renderHelperName(shortName);
// The render helper's return type keys off @kind: email → EmailDocument,
// document (the default) → String. Read the own attr the same way the
@@ -319,14 +327,14 @@ render, ApiSymbolKind.RENDER, fqn(promptsPkg, render),
renderReturns));
}
// ADR-0052: the inbound symbols belong to the RESPONDING PROMPT, never to an output.
- if (SpringOutputPromptGenerator.appliesTo(tmpl, loader)) {
+ if (applies.outputPrompt().test(tmpl, loader)) {
String prompt = SpringNaming.responseFormatName(shortName);
symbols.add(symbol(
prompt, ApiSymbolKind.PROMPT, fqn(promptsPkg, prompt),
"final class " + prompt,
"builds the response-format prompt fragment"));
}
- if (SpringOutputParserGenerator.appliesTo(tmpl, loader)) {
+ if (applies.outputParser().test(tmpl, loader)) {
String parser = SpringNaming.parserName(shortName);
symbols.add(symbol(
parser, ApiSymbolKind.OUTPUT_PARSER, fqn(promptsPkg, parser),
diff --git a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/Fr019SharedEnum.java b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/Fr019SharedEnum.java
index f84d91a83..48d7abd9f 100644
--- a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/Fr019SharedEnum.java
+++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/Fr019SharedEnum.java
@@ -1,10 +1,15 @@
package com.metaobjects.generator.spring;
+// Eject note (ADR-0034 Amendment 8): `mvn metaobjects:eject` copies this file with the generators
+// that call it. The naming and type-mapping siblings stay in com.metaobjects.generator.spring, so
+// they are imported by name — a bare same-package reference would not resolve from the copy.
import com.metaobjects.field.EnumField;
import com.metaobjects.field.MetaField;
import com.metaobjects.generator.GeneratorException;
import com.metaobjects.generator.util.GeneratedFileWriter;
import com.metaobjects.generator.util.GeneratorUtil;
+import com.metaobjects.generator.spring.SpringNaming;
+import com.metaobjects.generator.spring.SpringTypeMapper;
import com.metaobjects.loader.MetaDataLoader;
import com.metaobjects.object.MetaObject;
diff --git a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/OutputFormatSpecEmitter.java b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/OutputFormatSpecEmitter.java
index 41380cc82..5bcc6d341 100644
--- a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/OutputFormatSpecEmitter.java
+++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/OutputFormatSpecEmitter.java
@@ -1,5 +1,9 @@
package com.metaobjects.generator.spring;
+// Eject note (ADR-0034 Amendment 8): `mvn metaobjects:eject` copies this file with the
+// output-prompt generator. FindInbound stays in com.metaobjects.generator.spring as engine, so it
+// is imported by name — a bare same-package reference would not resolve from the copy.
+import com.metaobjects.generator.spring.FindInbound;
import com.metaobjects.field.BooleanField;
import com.metaobjects.field.DoubleField;
import com.metaobjects.field.EnumField;
diff --git a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/apidocs/ApiDocsCrossPortConformanceTest.java b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/apidocs/ApiDocsCrossPortConformanceTest.java
index 70371040e..50b7334a6 100644
--- a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/apidocs/ApiDocsCrossPortConformanceTest.java
+++ b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/apidocs/ApiDocsCrossPortConformanceTest.java
@@ -32,7 +32,7 @@
* This test is the JAVA half. It loads the SAME input metadata
* ({@code fixtures/conformance/api-docs-cross-port/input/meta.json}) the TS side loads,
* builds the {@link JavaApiModel}, and for each manifest unit asserts — using the EXACT
- * path/href math the {@code metaobjects:docs} goal ({@code DocsMojo}) uses, computed via
+ * path/href math the {@code api-docs} generator ({@code JavaApiDocsGenerator}) uses, computed via
* {@link DocsPaths}, never re-derived here — that:
*
* - {@code apiSubDir + "/" + docPageOutputPath(PACKAGE, pkg, node)} equals the
@@ -118,7 +118,7 @@ public void javaApiJavaPathsAndHrefsMatchTheSharedManifest() throws Exception {
manifestNodes, javaNodes);
// ---- per-unit: api/java path + model back-href match the manifest EXACTLY ----
- // Path/href math mirrors DocsMojo with layout=package, apiSubDir="api/java",
+ // Path/href math mirrors JavaApiDocsGenerator with layout=package, apiSubDir="api/java",
// modelBaseUrl=null — every value from DocsPaths, nothing re-derived here.
JavaApiDocsRenderer renderer = new JavaApiDocsRenderer();
List checked = new ArrayList<>();
diff --git a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/CodegenCompileConformanceTest.java b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/CodegenCompileConformanceTest.java
index 1c3ecc3d0..922fed388 100644
--- a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/CodegenCompileConformanceTest.java
+++ b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/CodegenCompileConformanceTest.java
@@ -1,6 +1,5 @@
package com.metaobjects.generator.spring;
-import com.metaobjects.generator.EmitsObjectExtractors;
import com.metaobjects.generator.direct.object.javacode.ExtractorCodeGenerator;
import com.metaobjects.generator.direct.object.javacode.JavaObjectCodeGenerator;
import com.metaobjects.generator.Generator;
@@ -184,17 +183,14 @@ public void theJavaModelTierCompiles() throws Exception {
}
/**
- * The same model tier with the extractors written by the {@code extractor} generator
- * instead of by {@code entity}. One emitter per file is what a pom holding both gets
- * (the Maven plugin derives the arg), so it is what is compiled here: a standalone
- * extractor that named a class {@code entity} did not emit would fail this compile.
+ * The same model tier with the {@code extractor} generator listed beside {@code entity},
+ * which is the only way the extractors are written: an extractor that named a class
+ * {@code entity} did not emit would fail this compile.
*/
@Test
- public void theJavaModelTierCompilesWithTheExtractorsFromTheirOwnGenerator() throws Exception {
- Map entityOnly = new LinkedHashMap<>(AS_CLASS);
- entityOnly.put(EmitsObjectExtractors.ARG_EMIT_EXTRACTORS, "false");
+ public void theJavaModelTierCompilesWithTheExtractorGeneratorBesideIt() throws Exception {
Map> selection = new LinkedHashMap<>();
- selection.put("entity", entityOnly);
+ selection.put("entity", AS_CLASS);
selection.put("extractor", PLAIN);
selection.put("names", PLAIN);
generateAndCompile("model-split", selection, 17, "ProgramExtractor");
diff --git a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/GeneratedExtractorCompileRunTest.java b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/GeneratedExtractorCompileRunTest.java
index fbd01b2fc..2727bb845 100644
--- a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/GeneratedExtractorCompileRunTest.java
+++ b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/GeneratedExtractorCompileRunTest.java
@@ -1,5 +1,6 @@
package com.metaobjects.generator.spring;
+import com.metaobjects.generator.direct.object.javacode.ExtractorCodeGenerator;
import com.metaobjects.generator.direct.object.javacode.JavaObjectCodeGenerator;
import com.metaobjects.loader.InMemoryStringSource;
import com.metaobjects.loader.LoaderOptions;
@@ -136,7 +137,8 @@ private void runFlavor(String flavor, String loaderName) throws Exception {
MetaObject answerMo = loader.getMetaObjectByName(ANSWER_FQN);
assertNotNull("Answer MetaObject must load", answerMo);
- // 1. Generate flavored classes + provider + Extractor.
+ // 1. Generate flavored classes + provider (`entity`) and the Extractor (`extractor`),
+ // the two entries a pom lists, over one output directory.
Path gen = tmp.newFolder("gen-" + flavor).toPath();
Map args = new HashMap<>();
args.put("outputDir", gen.toString());
@@ -147,6 +149,10 @@ private void runFlavor(String flavor, String loaderName) throws Exception {
generator.setArgs(args);
generator.execute(loader);
+ ExtractorCodeGenerator extractorGenerator = new ExtractorCodeGenerator();
+ extractorGenerator.setArgs(Map.of("outputDir", gen.toString()));
+ extractorGenerator.execute(loader);
+
// The Extractor must have been emitted alongside the flavored class.
File extractorSrc = gen.resolve("acme/payload/AnswerExtractor.java").toFile();
assertTrue("AnswerExtractor.java must be emitted for flavor=" + flavor + " at " + extractorSrc,
diff --git a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/NoMagicPhysicalNamesTest.java b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/NoMagicPhysicalNamesTest.java
index 7a669645c..e30031d40 100644
--- a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/NoMagicPhysicalNamesTest.java
+++ b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/NoMagicPhysicalNamesTest.java
@@ -1,6 +1,5 @@
package com.metaobjects.generator.spring;
-import com.metaobjects.generator.EmitsObjectExtractors;
import com.metaobjects.generator.EmitsPhysicalNameConstants;
import com.metaobjects.generator.Generator;
import com.metaobjects.generator.GeneratorRegistry;
@@ -461,10 +460,8 @@ private Map generate() throws Exception {
new TreeSet<>(GeneratorRegistryConformanceTest.NOT_WIRABLE), notGenerators);
// Derive `useNames` from the suite through the SHIPPED helper — the identical call
// AbstractMetaDataMojo.buildGenerators makes. Re-implementing it here would leave
- // the gate measuring the test's own logic. The same goes for who writes the
- // extractors: the registry holds both `entity` and `extractor`, as a pom may.
- Map runArgs = EmitsObjectExtractors.deriveEmitExtractors(
- EmitsPhysicalNameConstants.deriveUseNames(args, suite), suite);
+ // the gate measuring the test's own logic.
+ Map runArgs = EmitsPhysicalNameConstants.deriveUseNames(args, suite);
for (Generator g : suite) {
Map own = new HashMap<>(runArgs);
// A generator that writes ONE file is told its name; a per-object generator
diff --git a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/OwnedPathNeverReachesPackagedSupportTest.java b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/OwnedPathNeverReachesPackagedSupportTest.java
index 702aa58bb..085cf79d5 100644
--- a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/OwnedPathNeverReachesPackagedSupportTest.java
+++ b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/OwnedPathNeverReachesPackagedSupportTest.java
@@ -70,11 +70,25 @@ private Set ejectableGenerators() {
return out;
}
+ /** The classes eject copies WITH an ejectable generator of this module ({@code ejectWith}): they
+ * are owned beside it, so reaching one is not reaching packaged code. */
+ private Set copiedWithGenerators() {
+ Set out = new TreeSet<>();
+ for (GeneratorInfo info : GeneratorRegistry.list().values()) {
+ if (info.ejectResourcePath() == null || !Files.exists(classFile(info.classname()))) continue;
+ for (String with : info.ejectWith()) {
+ if (Files.exists(classFile(with))) out.add(with);
+ }
+ }
+ return out;
+ }
+
@Test
public void noPackagedClassAnOwnedGeneratorReachesRefersBackToTheSupportClass() throws IOException {
Set support = support();
Set owned = new TreeSet<>(support);
owned.addAll(ejectableGenerators());
+ owned.addAll(copiedWithGenerators());
Deque queue = new ArrayDeque<>(owned);
Set seen = new HashSet<>(owned);
diff --git a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/ReportingInertTest.java b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/ReportingInertTest.java
index 9bd9e6970..955404283 100644
--- a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/ReportingInertTest.java
+++ b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/ReportingInertTest.java
@@ -44,7 +44,7 @@
*
Since Plan 3 a report that declares a read-only {@code source.rdb @kind: view} is
* SERVED: the REST-surface generators emit its read-only files and nothing else changes.
* {@code StoreTotals} is that report, so {@code with/} differs from {@code without/} in
- * exactly {@link #SERVED_REPORT_FILES} (one file per generator) and one api-docs unit.
+ * exactly {@link #SERVED_REPORT_FILES} (one file per generator, the api-docs page among them).
* {@code DailyRevenue}, {@code ProgramEngagement} and {@code ProgramCatalogue} (which declares
* {@code @spine} and lists a dimension reached by {@code @via}) declare no source and stay
* inert, as does a measure {@code @default} ({@code avgDaysPerStarter}).
@@ -72,7 +72,17 @@ public class ReportingInertTest extends SharedRegistryTestBase {
"dto", "acme/shop/StoreTotalsDto.java",
"repository", "acme/shop/StoreTotalsRepository.java",
"filter-allowlist", "acme/shop/StoreTotalsFilterAllowlist.java",
- "routes", "acme/shop/StoreTotalsController.java");
+ "routes", "acme/shop/StoreTotalsController.java",
+ // The api reference documents the served report as one more unit.
+ "api-docs", "api/java/StoreTotals.md");
+
+ /**
+ * The files that LIST every unit rather than describe one, by generator stable name: the api
+ * reference's index and agent page. They differ between the two models by the served report's
+ * entry, so each is checked to name it and then left out of the byte comparison.
+ */
+ private static final Map> UNIT_LISTINGS = Map.of(
+ "api-docs", List.of("api/java/README.md", "api/java/AGENT-API.md"));
private static Path model(String variant) {
return SpringTestFixtures.findCorpusRoot().getParent()
@@ -178,8 +188,17 @@ public void everyGeneratorEmitsTheSameFilesButTheServedReportsOwn() throws Excep
String extra = SERVED_REPORT_FILES.get(info.stableName());
if (extra != null) seen.add(info.stableName());
try {
- assertSameBut(info.stableName(), emit("without", List.of(info)), emit("with", List.of(info)),
- extra == null ? List.of() : List.of(extra));
+ Map without = emit("without", List.of(info));
+ Map with = emit("with", List.of(info));
+ for (String listing : UNIT_LISTINGS.getOrDefault(info.stableName(), List.of())) {
+ assertTrue(info.stableName() + ": " + listing + " does not name the served report",
+ with.getOrDefault(listing, "").contains("StoreTotals"));
+ assertFalse(info.stableName() + ": " + listing + " names the report without it",
+ without.getOrDefault(listing, "").contains("StoreTotals"));
+ without.remove(listing);
+ with.remove(listing);
+ }
+ assertSameBut(info.stableName(), without, with, extra == null ? List.of() : List.of(extra));
} catch (AssertionError e) {
leaks.add(e.getMessage());
}
@@ -238,7 +257,7 @@ public void theModelAndWebTiersInOneRunEmitTheSameFiles() throws Exception {
}
/**
- * The api docs surface ({@code mvn metaobjects:docs}): every unit page, the index and the
+ * The api docs surface (the {@code api-docs} generator): every unit page, the index and the
* agent page.
*/
private Map apiDocs(String variant) throws Exception {
diff --git a/server/java/maven-plugin/pom.xml b/server/java/maven-plugin/pom.xml
index dfe7ad280..b6f8aa5e4 100644
--- a/server/java/maven-plugin/pom.xml
+++ b/server/java/maven-plugin/pom.xml
@@ -75,9 +75,9 @@
metaobjects-codegen-plantuml
${project.version}
-
+
com.metaobjects
metaobjects-codegen-spring
@@ -154,6 +154,15 @@
3.0.2
test
+
+
+ com.github.tschuchortdev
+ kotlin-compile-testing
+ 1.6.0
+ test
+
org.apache.derby
derby
diff --git a/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/AbstractMetaDataMojo.java b/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/AbstractMetaDataMojo.java
index 96a6f3339..edce9e7fb 100644
--- a/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/AbstractMetaDataMojo.java
+++ b/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/AbstractMetaDataMojo.java
@@ -14,7 +14,6 @@
import com.metaobjects.loader.LoaderConfigurable;
import com.metaobjects.loader.LoaderConfigurationConstants;
import com.metaobjects.loader.LoaderOptions;
-import com.metaobjects.generator.EmitsObjectExtractors;
import com.metaobjects.generator.EmitsPhysicalNameConstants;
import com.metaobjects.loader.MetaDataLoader;
import org.apache.maven.plugin.AbstractMojo;
@@ -109,12 +108,47 @@ public void setLax(boolean lax) {
this.lax = lax;
}
+ // The parameters of the `docs` goal removed in 8.1.0 (ADR-0034 Amendment 9), which nothing
+ // reads. They are declared so that a pom still setting one is refused by name: Maven drops
+ // an element no goal declares without a word, and the build would then write no
+ // documentation and say nothing. (The goal's fifth parameter, , is this
+ // class's own build directory and cannot be told apart.)
+ @Parameter(property = "metaobjects.docs.language")
+ private String language;
+ @Parameter(property = "metaobjects.docs.apiSubDir")
+ private String apiSubDir;
+ @Parameter(property = "metaobjects.docs.layout")
+ private String layout;
+ @Parameter(property = "metaobjects.docs.modelBaseUrl")
+ private String modelBaseUrl;
+
+ /**
+ * Refuse a pom that still carries the removed {@code docs} goal's configuration. ADR-0034
+ * Amendment 9 fixes the sentence for every port.
+ *
+ * @param goal the goal refusing, as the adopter typed it ({@code generate}, {@code verify})
+ */
+ protected void refuseRemovedDocsConfiguration(String goal) throws MojoFailureException {
+ List set = new ArrayList<>();
+ if (language != null) set.add("");
+ if (apiSubDir != null) set.add("");
+ if (layout != null) set.add("");
+ if (modelBaseUrl != null) set.add("");
+ if (set.isEmpty()) return;
+ throw new MojoFailureException(
+ "metaobjects:" + goal + ": found 1.0 docs configuration (" + String.join(", ", set)
+ + " in the plugin's ). Nothing reads it any more, so this run would "
+ + "write no documentation. Declare each docs generator as a entry in the "
+ + "plugin's . See " + DocsMojo.MIGRATION + ".");
+ }
+
public void execute() throws MojoExecutionException, MojoFailureException
{
// #233: warm the global registry singletons before this reactor module's load
// can race a sibling module's first-init under `mvn -T`. Must precede
// createLoader(), which triggers MavenLoaderConfiguration's eager
// getTypeRegistry().getRegisteredTypes() first-touch on this thread.
+ refuseRemovedDocsConfiguration( execution != null ? execution.getGoal() : "generate" );
com.metaobjects.registry.RegistryBootstrap.warmUpDefaults();
if ( getLoader() == null ) {
throw new MojoExecutionException( "No element was defined");
@@ -200,16 +234,9 @@ protected List buildGenerators(ClassLoader projectClassLoader,
// aggregation point that makes it safe to default from the run instead: the same
// decision TypeScript makes from its `emitsNames` generator marker and C# from
// GeneratorRegistry.IncludesNames. An explicit `` in the pom still wins.
- //
- // The same aggregation answers a second question for the Java `entity` generator: is
- // a separate `extractor` generator in this run? `entity` writes the Extractor
- // classes itself unless one is, so a run holding both has one emitter per file
- // rather than two that agree until the owned one is edited.
for ( int i = 0; i < generatorImpls.size(); i++ ) {
- Map derived =
- EmitsPhysicalNameConstants.deriveUseNames( argsByGenerator.get(i), generatorImpls );
generatorImpls.get(i).setArgs(
- EmitsObjectExtractors.deriveEmitExtractors( derived, generatorImpls ) );
+ EmitsPhysicalNameConstants.deriveUseNames( argsByGenerator.get(i), generatorImpls ) );
}
return generatorImpls;
diff --git a/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/DocsMojo.java b/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/DocsMojo.java
index 91ee95df7..814499982 100644
--- a/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/DocsMojo.java
+++ b/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/DocsMojo.java
@@ -1,218 +1,36 @@
package com.metaobjects.mojo;
-import com.metaobjects.generator.Generator;
-import com.metaobjects.loader.MetaDataLoader;
-import org.apache.maven.plugin.MojoExecutionException;
+import org.apache.maven.plugin.AbstractMojo;
import org.apache.maven.plugin.MojoFailureException;
import org.apache.maven.plugins.annotations.LifecyclePhase;
import org.apache.maven.plugins.annotations.Mojo;
-import org.apache.maven.plugins.annotations.Parameter;
-import org.apache.maven.plugins.annotations.ResolutionScope;
-
-import java.io.IOException;
-import java.io.UncheckedIOException;
-import java.nio.charset.StandardCharsets;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.util.LinkedHashMap;
-import java.util.List;
-import java.util.Locale;
-import java.util.Map;
/**
- * {@code metaobjects:docs} — emit the generated SDK api surface (the SP-1 {@code apiSurfaces}
- * surface) into a configurable subdir, for one or more languages.
- *
- * Extends {@link AbstractMetaDataMojo} so it inherits metadata loading from the
- * {@code } config: the base {@link AbstractMetaDataMojo#execute()} builds the
- * {@link MetaDataLoader} and then calls the {@link #executeGenerators(MetaDataLoader, List)}
- * hook. This goal needs only the loaded loader, not configured {@code }, so it
- * ignores the (empty) generator list and instead builds + renders the api-docs IR.
+ * {@code metaobjects:docs} — removed in 8.1.0 (ADR-0034 Amendment 9). The goal stays only to
+ * say so: a pom that still binds it fails with the line below, where a goal that was simply
+ * gone would fail with Maven's "could not find goal", which names neither what replaced it
+ * nor where the change is written down. It keeps the 8.0 goal's default phase: an execution
+ * that binds {@code docs} with no {@code } is scheduled through that phase,
+ * and without one Maven would skip the goal and the build would pass with no documentation.
*
- * The {@link #language} parameter selects the native SDK surface:
- *
- * - {@code java} (default) — the {@code codegen-spring} Java surface ({@code api/java});
- * - {@code kotlin} — the {@code codegen-kotlin} Kotlin surface ({@code api/kotlin});
- * - {@code java,kotlin} — both surfaces, each into its own subdir.
- *
- * Each language's surface defaults its own {@code apiSubDir} ({@code api/}); set
- * {@link #apiSubDir} explicitly only when documenting a single language into a custom dir.
- *
- * The Mojo is wiring only: every name comes from the per-language IR and every path from that
- * port's {@code DocsPaths} (byte-parity with the TS contract) — nothing is re-derived here. Output
- * paths are collision-checked (duplicate page path → build failure), mirroring the TS
- * {@code assertNoDuplicateDocPaths}.
+ *
Documentation is generated the way code is: each docs generator is a {@code }
+ * entry in the list {@code metaobjects:generate} runs, with its options in {@code }.
*/
-@Mojo(name = "docs",
- requiresDependencyResolution = ResolutionScope.COMPILE_PLUS_RUNTIME,
- defaultPhase = LifecyclePhase.GENERATE_RESOURCES,
- threadSafe = true) // #233
-public class DocsMojo extends AbstractMetaDataMojo {
-
- private static final String LANG_JAVA = "java";
- private static final String LANG_KOTLIN = "kotlin";
-
- /** Root output dir for docs; each surface lands under its {@code apiSubDir}. */
- @Parameter(property = "metaobjects.docs.outputDirectory",
- defaultValue = "${project.build.directory}/docs")
- private java.io.File outputDirectory;
-
- /**
- * Which native SDK surface(s) to emit — comma-separated {@code java} / {@code kotlin}.
- * Default {@code java} (back-compat: the goal emitted only {@code api/java} before the
- * Kotlin surface landed).
- */
- @Parameter(property = "metaobjects.docs.language", defaultValue = LANG_JAVA)
- private String language;
-
- /**
- * Subdir (under {@link #outputDirectory}) for the api surface. Unset → each language uses its
- * own default ({@code api/}). Set this only when emitting a single language to a custom
- * dir; with multiple languages an explicit value would collide and is rejected.
- */
- @Parameter(property = "metaobjects.docs.apiSubDir")
- private String apiSubDir;
+@Mojo(name = "docs", defaultPhase = LifecyclePhase.GENERATE_RESOURCES, threadSafe = true)
+public class DocsMojo extends AbstractMojo {
- /** Doc-page layout: {@code flat} (one file per unit) or {@code package} (foldered). */
- @Parameter(property = "metaobjects.docs.layout", defaultValue = "flat")
- private String layout;
+ /** Where the change to a 1.0 pom is written down, before and after. */
+ static final String MIGRATION =
+ "docs/features/migrations/docs-are-generators.md, section \"Java and Kotlin\"";
- /** Optional base URL for federated model docs; when unset, model links are relative. */
- @Parameter(property = "metaobjects.docs.modelBaseUrl")
- private String modelBaseUrl;
+ /** What this goal prints. ADR-0034 Amendment 9 fixes the sentence for every port. */
+ static final String REMOVED =
+ "metaobjects:docs was removed in 8.1.0. `mvn metaobjects:generate` writes documentation "
+ + "now: declare each docs generator as a entry in the plugin's . "
+ + "See " + MIGRATION + ".";
@Override
- public void execute() throws MojoExecutionException, MojoFailureException {
- // Inherit loader construction + the executeGenerators hook from the base.
- super.execute();
- }
-
- @Override
- protected void executeGenerators(MetaDataLoader loader, List generatorImpls) {
- // Default to java when unset (the MojoRule test harness does not apply @Parameter
- // defaultValue; a real Maven run does — guard both paths).
- String langSpec = (language == null || language.trim().isEmpty()) ? LANG_JAVA : language;
- String[] langs = langSpec.split(",");
- boolean multi = langs.length > 1;
- if (multi && apiSubDir != null && !apiSubDir.isEmpty()) {
- throw new IllegalStateException(
- "metaobjects:docs — an explicit cannot be shared across multiple "
- + "languages (" + language + "); omit it so each uses api/");
- }
-
- String projectName = project != null ? project.getArtifactId() : "project";
- boolean packageLayout = "package".equalsIgnoreCase(layout);
-
- for (String raw : langs) {
- String lang = raw.trim().toLowerCase(Locale.ROOT);
- if (lang.isEmpty()) {
- continue;
- }
- String subDir = (apiSubDir != null && !apiSubDir.isEmpty()) ? apiSubDir : ("api/" + lang);
- Map emitted;
- switch (lang) {
- case LANG_JAVA:
- emitted = renderJava(loader, projectName, subDir, packageLayout);
- break;
- case LANG_KOTLIN:
- emitted = renderKotlin(loader, projectName, subDir, packageLayout);
- break;
- default:
- throw new IllegalStateException(
- "metaobjects:docs — unknown language '" + lang
- + "' (supported: java, kotlin)");
- }
- writeSurface(subDir, emitted);
- }
- }
-
- // ----- Java surface (codegen-spring) -------------------------------------
-
- private Map renderJava(MetaDataLoader loader, String projectName,
- String subDir, boolean packageLayout) {
- com.metaobjects.generator.apidocs.DocsPaths.Layout lay = packageLayout
- ? com.metaobjects.generator.apidocs.DocsPaths.Layout.PACKAGE
- : com.metaobjects.generator.apidocs.DocsPaths.Layout.FLAT;
- com.metaobjects.generator.apidocs.JavaApiModel model =
- new com.metaobjects.generator.apidocs.JavaApiModelBuilder().build(loader, projectName);
- com.metaobjects.generator.apidocs.JavaApiDocsRenderer renderer =
- new com.metaobjects.generator.apidocs.JavaApiDocsRenderer();
-
- Map emitted = new LinkedHashMap<>();
- for (com.metaobjects.generator.apidocs.ApiUnit unit : model.units()) {
- String pagePath = com.metaobjects.generator.apidocs.DocsPaths.docPageOutputPath(
- lay, unit.pkg(), unit.node());
- String apiPagePathFromDocsRoot = subDir + "/" + pagePath;
- String modelHref = com.metaobjects.generator.apidocs.DocsPaths.modelCrossHref(
- apiPagePathFromDocsRoot, pagePath, modelBaseUrl);
- put(emitted, pagePath, renderer.renderUnitPage(unit, modelHref));
- }
- put(emitted, "README.md", renderer.renderIndex(model, lay));
- put(emitted, "AGENT-API.md", renderer.renderAgentApi(model));
- return emitted;
- }
-
- // ----- Kotlin surface (codegen-kotlin) -----------------------------------
-
- private Map renderKotlin(MetaDataLoader loader, String projectName,
- String subDir, boolean packageLayout) {
- com.metaobjects.generator.kotlin.apidocs.DocsPaths.Layout lay = packageLayout
- ? com.metaobjects.generator.kotlin.apidocs.DocsPaths.Layout.PACKAGE
- : com.metaobjects.generator.kotlin.apidocs.DocsPaths.Layout.FLAT;
- com.metaobjects.generator.kotlin.apidocs.KotlinApiModel model =
- new com.metaobjects.generator.kotlin.apidocs.KotlinApiModelBuilder(
- com.metaobjects.generator.kotlin.Fr019SharedEnum.ProvidedEnumConfig.fromArgs(getGlobals()))
- .build(loader, projectName);
- com.metaobjects.generator.kotlin.apidocs.KotlinApiDocsRenderer renderer =
- new com.metaobjects.generator.kotlin.apidocs.KotlinApiDocsRenderer();
-
- Map emitted = new LinkedHashMap<>();
- for (com.metaobjects.generator.kotlin.apidocs.ApiUnit unit : model.getUnits()) {
- String pagePath = com.metaobjects.generator.kotlin.apidocs.DocsPaths.INSTANCE
- .docPageOutputPath(lay, unit.getPkg(), unit.getNode());
- String apiPagePathFromDocsRoot = subDir + "/" + pagePath;
- String modelHref = com.metaobjects.generator.kotlin.apidocs.DocsPaths.INSTANCE
- .modelCrossHref(apiPagePathFromDocsRoot, pagePath, modelBaseUrl);
- put(emitted, pagePath, renderer.renderUnitPage(unit, modelHref));
- }
- put(emitted, "README.md", renderer.renderIndex(model, lay));
- put(emitted, "AGENT-API.md", renderer.renderAgentApi(model));
- return emitted;
- }
-
- // ----- write -------------------------------------------------------------
-
- /** Write a language surface's pages under {@code outputDirectory/subDir}. */
- private void writeSurface(String subDir, Map emitted) {
- Path apiRoot = outputDirectory.toPath().resolve(subDir);
- try {
- for (Map.Entry e : emitted.entrySet()) {
- Path dest = apiRoot.resolve(e.getKey());
- // NOT routed through GeneratedFileWriter, deliberately. That guard refuses
- // any existing file lacking the `GENERATED` marker, and these API pages are
- // rendered from `templates/api/*.mustache` — none of which emits the token,
- // and none of which is obliged to. Guarding them made the FIRST run write and
- // every run after silently refuse, so the docs froze while the build stayed
- // green and still reported "wrote N api pages".
- //
- // The marker floor protects output whose header WE control. Doc pages
- // rendered from a user-editable template are not that; the guard belongs on
- // the first-party generators, which were each verified to emit the token.
- Files.createDirectories(dest.getParent());
- Files.writeString(dest, e.getValue(), StandardCharsets.UTF_8);
- }
- } catch (IOException ex) {
- throw new UncheckedIOException("metaobjects:docs — failed writing api pages into " + apiRoot, ex);
- }
- getLog().info("metaobjects:docs — wrote " + emitted.size() + " api pages into " + apiRoot);
- }
-
- /** Insert into the emit map, failing on a duplicate output path (collision guard). */
- private static void put(Map emitted, String path, String content) {
- if (emitted.containsKey(path)) {
- throw new IllegalStateException(
- "metaobjects:docs — duplicate api page output path: " + path);
- }
- emitted.put(path, content);
+ public void execute() throws MojoFailureException {
+ throw new MojoFailureException(REMOVED);
}
}
diff --git a/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/EjectSupport.java b/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/EjectSupport.java
index a41064636..1caecc77a 100644
--- a/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/EjectSupport.java
+++ b/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/EjectSupport.java
@@ -68,6 +68,9 @@ public static final class Entry {
/** Fully-qualified names of the classes eject copies WITH this generator into the
* same owned package: the rest of its emit (empty for most generators). */
public final List with;
+ /** Template references the generator reads from the classpath, which eject copies into
+ * the adopter's resources (empty for every generator but {@code api-docs}). */
+ public final List resources;
public Entry(Port port, String stableName, String classname, String description, String resourcePath) {
this(port, stableName, classname, description, resourcePath, List.of());
@@ -80,13 +83,20 @@ public Entry(Port port, String stableName, String classname, String description,
public Entry(Port port, String stableName, String classname, String description,
String resourcePath, List runtime, String note, List with) {
+ this(port, stableName, classname, description, resourcePath, runtime, note, with, List.of());
+ }
+
+ public Entry(Port port, String stableName, String classname, String description,
+ String resourcePath, List runtime, String note, List with,
+ List resources) {
+ this.resources = List.copyOf(resources);
this.port = port;
this.stableName = stableName;
this.classname = classname;
this.description = description;
this.resourcePath = resourcePath;
this.runtime = List.copyOf(runtime);
- this.note = note;
+ this.note = API_DOCS.equals(stableName) ? API_DOCS_NOTE : note;
this.with = List.copyOf(with);
}
@@ -102,14 +112,34 @@ public List sources() {
/** Classpath resource holding the reference source of {@code source}, one of
* {@link #sources()}. */
public String resourceOf(String source) {
- return source.equals(classname) ? resourcePath
- : GeneratorRegistry.EJECT_RESOURCE_ROOT + simpleNameOf(source) + ".java";
+ if (source.equals(classname)) return resourcePath;
+ String root = port == Port.KOTLIN
+ ? com.metaobjects.generator.kotlin.GeneratorRegistryKt.EJECT_RESOURCE_ROOT
+ : GeneratorRegistry.EJECT_RESOURCE_ROOT;
+ return root + simpleNameOf(source) + "." + port.extension;
}
/** {@link #note} with the owned copy's class name filled in, or {@code null} when
* this generator's only wiring is the {@code } change. */
- public String wiringNote(String ownedPackage) {
- return note == null ? null : note.replace("{owned}", ownedPackage + "." + simpleName());
+ public String wiringNote(String basePackage) {
+ return note == null ? null : note.replace("{owned}", ownedClassname(basePackage));
+ }
+
+ /**
+ * The package this generator's copies are written in, given the package eject was asked
+ * for. The {@code api-docs} copies take a subpackage of their port: the Java and the
+ * Kotlin api reference name their page model and path classes alike ({@code ApiUnit},
+ * {@code DocsPaths}, …), and one owned module may hold both. Every other generator's
+ * copies go in {@code basePackage} itself.
+ */
+ public String ownedPackage(String basePackage) {
+ return API_DOCS.equals(stableName)
+ ? basePackage + "." + API_DOCS_SUBPACKAGE + "." + port.id : basePackage;
+ }
+
+ /** The owned copy's class name: the {@code } value to wire. */
+ public String ownedClassname(String basePackage) {
+ return ownedPackage(basePackage) + "." + simpleName();
}
/** The simple class name — also the filename stem the ejected copy is written as. */
@@ -118,6 +148,20 @@ public String simpleName() {
}
}
+ /** The stable name both ports register their api reference generator under. */
+ public static final String API_DOCS = "api-docs";
+
+ /** What eject prints for {@link #API_DOCS}, on either port. A pom carried over from 8.0 ran
+ * the api reference through the removed docs goal, so it may hold no entry to change. */
+ private static final String API_DOCS_NOTE =
+ "api-docs: `mvn metaobjects:generate` runs this generator like any other. If your "
+ + " has no entry for it, add {owned}"
+ + "your docs directory.";
+
+ /** The subpackage of the owned package the {@link #API_DOCS} copies are written under,
+ * above the one named for their port (see {@link Entry#ownedPackage}). */
+ public static final String API_DOCS_SUBPACKAGE = "apidocs";
+
/** The simple name of a fully-qualified class name. */
public static String simpleNameOf(String classname) {
int dot = classname.lastIndexOf('.');
@@ -141,31 +185,22 @@ public static List allEntries() {
for (GeneratorRegistry.GeneratorInfo info : GeneratorRegistry.list().values()) {
out.add(new Entry(Port.JAVA, info.stableName(), info.classname(), info.description(),
info.ejectResourcePath(), info.ejectRuntime(), info.ejectNote(),
- info.ejectWith()));
+ info.ejectWith(), info.ejectResources()));
}
for (Map.Entry e : GeneratorRegistryKt.getGENERATOR_REGISTRY().entrySet()) {
GeneratorInfo info = e.getValue();
- // Kotlin generator classes have no separate public "classname" string field —
- // the registry's factory closure is what constructs them — so the FQN is
- // derived from the package plus the stable resource filename (both ports name
- // the ejectable resource after the class's simple name, e.g.
- // "KotlinNamesGenerator.kt" <-> class KotlinNamesGenerator).
- String simple = info.getEjectResourcePath() == null ? null
- : simpleNameFromResource(info.getEjectResourcePath());
- String classname = simple == null ? null
- : "com.metaobjects.generator.kotlin." + simple;
+ // Kotlin generator classes have no separate public "classname" string field: the
+ // registry's factory closure constructs them, so the FQN is the class it builds
+ // (calling it must not throw — the registry's own contract). The ejectable resource
+ // is named after the class's simple name, e.g. "KotlinNamesGenerator.kt".
+ String classname = info.getEjectResourcePath() == null ? null
+ : info.getFactory().invoke().getClass().getName();
out.add(new Entry(Port.KOTLIN, info.getName(), classname, info.getDescription(),
- info.getEjectResourcePath()));
+ info.getEjectResourcePath(), List.of(), null, info.getEjectWith()));
}
return out;
}
- private static String simpleNameFromResource(String resourcePath) {
- String file = resourcePath.substring(resourcePath.lastIndexOf('/') + 1);
- int dot = file.lastIndexOf('.');
- return dot < 0 ? file : file.substring(0, dot);
- }
-
/** Only the ejectable entries (non-null {@link Entry#resourcePath}), grouped by stable name. */
public static Map> ejectableByStableName() {
Map> byName = new LinkedHashMap<>();
@@ -269,13 +304,14 @@ public static String rewritePackage(String source, String newPackage) {
* so a bare same-package reference between them already means the copy; an
* {@code import} of one would still name the packaged class, and the owned generator
* would go on extending and calling code its owner cannot edit. An import of anything
- * else is left alone.
+ * else is left alone. Java closes an import with {@code ;} and Kotlin does not; each
+ * keeps its own punctuation.
*/
public static String rewriteCopiedImports(String source, List copied, String ownedPackage) {
String out = source;
for (String classname : copied) {
- out = out.replaceAll("(?m)^import\\s+" + Pattern.quote(classname) + "\\s*;",
- Matcher.quoteReplacement("import " + ownedPackage + "." + simpleNameOf(classname) + ";"));
+ out = out.replaceAll("(?m)^import\\s+" + Pattern.quote(classname) + "(?=\\s*;|[ \\t]*$)",
+ Matcher.quoteReplacement("import " + ownedPackage + "." + simpleNameOf(classname)));
}
return out;
}
@@ -289,6 +325,32 @@ public static String ownedSource(Entry entry, String reference, String ownedPack
return rewriteCopiedImports(rewritePackage(reference, ownedPackage), entry.sources(), ownedPackage);
}
+ // ------------------------------------------------------------------------------------
+ // templates — the page text a generator reads from the classpath, handed over with it
+ // ------------------------------------------------------------------------------------
+
+ /** The declaration a copied template provider names its classpath prefix in. */
+ private static final Pattern TEMPLATE_PREFIX_DECL = Pattern.compile(
+ "(?m)^(\\s*private static final String BASE_PREFIX = )\"[\\w/]+\";\\s*$");
+
+ /** The classpath directory the owned templates of the generators in {@code ownedPackage} are
+ * written under: the package's own path, so no other module's templates can shadow them. */
+ public static String ownedTemplatePrefix(String ownedPackage) {
+ return ownedPackage.replace('.', '/') + "/" + GeneratorRegistry.TEMPLATE_RESOURCE_PREFIX;
+ }
+
+ /** Point a copied template provider's {@code BASE_PREFIX} at {@link #ownedTemplatePrefix}. A
+ * source with no such declaration is returned unchanged. */
+ public static String rewriteTemplatePrefix(String source, String ownedPackage) {
+ Matcher m = TEMPLATE_PREFIX_DECL.matcher(source);
+ if (!m.find()) return source;
+ String replacement = m.group(1) + "\"" + ownedTemplatePrefix(ownedPackage) + "\";";
+ return new StringBuilder(source).replace(m.start(), m.end(), replacement).toString();
+ }
+
+ /** The extension a template reference resolves with. */
+ public static final String TEMPLATE_EXTENSION = ".md.mustache";
+
// ------------------------------------------------------------------------------------
// helper runtime — the source a generator's OUTPUT imports, handed over with it
// ------------------------------------------------------------------------------------
@@ -439,6 +501,8 @@ private static List normalizedLines(String source) {
if (t.isEmpty()) continue;
if (PACKAGE_LINE.matcher(t).matches()) continue;
if (RUNTIME_PACKAGE_DECL.matcher(t).matches()) continue;
+ if (TEMPLATE_PREFIX_DECL.matcher(t).matches()) continue;
+ if (isImportOfCopiedClass(t)) continue;
if (SUPPORT_IMPORT.matcher(t).matches()) continue;
if (t.startsWith(OWNED_RUNTIME_MARKER) || t.startsWith(OWNED_SUPPORT_MARKER)) continue;
out.add(t);
@@ -446,6 +510,26 @@ private static List normalizedLines(String source) {
return out;
}
+ private static final Pattern IMPORT_LINE = Pattern.compile("^import\\s+[\\w.]+\\.(\\w+)\\s*;?$");
+
+ /** The simple names of every class eject may copy WITH a generator, across all entries. */
+ private static final class Copied {
+ static final Set SIMPLE_NAMES = allEntries().stream()
+ .flatMap(e -> e.with.stream()).map(EjectSupport::simpleNameOf)
+ .collect(Collectors.toSet());
+ }
+
+ /**
+ * An import of a class eject copies with a generator. Eject points it at the copy when the
+ * copy is written for a generator that copies the class, and a file that two generators copy
+ * (the extractor, with {@code entity} and on its own) is written by whichever came first, so
+ * its imports follow that one: the line is eject's own edit, not drift.
+ */
+ private static boolean isImportOfCopiedClass(String trimmedLine) {
+ Matcher m = IMPORT_LINE.matcher(trimmedLine);
+ return m.matches() && Copied.SIMPLE_NAMES.contains(m.group(1));
+ }
+
public static Comparison compare(String owned, String reference) {
List a = normalizedLines(owned);
List b = normalizedLines(reference);
diff --git a/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/MetaDataEjectMojo.java b/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/MetaDataEjectMojo.java
index 7fdbd5c75..5f251ea37 100644
--- a/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/MetaDataEjectMojo.java
+++ b/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/MetaDataEjectMojo.java
@@ -21,6 +21,8 @@
import java.util.Map;
import java.util.Properties;
import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
import java.util.stream.Collectors;
/**
@@ -65,7 +67,8 @@
* What it never does. It never edits the calling project's own {@code pom.xml} —
* it PRINTS the {@code }, the plugin {@code }, and the {@code
* } change instead, the same reporting-not-editing stance ADR-0034 §3(c)
- * already rules for the TypeScript command. It never overwrites a file that exists unless
+ * already rules for the TypeScript command. An existing {@code codegen/pom.xml} is the adopter's
+ * too: when it lacks what a port ejected into the module needs, eject prints what to add. It never overwrites a file that exists unless
* {@code -Dforce=true}. It validates every requested name BEFORE writing anything, so a bad
* name in a multi-name call ejects nothing rather than half the batch.
*/
@@ -92,7 +95,9 @@ public class MetaDataEjectMojo extends AbstractMojo {
@Parameter(property = "port")
String port;
- /** Target package for ejected copies. Defaults to {@code .codegen}. */
+ /** Target package for ejected copies. Defaults to {@code .codegen}. The
+ * {@code api-docs} copies go in a subpackage of it per port (see
+ * {@link EjectSupport.Entry#ownedPackage}). */
@Parameter(property = "package")
String targetPackage;
@@ -160,7 +165,6 @@ public void execute() throws MojoExecutionException, MojoFailureException {
? project.getGroupId() + ".codegen" : targetPackage.trim();
Path codegenRoot = project.getBasedir().toPath().resolve(CODEGEN_DIR);
- Set portsTouched = new LinkedHashSet<>();
List classnameChanges = new ArrayList<>();
List entries = new ArrayList<>();
@@ -178,10 +182,10 @@ public void execute() throws MojoExecutionException, MojoFailureException {
Set support = new LinkedHashSet<>();
for (EjectSupport.Resolution r : resolved) {
EjectSupport.Entry e = r.entry;
- portsTouched.add(e.port);
String langDir = e.port == EjectSupport.Port.KOTLIN ? "kotlin" : "java";
- Path pkgDir = codegenRoot.resolve("src/main/" + langDir).resolve(pkg.replace('.', '/'));
+ String ownedPkg = e.ownedPackage(pkg);
+ Path pkgDir = codegenRoot.resolve("src/main/" + langDir).resolve(ownedPkg.replace('.', '/'));
for (String source : e.sources()) {
String simpleName = EjectSupport.simpleNameOf(source);
@@ -198,13 +202,16 @@ public void execute() throws MojoExecutionException, MojoFailureException {
"Could not read reference source for \"" + e.stableName + "\" at classpath "
+ "resource " + resource, ex);
}
- String rewritten = EjectSupport.ownedSource(e, reference, pkg);
+ String rewritten = EjectSupport.ownedSource(e, reference, ownedPkg);
if (generator && !e.runtime.isEmpty()) {
rewritten = EjectSupport.rewriteRuntimePackage(rewritten, runtimePkg);
}
if (e.port == EjectSupport.Port.JAVA) {
support.addAll(EjectSupport.supportFor(rewritten));
rewritten = EjectSupport.rewriteSupportImports(rewritten, pkg);
+ if (!e.resources.isEmpty()) {
+ rewritten = EjectSupport.rewriteTemplatePrefix(rewritten, ownedPkg);
+ }
}
String what = generator ? "\"" + e.stableName + "\""
@@ -212,12 +219,14 @@ public void execute() throws MojoExecutionException, MojoFailureException {
reportEject(what, target, rewritten, write(pkgDir, target, rewritten, force));
}
classnameChanges.add(" " + e.stableName + ": " + e.classname + " -> "
- + pkg + "." + e.simpleName());
+ + e.ownedClassname(pkg));
}
ejectSupport(support, codegenRoot.resolve("src/main/java").resolve(pkg.replace('.', '/')), pkg);
+ ejectTemplates(resolved, codegenRoot.resolve("src/main/resources"), pkg);
- boolean pomWritten = writeCodegenPomIfNeeded(codegenRoot, pkg, portsTouched);
+ boolean pomWritten = writeCodegenPomIfNeeded(codegenRoot, pkg, portsIn(codegenRoot));
+ reportWhatThePomLacks(codegenRoot);
if (!runtime.isEmpty()) {
ejectRuntime(runtime, runtimeRoot, runtimePkg);
@@ -234,6 +243,50 @@ public void execute() throws MojoExecutionException, MojoFailureException {
}
}
+ // ------------------------------------------------------------------------------------
+ // templates
+ // ------------------------------------------------------------------------------------
+
+ /**
+ * Write the templates the ejected generators read from the classpath into the owned module's
+ * resources, under the path of the package each generator's copies are in. The copied template
+ * provider is pointed there ({@link EjectSupport#rewriteTemplatePrefix}), so the owned page
+ * text is what runs.
+ */
+ private void ejectTemplates(List resolved, Path resourceRoot, String pkg)
+ throws MojoExecutionException {
+ Set written = new LinkedHashSet<>();
+ for (EjectSupport.Resolution r : resolved) {
+ Path dir = resourceRoot.resolve(EjectSupport.ownedTemplatePrefix(r.entry.ownedPackage(pkg)));
+ for (String ref : r.entry.resources) {
+ Path target = dir.resolve(ref + EjectSupport.TEMPLATE_EXTENSION);
+ if (!written.add(target)) continue;
+ String resource = GeneratorRegistry.EJECT_TEMPLATE_RESOURCE_ROOT + ref + EjectSupport.TEMPLATE_EXTENSION;
+ String content;
+ try {
+ content = readResource(resource);
+ } catch (IOException ex) {
+ throw new MojoExecutionException("Could not read reference template \"" + ref
+ + "\" at classpath resource " + resource, ex);
+ }
+ switch (write(target.getParent(), target, content, force)) {
+ case CREATED:
+ getLog().info("Copied template " + ref + " -> " + target
+ + ". The owned " + r.entry.stableName + " reads this copy; you own it now.");
+ break;
+ case REPLACED:
+ getLog().info("Copied template " + ref + " -> " + target
+ + ", REPLACING the file that was there (-Dforce=true).");
+ break;
+ case PRESERVED:
+ getLog().info(target + " already exists — " + describeOwned(target, content)
+ + " Left untouched (-Dforce=true replaces it).");
+ break;
+ }
+ }
+ }
+ }
+
// ------------------------------------------------------------------------------------
// support source
// ------------------------------------------------------------------------------------
@@ -457,39 +510,69 @@ private boolean writeCodegenPomIfNeeded(Path codegenRoot, String pkg, Set" + JAVA_RELEASE + "\n";
+
+ /** The Kotlin plugin's own source root, for a pom whose {@code } is Java's. */
+ private static final String KOTLIN_SOURCE_DIRS =
+ " \n"
+ + " \n"
+ + " ${project.basedir}/src/main/kotlin\n"
+ + " \n"
+ + " \n";
+
+ private String dependency(EjectSupport.Port port) {
+ return " \n"
+ + " com.metaobjects\n"
+ + " " + codegenArtifact(port) + "\n"
+ + " " + pluginVersion() + "\n"
+ + " \n";
+ }
+
+ /** The Kotlin compiler plugin, with {@code configuration} (possibly empty) inside it. */
+ private static String kotlinPlugin(String configuration) {
+ return " \n"
+ + " org.jetbrains.kotlin\n"
+ + " " + KOTLIN_PLUGIN + "\n"
+ + " " + kotlin.KotlinVersion.CURRENT + "\n"
+ + configuration
+ + " \n"
+ + " \n"
+ + " compile\n"
+ + " compile\n"
+ + " compile\n"
+ + " \n"
+ + " \n"
+ + " \n";
+ }
+
+ /** The ports the codegen module holds copies of: those with a source root in it. */
+ private static Set portsIn(Path codegenRoot) {
+ Set out = new LinkedHashSet<>();
+ for (EjectSupport.Port port : EjectSupport.Port.values()) {
+ if (Files.isDirectory(codegenRoot.resolve("src/main/" + port.id))) out.add(port);
+ }
+ return out;
+ }
+
private String codegenPom(String artifactId, Set ports) {
- String version = pluginVersion();
StringBuilder deps = new StringBuilder();
- if (ports.contains(EjectSupport.Port.JAVA)) {
- deps.append(" \n")
- .append(" com.metaobjects\n")
- .append(" metaobjects-codegen-spring\n")
- .append(" ").append(version).append("\n")
- .append(" \n");
- }
- if (ports.contains(EjectSupport.Port.KOTLIN)) {
- deps.append(" \n")
- .append(" com.metaobjects\n")
- .append(" metaobjects-codegen-kotlin\n")
- .append(" ").append(version).append("\n")
- .append(" \n");
- }
+ for (EjectSupport.Port port : ports) deps.append(dependency(port));
+ // A Kotlin-only module makes src/main/kotlin its source root. One that holds Java copies
+ // too keeps Maven's default root for them, and the Kotlin plugin is given its own.
+ boolean java = ports.contains(EjectSupport.Port.JAVA);
String kotlinPlugin = ports.contains(EjectSupport.Port.KOTLIN)
? " \n"
- + " src/main/kotlin\n"
+ + (java ? "" : " src/main/kotlin\n")
+ " \n"
- + " \n"
- + " org.jetbrains.kotlin\n"
- + " kotlin-maven-plugin\n"
- + " " + kotlin.KotlinVersion.CURRENT + "\n"
- + " \n"
- + " \n"
- + " compile\n"
- + " compile\n"
- + " compile\n"
- + " \n"
- + " \n"
- + " \n"
+ + kotlinPlugin(java ? KOTLIN_SOURCE_DIRS : "")
+ " \n"
+ " \n"
: "";
@@ -508,9 +591,7 @@ private String codegenPom(String artifactId, Set ports) {
// default is -source 8, and Kotlin's default JVM target is older still.
+ " \n"
+ " " + JAVA_RELEASE + "\n"
- + (ports.contains(EjectSupport.Port.KOTLIN)
- ? " " + JAVA_RELEASE + "\n"
- : "")
+ + (ports.contains(EjectSupport.Port.KOTLIN) ? KOTLIN_JVM_TARGET : "")
+ " \n"
+ " \n"
+ deps
@@ -519,6 +600,53 @@ private String codegenPom(String artifactId, Set ports) {
+ "\n";
}
+ private static final Pattern SOURCE_DIRECTORY =
+ Pattern.compile("\\s*([^<]*?)\\s*");
+
+ private static boolean declares(String pom, String artifactId) {
+ return pom.contains("" + artifactId + "");
+ }
+
+ /**
+ * Say what {@code codegen/pom.xml} does not declare that the copies under each port's source
+ * root need. Eject writes that pom for the ports the module holds then and never edits it, so
+ * a port ejected into the module in a later call is left out of the build, and the build passes.
+ */
+ private void reportWhatThePomLacks(Path codegenRoot) throws MojoExecutionException {
+ Path pom = codegenRoot.resolve("pom.xml");
+ String content;
+ try {
+ content = Files.readString(pom, StandardCharsets.UTF_8);
+ } catch (IOException ex) {
+ throw new MojoExecutionException("Could not read " + pom, ex);
+ }
+ for (EjectSupport.Port port : portsIn(codegenRoot)) {
+ String sourceRoot = "src/main/" + port.id;
+ List changes = new ArrayList<>();
+ if (!declares(content, codegenArtifact(port))) {
+ changes.add(" Add to :\n" + dependency(port));
+ }
+ if (port == EjectSupport.Port.KOTLIN && !declares(content, KOTLIN_PLUGIN)) {
+ changes.add(" Add to :\n" + KOTLIN_JVM_TARGET);
+ changes.add(" Add to :\n" + kotlinPlugin(KOTLIN_SOURCE_DIRS));
+ }
+ if (port == EjectSupport.Port.JAVA) {
+ Matcher sourceDirectory = SOURCE_DIRECTORY.matcher(content);
+ if (sourceDirectory.find() && !sourceDirectory.group(1).endsWith(sourceRoot)) {
+ changes.add(" Remove " + sourceDirectory.group() + " from , so that Maven compiles "
+ + sourceRoot + ", and add to the of " + KOTLIN_PLUGIN + ":\n"
+ + KOTLIN_SOURCE_DIRS);
+ }
+ }
+ if (changes.isEmpty()) continue;
+ getLog().warn(pom + " does not declare what the " + port.id + " copies under " + sourceRoot
+ + " need to build. It is yours, so eject does not edit it:");
+ for (String change : changes) {
+ for (String line : change.split("\n")) getLog().warn(line);
+ }
+ }
+ }
+
private static String escape(String s) {
return s == null ? "" : s.replace("&", "&").replace("<", "<").replace(">", ">");
}
diff --git a/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/MetaDataVerifyMojo.java b/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/MetaDataVerifyMojo.java
index 53891fb98..c76ce09d7 100644
--- a/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/MetaDataVerifyMojo.java
+++ b/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/MetaDataVerifyMojo.java
@@ -143,6 +143,7 @@ public class MetaDataVerifyMojo extends AbstractMetaDataMojo {
public void execute() throws MojoExecutionException, MojoFailureException {
// #233: warm the global registry singletons before verify builds its loader,
// for the same reason as the generate path (this mojo has its own execute()).
+ refuseRemovedDocsConfiguration("verify");
com.metaobjects.registry.RegistryBootstrap.warmUpDefaults();
if (getLoader() == null) {
throw new MojoExecutionException("No element was defined");
diff --git a/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/ApiDocsEjectBothPortsTest.java b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/ApiDocsEjectBothPortsTest.java
new file mode 100644
index 000000000..e92d009d8
--- /dev/null
+++ b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/ApiDocsEjectBothPortsTest.java
@@ -0,0 +1,322 @@
+package com.metaobjects.mojo;
+
+import com.metaobjects.generator.Generator;
+import com.metaobjects.loader.InMemoryStringSource;
+import com.metaobjects.loader.LoaderOptions;
+import com.metaobjects.loader.MetaDataLoader;
+import com.tschuchort.compiletesting.KotlinCompilation;
+import com.tschuchort.compiletesting.SourceFile;
+import org.apache.maven.model.Dependency;
+import org.apache.maven.model.Model;
+import org.apache.maven.model.Plugin;
+import org.apache.maven.model.io.xpp3.MavenXpp3Reader;
+import org.apache.maven.plugin.logging.Log;
+import org.apache.maven.project.MavenProject;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.codehaus.plexus.util.xml.Xpp3Dom;
+import org.junit.rules.TemporaryFolder;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mockito;
+
+import javax.tools.Diagnostic;
+import javax.tools.DiagnosticCollector;
+import javax.tools.JavaCompiler;
+import javax.tools.JavaFileObject;
+import javax.tools.StandardJavaFileManager;
+import javax.tools.StandardLocation;
+import javax.tools.ToolProvider;
+import java.io.File;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.io.Reader;
+import java.net.URL;
+import java.net.URLClassLoader;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeSet;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * A project that documents both languages (an {@code api-docs} {@code } entry for each)
+ * can own both generators in ONE {@code codegen/} module. The two ports name
+ * their page model and path classes alike ({@code ApiUnit}, {@code ApiSymbol}, {@code FieldShape},
+ * {@code ApiSymbolKind}, {@code DocsPaths}), so the copies have to land where no class is declared
+ * twice.
+ *
+ * Runs the real mojo once per port into one project, then compiles what it wrote with each
+ * language's own compiler. The classname eject prints for each port has to load, from that
+ * compile, as a generator that writes that language's pages.
+ */
+public class ApiDocsEjectBothPortsTest {
+
+ @Rule
+ public TemporaryFolder tempFolder = new TemporaryFolder();
+
+ private Path basedir;
+ private MavenProject project;
+
+ @Before
+ public void setUp() throws IOException {
+ basedir = tempFolder.newFolder("adopter-app").toPath();
+ project = Mockito.mock(MavenProject.class);
+ Mockito.when(project.getBasedir()).thenReturn(basedir.toFile());
+ Mockito.when(project.getGroupId()).thenReturn("com.acme");
+ Mockito.when(project.getArtifactId()).thenReturn("widgets");
+ Mockito.when(project.getVersion()).thenReturn("1.0.0");
+ Mockito.when(project.getDependencies()).thenReturn(List.of());
+ }
+
+ private static final Pattern CLASSNAME_CHANGE = Pattern.compile("^\\s*api-docs: \\S+\\s+->\\s+(\\S+)$");
+
+ /** Eject {@code api-docs} for one port, and return the log eject wrote to. */
+ private Log eject(String port) throws Exception {
+ return eject("api-docs", port, false);
+ }
+
+ private Log eject(String names, String port, boolean force) throws Exception {
+ MetaDataEjectMojo mojo = new MetaDataEjectMojo();
+ mojo.project = project;
+ mojo.names = names;
+ mojo.port = port;
+ mojo.force = force;
+ Log log = Mockito.mock(Log.class);
+ mojo.setLog(log);
+ mojo.execute();
+ return log;
+ }
+
+ private Model codegenPom() throws Exception {
+ try (Reader in = Files.newBufferedReader(basedir.resolve("codegen/pom.xml"), StandardCharsets.UTF_8)) {
+ return new MavenXpp3Reader().read(in);
+ }
+ }
+
+ /**
+ * The pom builds both ports' copies: it depends on both codegen artifacts, leaves Maven's
+ * source root at its default ({@code src/main/java}, which javac compiles), and gives the
+ * Kotlin plugin {@code src/main/kotlin} itself. Read through Maven's own model reader.
+ *
+ * The module is not built by Maven here: its dependencies are this reactor's artifacts,
+ * which no repository holds while these tests run. Both source trees are compiled, by each
+ * language's compiler, in {@link #bothPortsEjectedIntoOneModuleCompileAndDeclareNoClassTwice}.
+ */
+ private void assertPomBuildsBothPorts() throws Exception {
+ Model pom = codegenPom();
+ Set dependencies = pom.getDependencies().stream()
+ .map(Dependency::getArtifactId).collect(Collectors.toCollection(TreeSet::new));
+ assertEquals(Set.of("metaobjects-codegen-kotlin", "metaobjects-codegen-spring"), dependencies);
+ assertNull("a source root set here replaces Maven's default, src/main/java",
+ pom.getBuild().getSourceDirectory());
+ Plugin kotlin = pom.getBuild().getPlugins().stream()
+ .filter(p -> p.getArtifactId().equals("kotlin-maven-plugin")).findFirst().orElseThrow();
+ Xpp3Dom sourceDirs = ((Xpp3Dom) kotlin.getConfiguration()).getChild("sourceDirs");
+ assertEquals(1, sourceDirs.getChildCount());
+ assertEquals("${project.basedir}/src/main/kotlin", sourceDirs.getChild(0).getValue());
+ }
+
+ /** Eject {@code api-docs} for one port, and return the owned classname eject printed for it. */
+ private String ejectApiDocs(String port) throws Exception {
+ ArgumentCaptor captor = ArgumentCaptor.forClass(CharSequence.class);
+ Mockito.verify(eject(port), Mockito.atLeastOnce()).info(captor.capture());
+ List printed = new ArrayList<>();
+ for (CharSequence line : captor.getAllValues()) {
+ Matcher m = CLASSNAME_CHANGE.matcher(line);
+ if (m.matches()) printed.add(m.group(1));
+ }
+ assertEquals(port + ": eject prints one change for api-docs", 1, printed.size());
+ // The wiring note names the same class for the entry to add.
+ String owned = printed.get(0);
+ assertTrue(port + ": no wiring note names " + owned, captor.getAllValues().stream()
+ .anyMatch(line -> line.toString().contains("" + owned + "")));
+ return owned;
+ }
+
+ /** Everything eject warned about, one printed line per element. */
+ private static List warnings(Log log) {
+ ArgumentCaptor captor = ArgumentCaptor.forClass(CharSequence.class);
+ Mockito.verify(log, Mockito.atLeast(0)).warn(captor.capture());
+ return captor.getAllValues().stream().map(line -> line.toString().strip()).collect(Collectors.toList());
+ }
+
+ private static List sourcesUnder(Path root, String extension) throws IOException {
+ try (Stream s = Files.walk(root)) {
+ return s.filter(p -> p.toString().endsWith(extension)).map(Path::toFile).collect(Collectors.toList());
+ }
+ }
+
+ /** Every class file under {@code classes}, as a path relative to it: one per declared class. */
+ private static Set declared(Path classes) throws IOException {
+ try (Stream s = Files.walk(classes)) {
+ return s.filter(p -> p.toString().endsWith(".class"))
+ .map(p -> classes.relativize(p).toString().replace(File.separatorChar, '/'))
+ .collect(Collectors.toCollection(TreeSet::new));
+ }
+ }
+
+ private Path compileJava(Path sourceRoot) throws IOException {
+ List sources = sourcesUnder(sourceRoot, ".java");
+ assertFalse("eject wrote no Java source", sources.isEmpty());
+ Path classes = tempFolder.newFolder("java-classes").toPath();
+ JavaCompiler javac = ToolProvider.getSystemJavaCompiler();
+ DiagnosticCollector diagnostics = new DiagnosticCollector<>();
+ try (StandardJavaFileManager fm = javac.getStandardFileManager(diagnostics, null, StandardCharsets.UTF_8)) {
+ fm.setLocation(StandardLocation.CLASS_OUTPUT, List.of(classes.toFile()));
+ javac.getTask(null, fm, diagnostics,
+ List.of("-proc:none", "-classpath", System.getProperty("java.class.path")),
+ null, fm.getJavaFileObjectsFromFiles(sources)).call();
+ }
+ String errors = diagnostics.getDiagnostics().stream()
+ .filter(d -> d.getKind() == Diagnostic.Kind.ERROR).map(d -> " " + d).collect(Collectors.joining("\n"));
+ assertEquals("the Java api-docs copy does not compile", "", errors);
+ return classes;
+ }
+
+ private KotlinCompilation.Result compileKotlin(Path sourceRoot) throws IOException {
+ List sources = new ArrayList<>();
+ for (File file : sourcesUnder(sourceRoot, ".kt")) sources.add(SourceFile.Companion.fromPath(file, false));
+ assertFalse("eject wrote no Kotlin source", sources.isEmpty());
+ KotlinCompilation compilation = new KotlinCompilation();
+ compilation.setSources(sources);
+ compilation.setInheritClassPath(true);
+ compilation.setMessageOutputStream(OutputStream.nullOutputStream());
+ KotlinCompilation.Result result = compilation.compile();
+ assertEquals("the Kotlin api-docs copy does not compile:\n" + result.getMessages(),
+ KotlinCompilation.ExitCode.OK, result.getExitCode());
+ return result;
+ }
+
+ private static final String MODEL = "{ \"metadata.root\": { \"package\": \"acme::shop\", \"children\": ["
+ + " { \"object.value\": { \"name\": \"Money\", \"children\": ["
+ + " { \"field.string\": { \"name\": \"code\" } } ]}} ]}}";
+
+ /** Run the owned generator the way a {@code } entry does, and require the pages of
+ * {@code language} and nothing outside them. */
+ private void assertWritesTheApiReferenceOf(String language, String classname, ClassLoader loader)
+ throws Exception {
+ Generator generator = (Generator) Class.forName(classname, true, loader)
+ .getDeclaredConstructor().newInstance();
+ MetaDataLoader model = new MetaDataLoader(
+ LoaderOptions.create(false, false, true), MetaDataLoader.SUBTYPE_MANUAL, "owned-api-docs-" + language);
+ model.init();
+ model.load(List.of(new InMemoryStringSource(MODEL, "owned-api-docs/meta.json")));
+ Path out = tempFolder.newFolder("docs-" + language).toPath();
+ generator.setArgs(Map.of("outputDir", out.toString()));
+ generator.execute(model);
+
+ Set written = new TreeSet<>();
+ try (Stream s = Files.walk(out)) {
+ s.filter(Files::isRegularFile).forEach(p -> written.add(out.relativize(p).toString()
+ .replace(File.separatorChar, '/')));
+ }
+ assertEquals(classname, Set.of("api/" + language + "/AGENT-API.md", "api/" + language + "/Money.md",
+ "api/" + language + "/README.md"), written);
+ }
+
+ // The pom eject writes builds every port the module holds then. It is never edited afterwards,
+ // so a port ejected later is left out of the build unless eject says what the pom has to gain. What each
+ // port needs is fixed by Maven and by where eject writes: its codegen artifact, a compiler for
+ // its language, and its directory as a source root.
+
+ @Test
+ public void aPomWrittenForThePortBeingEjectedDrawsNoWarning() throws Exception {
+ assertEquals(List.of(), warnings(eject("java")));
+ assertEquals("the same port again, into the pom written for it", List.of(), warnings(eject("java")));
+ }
+
+ @Test
+ public void oneCallThatNamesBothPortsWritesAPomThatBuildsBothAndWarnsOfNothing() throws Exception {
+ // A Java-only name beside a Kotlin-only one: no -Dport, and one call holds both ports.
+ Log log = eject("html-docs,exposed-table", null, false);
+
+ assertFalse(sourcesUnder(basedir.resolve("codegen/src/main/java"), ".java").isEmpty());
+ assertFalse(sourcesUnder(basedir.resolve("codegen/src/main/kotlin"), ".kt").isEmpty());
+ assertPomBuildsBothPorts();
+ assertEquals(List.of(), warnings(log));
+ }
+
+ @Test
+ public void aForcedEjectRewritesThePomForEveryPortTheModuleHolds() throws Exception {
+ eject("java");
+ eject("kotlin");
+
+ Log log = eject("api-docs", "kotlin", true);
+
+ assertPomBuildsBothPorts();
+ assertEquals(List.of(), warnings(log));
+ }
+
+ @Test
+ public void kotlinEjectedIntoAJavaModulePrintsTheDependencyAndTheCompilerItsPomLacks() throws Exception {
+ eject("java");
+ Path pom = basedir.resolve("codegen/pom.xml");
+ String before = Files.readString(pom, StandardCharsets.UTF_8);
+
+ List warned = warnings(eject("kotlin"));
+
+ assertEquals("eject never edits an existing pom", before, Files.readString(pom, StandardCharsets.UTF_8));
+ assertTrue(warned.toString(), warned.contains("metaobjects-codegen-kotlin"));
+ assertTrue(warned.toString(), warned.contains("kotlin-maven-plugin"));
+ // The plugin is given the Kotlin root itself: the pom's own source root stays Java's.
+ assertTrue(warned.toString(), warned.contains("${project.basedir}/src/main/kotlin"));
+ assertTrue(warned.toString(), warned.contains("21"));
+ // The Java port's needs are met by the pom written for it.
+ assertFalse(warned.toString(), warned.contains("metaobjects-codegen-spring"));
+ assertFalse(warned.toString(), warned.stream().anyMatch(line -> line.contains("")));
+ }
+
+ @Test
+ public void javaEjectedIntoAKotlinModulePrintsTheDependencyAndTheSourceRootItsPomLacks() throws Exception {
+ eject("kotlin");
+ Path pom = basedir.resolve("codegen/pom.xml");
+ String before = Files.readString(pom, StandardCharsets.UTF_8);
+
+ List warned = warnings(eject("java"));
+
+ assertEquals("eject never edits an existing pom", before, Files.readString(pom, StandardCharsets.UTF_8));
+ assertTrue(warned.toString(), warned.contains("metaobjects-codegen-spring"));
+ // The Kotlin pom's source root is src/main/kotlin, which leaves src/main/java uncompiled.
+ assertTrue(warned.toString(), warned.stream().anyMatch(line ->
+ line.startsWith("Remove src/main/kotlin")
+ && line.contains("src/main/java")));
+ assertTrue(warned.toString(), warned.contains("${project.basedir}/src/main/kotlin"));
+ // The Kotlin port's needs are met by the pom written for it.
+ assertFalse(warned.toString(), warned.contains("metaobjects-codegen-kotlin"));
+ assertFalse(warned.toString(), warned.contains("kotlin-maven-plugin"));
+ }
+
+ @Test
+ public void bothPortsEjectedIntoOneModuleCompileAndDeclareNoClassTwice() throws Exception {
+ String ownedJava = ejectApiDocs("java");
+ String ownedKotlin = ejectApiDocs("kotlin");
+
+ Path codegen = basedir.resolve("codegen");
+ Path javaClasses = compileJava(codegen.resolve("src/main/java"));
+ KotlinCompilation.Result kotlin = compileKotlin(codegen.resolve("src/main/kotlin"));
+
+ // One module has one class output: a class both languages declare is a build failure.
+ Set twice = declared(javaClasses);
+ twice.retainAll(declared(kotlin.getOutputDirectory().toPath()));
+ assertEquals("classes the Java and the Kotlin api-docs copies both declare", Set.of(), twice);
+
+ URL[] javaUrls = {javaClasses.toUri().toURL(), codegen.resolve("src/main/resources/").toUri().toURL()};
+ try (URLClassLoader java = new URLClassLoader(javaUrls, getClass().getClassLoader())) {
+ assertWritesTheApiReferenceOf("java", ownedJava, java);
+ }
+ assertWritesTheApiReferenceOf("kotlin", ownedKotlin, kotlin.getClassLoader());
+ }
+}
diff --git a/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/ApiDocsGeneratorEntryTest.java b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/ApiDocsGeneratorEntryTest.java
new file mode 100644
index 000000000..434a2374b
--- /dev/null
+++ b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/ApiDocsGeneratorEntryTest.java
@@ -0,0 +1,162 @@
+package com.metaobjects.mojo;
+
+import com.metaobjects.generator.GeneratorException;
+import com.metaobjects.generator.apidocs.JavaApiDocsGenerator;
+import com.metaobjects.generator.kotlin.apidocs.KotlinApiDocsGenerator;
+import org.apache.maven.plugin.MojoFailureException;
+import org.apache.maven.plugin.testing.MojoRule;
+import org.codehaus.plexus.PlexusTestCase;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+
+import java.io.File;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+/**
+ * The api reference is a {@code } entry like any other (ADR-0034 Amendment 9):
+ * {@code metaobjects:generate} writes it for each language the list names, from the options
+ * in that entry's {@code }, and {@code metaobjects:verify} checks its pages with the
+ * rest of the list.
+ */
+public class ApiDocsGeneratorEntryTest {
+
+ private static final String JAVA_DOCS = JavaApiDocsGenerator.class.getName();
+ private static final String KOTLIN_DOCS = KotlinApiDocsGenerator.class.getName();
+
+ @Rule
+ public MojoRule rule = new MojoRule();
+
+ @Rule
+ public TemporaryFolder tmp = new TemporaryFolder();
+
+ private static File pom() {
+ return new File(PlexusTestCase.getBasedir(), "src/test/resources/mojo/pom-api-docs.xml");
+ }
+
+ private static Path docs() {
+ return new File(PlexusTestCase.getBasedir(), "target/api-docs-test/docs").toPath();
+ }
+
+ @Test
+ public void theGenerateGoalWritesTheApiReferenceOfEachLanguageTheListNames() throws Exception {
+ ((MetaDataGeneratorMojo) rule.lookupMojo("generate", pom())).execute();
+
+ // The Java entry sets every option: , package (the Basket entity of
+ // package simple::fruitbasket lands under its package folder), and
+ // .
+ Path java = docs().resolve("reference/java");
+ Path basket = java.resolve("simple/fruitbasket/Basket.md");
+ assertTrue("expected entity api page at " + basket, Files.exists(basket));
+ assertTrue("expected index README.md", Files.exists(java.resolve("README.md")));
+ String page = Files.readString(basket);
+ assertTrue(page, page.contains("```java"));
+ assertTrue("the model link is written against : " + page,
+ page.contains("**Model / metadata:**") && page.contains("https://docs.example.test/model/"));
+ String agent = Files.readString(java.resolve("AGENT-API.md"));
+ assertTrue("the agent page carries : " + agent, agent.contains("fruit-shop"));
+
+ // The Kotlin entry takes every default: api/kotlin, flat layout.
+ Path kotlin = docs().resolve("api/kotlin");
+ assertTrue(Files.exists(kotlin.resolve("README.md")));
+ assertTrue(Files.exists(kotlin.resolve("AGENT-API.md")));
+ String kotlinPage = Files.readString(kotlin.resolve("Basket.md"));
+ assertTrue(kotlinPage, kotlinPage.contains("```kotlin"));
+ assertTrue(kotlinPage, kotlinPage.contains("**Model / metadata:**"));
+ }
+
+ @Test
+ public void regeneratingUpdatesThePages_itDoesNotFreezeAfterTheFirstRun() throws Exception {
+ // A DO-NOT-EDIT artifact that stops updating is the worst kind of stale: the
+ // command still reports success. This fires because the marker write-guard was
+ // briefly applied here, and API pages are rendered from user-editable Mustache
+ // templates that emit no marker — so run 1 wrote, run 2 refused, and the docs
+ // silently froze while the build stayed green. Only a second run distinguishes
+ // "writes" from "keeps writing".
+ Path basket = docs().resolve("reference/java/simple/fruitbasket/Basket.md");
+
+ ((MetaDataGeneratorMojo) rule.lookupMojo("generate", pom())).execute();
+ assertTrue("first run should emit the page", Files.exists(basket));
+
+ // Stand in for "the model changed": clobber the page, then regenerate. A second
+ // run that refuses leaves the sentinel in place.
+ Files.writeString(basket, "STALE SENTINEL\n");
+ ((MetaDataGeneratorMojo) rule.lookupMojo("generate", pom())).execute();
+
+ String after = Files.readString(basket);
+ assertFalse("second run must rewrite the page, not refuse it — a frozen "
+ + "DO-NOT-EDIT artifact reports success while going stale",
+ after.contains("STALE SENTINEL"));
+ assertTrue("regenerated page should carry its normal content",
+ after.contains("**Model / metadata:**"));
+ }
+
+ // ------------------------------------------------------------------------------------
+ // verify covers the pages, as it covers every other entry's output
+ // ------------------------------------------------------------------------------------
+
+ private void configure(AbstractMetaDataMojo mojo, GeneratorParam... generators) {
+ mojo.setLoader(LoaderParam.builder("api-docs-entry-test")
+ .withClassname("com.metaobjects.loader.MetaDataLoader")
+ .withSourceDir("./src/test/resources")
+ .withSource("mojo/verify-test-metadata.json")
+ .build());
+ mojo.setGenerators(new ArrayList<>(List.of(generators)));
+ mojo.setGlobals(Collections.emptyMap());
+ }
+
+ private static GeneratorParam entry(String classname, Path outputDir, String... args) {
+ GeneratorParam.Builder b = GeneratorParam.builder(classname).withArg("outputDir", outputDir.toString());
+ for (int i = 0; i < args.length; i += 2) b.withArg(args[i], args[i + 1]);
+ return b.build();
+ }
+
+ @Test
+ public void theVerifyGoalChecksThePagesOfBothLanguagesInOneDirectory() throws Exception {
+ Path committed = tmp.newFolder("docs").toPath();
+ MetaDataGeneratorMojo generate = new MetaDataGeneratorMojo();
+ configure(generate, entry(JAVA_DOCS, committed), entry(KOTLIN_DOCS, committed));
+ generate.execute();
+
+ MetaDataVerifyMojo inSync = new MetaDataVerifyMojo();
+ configure(inSync, entry(JAVA_DOCS, committed), entry(KOTLIN_DOCS, committed));
+ inSync.execute();
+
+ Path page = committed.resolve("api/kotlin/Widget.md");
+ Files.writeString(page, Files.readString(page) + "\nedited by hand\n", StandardCharsets.UTF_8);
+ MetaDataVerifyMojo drifted = new MetaDataVerifyMojo();
+ configure(drifted, entry(JAVA_DOCS, committed), entry(KOTLIN_DOCS, committed));
+ try {
+ drifted.execute();
+ fail("expected the edited page to read as drift");
+ } catch (MojoFailureException e) {
+ assertTrue(e.getMessage(), e.getMessage().contains("[content-differs] " + page));
+ assertFalse("only the edited page drifted: " + e.getMessage(), e.getMessage().contains("api/java"));
+ }
+ }
+
+ @Test
+ public void aLayoutThatNamesNoLayoutIsRefusedOnBothPorts() throws Exception {
+ for (String classname : List.of(JAVA_DOCS, KOTLIN_DOCS)) {
+ MetaDataGeneratorMojo generate = new MetaDataGeneratorMojo();
+ Path out = tmp.newFolder().toPath();
+ configure(generate, entry(classname, out, "layout", "packaged"));
+ try {
+ generate.execute();
+ fail(classname + ": expected packaged to be refused");
+ } catch (GeneratorException e) {
+ assertTrue(e.getMessage(), e.getMessage().contains(
+ "api-docs: must be flat or package (got \"packaged\")"));
+ }
+ }
+ }
+}
diff --git a/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/DeclaresNothingWritesNothingTest.java b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/DeclaresNothingWritesNothingTest.java
new file mode 100644
index 000000000..28974e155
--- /dev/null
+++ b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/DeclaresNothingWritesNothingTest.java
@@ -0,0 +1,108 @@
+package com.metaobjects.mojo;
+
+import com.metaobjects.generator.direct.object.javacode.JavaObjectCodeGenerator;
+import com.metaobjects.generator.kotlin.KotlinEntityGenerator;
+import org.apache.maven.project.MavenProject;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import org.mockito.Mockito;
+
+import java.io.File;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * Nothing is generated that the pom does not declare (ADR-0034 Amendment 9).
+ *
+ *