From 8c57157e40f726a2864b044c3d2112cb992d91f4 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Mon, 12 Oct 2026 01:29:49 -0400 Subject: [PATCH 01/17] feat(eject): hand over the Java and Kotlin emitters; api-docs and four Java docs generators eject An ejected Java or Kotlin generator was one file over packaged emitters, so a defect in the enum, record-builder, extraction-mirror or prompt-format writers waited for a release. Eject now copies those emitters with the generator, into its package, and points the imports the copies have of one another at the copies. - Java dto and value-object take Fr019SharedEnum and SpringRecordBuilder, output-parser takes Fr019SharedEnum, output-prompt takes OutputFormatSpecEmitter, and entity takes ExtractorCodeGenerator, so an owned entity writes its extractors through the owned copy. - Kotlin entity takes KotlinEnumEmitter, output-parser takes KotlinExtractSchemaEmitter and output-prompt takes KotlinOutputFormatSpecEmitter. Kotlin Fr019SharedEnum stays in the package as engine: it writes nothing and the type mapper takes its config as an argument. - api-docs is a generator in Java and Kotlin, run through a new DocsGenerator marker. metaobjects:docs runs the one the project lists for a surface in place of the packaged one; generate and verify skip it. Eject copies the page model, builder, renderer and path math, and for Java the page templates, with the copied template provider pointed at them. - plantuml, html-docs, ai-docs and json-schema ship eject source, each with its writer. SingleHtmlDirectGeneratorBase drives the new HtmlDirectWriter, and the JSON Schema and AI documentation attributes move to CodeGenMetaDataProvider, with deprecated forwarders left on the two generators. The eject contract's pending list holds no java or kotlin entry. Packaged output is unchanged. --- CHANGELOG.md | 54 +++++- README.md | 2 +- docs/features/own-your-codegen.md | 132 ++++++++++++-- docs/ports/java.md | 11 +- docs/ports/kotlin.md | 9 +- .../registry.json | 82 ++------- server/java/README.md | 2 +- server/java/codegen-base/pom.xml | 28 ++- .../generator/CodeGenMetaDataProvider.java | 109 +++++++++++- .../metaobjects/generator/DocsGenerator.java | 39 +++++ .../ai/MetaDataAIDocumentationGenerator.java | 78 ++++----- .../json/MetaDataFileJsonSchemaGenerator.java | 85 ++++----- .../metadata/html/HtmlDirectWriter.java | 21 +++ .../MetaDataHtmlDocumentationGenerator.java | 4 + .../html/MetaDataHtmlDocumentationWriter.java | 6 +- .../html/SingleHtmlDirectGeneratorBase.java | 6 +- server/java/codegen-kotlin/pom.xml | 18 ++ .../generator/kotlin/Fr019SharedEnum.kt | 10 +- .../generator/kotlin/GeneratorRegistry.kt | 41 +++++ .../generator/kotlin/KotlinEnumEmitter.kt | 7 + .../kotlin/KotlinExtractSchemaEmitter.kt | 9 + .../kotlin/KotlinOutputFormatSpecEmitter.kt | 6 + .../kotlin/apidocs/KotlinApiDocsGenerator.kt | 61 +++++++ .../generator/kotlin/EjectContractTest.kt | 42 ++++- .../kotlin/EjectedGeneratorsCompileTest.kt | 16 +- .../generator/kotlin/ReportingInertTest.kt | 38 ++-- server/java/codegen-plantuml/pom.xml | 30 ++++ server/java/codegen-spring/pom.xml | 44 +++++ .../generator/GeneratorRegistry.java | 123 +++++++++++-- .../apidocs/GeneratorApplicability.java | 55 ++++++ .../apidocs/JavaApiDocsGenerator.java | 78 +++++++++ .../apidocs/JavaApiModelBuilder.java | 36 ++-- .../generator/spring/Fr019SharedEnum.java | 5 + .../spring/OutputFormatSpecEmitter.java | 4 + ...edPathNeverReachesPackagedSupportTest.java | 14 ++ .../generator/spring/ReportingInertTest.java | 27 ++- .../mojo/AbstractMetaDataMojo.java | 19 ++ .../java/com/metaobjects/mojo/DocsMojo.java | 164 ++++++------------ .../com/metaobjects/mojo/EjectSupport.java | 97 ++++++++--- .../metaobjects/mojo/MetaDataEjectMojo.java | 47 +++++ .../mojo/MetaDataGeneratorMojo.java | 5 + .../metaobjects/mojo/MetaDataVerifyMojo.java | 7 + .../mojo/DocsGeneratorGoalsTest.java | 128 ++++++++++++++ .../metaobjects/mojo/EjectContractTest.java | 18 +- .../mojo/EjectRuntimeRoundTripTest.java | 5 + .../metaobjects/mojo/EjectSupportTest.java | 78 ++++++++- .../EntityExtractorEjectRoundTripTest.java | 14 +- .../mojo/MetaDataEjectMojoTest.java | 26 +++ .../mojo/test/OwnedDocsGenerator.java | 23 +++ .../ADR-0034-codegen-scaffold-and-own.md | 26 +-- 50 files changed, 1567 insertions(+), 422 deletions(-) create mode 100644 server/java/codegen-base/src/main/java/com/metaobjects/generator/DocsGenerator.java create mode 100644 server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/metadata/html/HtmlDirectWriter.java create mode 100644 server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/apidocs/KotlinApiDocsGenerator.kt create mode 100644 server/java/codegen-spring/src/main/java/com/metaobjects/generator/apidocs/GeneratorApplicability.java create mode 100644 server/java/codegen-spring/src/main/java/com/metaobjects/generator/apidocs/JavaApiDocsGenerator.java create mode 100644 server/java/maven-plugin/src/test/java/com/metaobjects/mojo/DocsGeneratorGoalsTest.java create mode 100644 server/java/maven-plugin/src/test/java/com/metaobjects/mojo/test/OwnedDocsGenerator.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 863f9300e..41e291b24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -125,8 +125,48 @@ generator catalog and the Atlas evaluation are 1.2 work. pages `meta docs` writes) and `agent-docs` (the pages `meta docs --agent` writes), both neutral and both still run by `meta docs`; a `generators` entry naming either string is refused the way `"docs"` is. Java: `plantuml`, `html-docs`, `ai-docs`, `json-schema` and the - deprecated `mustache`, each by the class name a pom already uses. None of the seven ships - eject source yet; `mustache` will not. + deprecated `mustache`, each by the class name a pom already uses. `mustache` will not ship + eject source. The four Java generators that can ship it do: see **Java and Kotlin** below. + +- **Java and Kotlin: eject hands over the emitters a generator writes through, and + `api-docs`, `plantuml`, `html-docs`, `ai-docs` and `json-schema` are ejectable.** An ejected + Java or Kotlin generator used to be one file over packaged emitters, so a defect in the enum, + record-builder, extraction-mirror or prompt-format writers still waited for a release. Eject + now copies those emitters with the generator, into its package, and points the imports the + copies have of one another at the copies. Java `dto` and `value-object` take + `Fr019SharedEnum` and `SpringRecordBuilder`; Java `output-parser` takes `Fr019SharedEnum`; + Java `output-prompt` takes `OutputFormatSpecEmitter`; Kotlin `entity` takes + `KotlinEnumEmitter`; Kotlin `output-parser` takes `KotlinExtractSchemaEmitter`; Kotlin + `output-prompt` takes `KotlinOutputFormatSpecEmitter` and `KotlinExtractSchemaEmitter`. A + class two generators share is written once and never overwritten. The naming and type-mapping + tables, the model walks and the generator bases stay in the package as the engine. Kotlin + `Fr019SharedEnum` is engine: it writes nothing, and the type mapper takes its + `ProvidedEnumConfig` as an argument, so its `providedInDeclarationPackage` and + `DECLARATION_PACKAGE` members are now public. The owned Java `entity` writes its extractors + through the owned `ExtractorCodeGenerator`, where it used to construct the packaged one. + Packaged output is unchanged. A copy you ejected earlier keeps compiling with the packaged + emitters; own them with the recipe in + [own-your-codegen](docs/features/own-your-codegen.md#ejecting-a-generator-hands-over-the-emitters-it-writes-through-java-and-kotlin). + - **`api-docs` is a generator, and ejectable, in Java and Kotlin.** The api reference + `mvn metaobjects:docs` writes is now `JavaApiDocsGenerator` / `KotlinApiDocsGenerator`, run + through a new `DocsGenerator` marker (`metaobjects-codegen-base`). `metaobjects:generate` and + `metaobjects:verify` skip a `DocsGenerator` and say so in the build log; `metaobjects:docs` + runs the one your `` list names for a surface, else the packaged one. Eject + copies the generator, the page model, the builder, the renderer and the path math, and for + Java the page templates too, into `codegen/src/main/resources//templates/`, + with the copied template provider pointed there. What the Java reference documents comes + from `GeneratorApplicability.packaged()`, so it describes the packaged generators even in a + project that owns a copy of one. Page output and the goal's parameters + (`language`, `apiSubDir`, `layout`, `modelBaseUrl`, `outputDirectory`) are unchanged. + - **The Java `plantuml`, `html-docs`, `ai-docs` and `json-schema` generators ship eject + source**, each with the writer that emits its file. `SingleHtmlDirectGeneratorBase` now + drives the new `HtmlDirectWriter`, as the JSON base drives `JsonDirectWriter`, so a copied + HTML generator pairs with the copy of its writer. A subclass of the base that overrode + `getWriter` or `writeHtml` with `MetaDataHtmlDocumentationWriter` in the signature needs + `HtmlDirectWriter` there. The attributes the JSON Schema and AI documentation generators + add to the metamodel moved to `CodeGenMetaDataProvider`, where the loader registers them, + so a copy owns emit code only; the old static methods and constants on the two generators + stay as deprecated forwarders. - **The Java `entity` and `extractor` generators can be ejected.** `mvn metaobjects:eject -Dnames=entity,extractor -Dport=java` copies each generator, with @@ -137,14 +177,16 @@ generator catalog and the Atlas evaluation are 1.2 work. `identical` or `DIFFERS`, and `mvn metaobjects:verify` runs them when your pom names them. Every Java generator is now ejectable except `template`. Ruling and reasoning: ADR-0034 Amendment 6. - - **The owned `entity` includes the class body.** Eject copies seven files into one + - **The owned `entity` includes the class body.** Eject copies eight files into one package: `JavaObjectCodeGenerator`, the two bases it extends (`JavaCodeGenerator`, - `BaseObjectCodeGenerator`), and the four writers that emit each class + `BaseObjectCodeGenerator`), the four writers that emit each class (`BaseObjectCodeWriter`, `JavaCodeWriter`, `PojoAwareCodeWriter`, - `ValueObjectCodeWriter`). The copies use one another, never the packaged classes: where + `ValueObjectCodeWriter`) and `ExtractorCodeGenerator`, which writes the + `Extractor` classes `entity` emits when no separate `extractor` is in the run. The + copies use one another, never the packaged classes: where one imports another, eject rewrites that import along with the package line. You change a field, a constructor or an accessor by editing the writer, with nothing to subclass. - `-Dlist` gives `entity` one verdict over all seven files. Details: + `-Dlist` gives `entity` one verdict over all eight files. Details: [own-your-codegen](docs/features/own-your-codegen.md#java-entity-and-extractor). - **`extractor` is a generator of its own (Java).** `ExtractorCodeGenerator` can be named in ``, with the same `outputDir`, `pkgPrefix`, `pkgSuffix`, `namePrefix`, diff --git a/README.md b/README.md index 6ddbb233a..411ba70bc 100644 --- a/README.md +++ b/README.md @@ -134,7 +134,7 @@ first-week wedge plan — and `meta init` picks up from there. |---|---|---|---| | TypeScript | **npm `1.0.13`** — the `@metaobjectsdev/*` packages | [`docs/ports/typescript.md`](docs/ports/typescript.md) | [`server/typescript/`](server/typescript/) · [`client/web/`](client/web/) | | Java | **Maven Central `8.0.13`** (`com.metaobjects:*` — the JVM major is npm major + 7) — loader + OMDB + render + Maven plugin all shipped; full conformance green | [`docs/ports/java.md`](docs/ports/java.md) | [`server/java/`](server/java/) | -| Kotlin | **Maven Central `8.0.13`** — codegen tier on top of Java: 15 generators (entity, Exposed table, names, relations, repository, output-parser, output-prompt, render-helper, extractor, filter-allowlist, validator, Spring config, storedProc, Spring controller, requirement-tests); 24 / 24 persistence-conformance | [`docs/ports/kotlin.md`](docs/ports/kotlin.md) | [`server/java/codegen-kotlin/`](server/java/codegen-kotlin/) · [`server/java/metadata-ktx/`](server/java/metadata-ktx/) | +| Kotlin | **Maven Central `8.0.13`** — codegen tier on top of Java: 16 generators (entity, Exposed table, names, relations, repository, output-parser, output-prompt, render-helper, extractor, filter-allowlist, validator, Spring config, storedProc, Spring controller, requirement-tests, api-docs); 24 / 24 persistence-conformance | [`docs/ports/kotlin.md`](docs/ports/kotlin.md) | [`server/java/codegen-kotlin/`](server/java/codegen-kotlin/) · [`server/java/metadata-ktx/`](server/java/metadata-ktx/) | | C# | **NuGet `1.0.13`** — loader + conformance + EF Core codegen + render engine + `dotnet meta` CLI all shipped | [`docs/ports/csharp.md`](docs/ports/csharp.md) | [`server/csharp/`](server/csharp/) | | Python | **PyPI `1.0.13`** — loader + conformance + render + entity-model codegen + ObjectManager runtime shipped; schema migrations are TS-owned (ADR-0015) | [`docs/ports/python.md`](docs/ports/python.md) | [`server/python/`](server/python/) | diff --git a/docs/features/own-your-codegen.md b/docs/features/own-your-codegen.md index efae4176c..4466fb4a7 100644 --- a/docs/features/own-your-codegen.md +++ b/docs/features/own-your-codegen.md @@ -805,14 +805,16 @@ always wins over one with the same name. So eject puts each copy into a separate `codegen/` Maven module, under your own package (`-Dpackage`, default `.codegen`). Every copy compiles on its own, and none calls another generator. The package declaration is the only line it changes, except for a generator that is copied with -other classes (Java `entity` and `extractor`, below) and for the support imports described -below: there the imports those copies have of one another change too. On first use +other classes (the emitters it writes through, below, and Java `entity` and `extractor`) and +for the support imports and the template prefix described below: there the imports those +copies have of one another change too. On first use it writes `codegen/pom.xml`, which depends on `metaobjects-codegen-spring` or `metaobjects-codegen-kotlin` at the plugin's version. -Eject changes a copy's package declaration, and for a Java generator up to two more -lines, both described below: its import of `SpringGeneratorSupport`, and its -`RUNTIME_PACKAGE` constant. +Eject changes a copy's package declaration, and for a Java generator up to three more +lines, each described below: its import of `SpringGeneratorSupport`, its +`RUNTIME_PACKAGE` constant, and, for `api-docs`, the classpath prefix its template +provider reads from. Eject never edits an existing pom. It prints the three things to wire: the `` entry for your parent pom, a plugin `` on the codegen module for the module @@ -838,7 +840,7 @@ Both names exist in both ports, so pass `-Dport`. Their reference source ships i `metaobjects-codegen-base`, not `metaobjects-codegen-spring`; your `codegen/` module reaches it through the same dependency. -**`entity` copies seven files, and together they are the generator.** +**`entity` copies eight files, and together they are the generator.** `JavaObjectCodeGenerator` does not emit a class body itself. A writer does, one per flavor, so the writers come with it: @@ -849,19 +851,22 @@ flavor, so the writers come with it: | `BaseObjectCodeWriter` | The class header comment, and the per-field loop every flavor shares. | | `JavaCodeWriter` | The Java type of each field; accessor, parameter, class and package names; the body of the interface flavor. | | `PojoAwareCodeWriter`, `ValueObjectCodeWriter` | The fields, the constructor and the accessor bodies of the two concrete flavors. | +| `ExtractorCodeGenerator` | The `Extractor` classes `entity` writes beside each concrete class, unless a separate `extractor` generator is in the run. | -All seven land in one package and use one another, never the packaged classes: eject +All eight land in one package and use one another, never the packaged classes: eject rewrites the package line of each and, where one imports another, that import. To change a field, a constructor or an accessor, edit the writer. You subclass nothing. `-Dlist` gives -`entity` one verdict over all seven files. +`entity` one verdict over all eight files. What stays in the package holds no emit: `MultiFileDirectGeneratorBase` (the one-file-per-object loop), `FileDirectWriter` (indentation and `println`) and `GeneratedFileWriter` (the write guard: a file with no `GENERATED` marker is left alone). -The owned `entity` still calls the packaged `ExtractorCodeGenerator` to write the -extractors, until you wire an `extractor` generator of your own. +The extractors the owned `entity` writes come from the owned copy of +`ExtractorCodeGenerator` beside it, so changing how an extractor reads is an edit in code you +own whether or not you also wire an `extractor` generator. -**`extractor` copies three files.** `ExtractorCodeGenerator.java` holds its emit. +**`extractor` copies three files.** `ExtractorCodeGenerator.java` holds its emit. Eject it +beside `entity`, in the same package, and they share that one copy. `JavaCodeWriter` and `BaseObjectCodeWriter` come with it because that is where it reads the package and class name of the type each extractor casts to. Eject both generators into the same package, which is the default, and they share the one `JavaCodeWriter`: a naming @@ -891,6 +896,104 @@ each with that entry's `outputDir`, naming args and ``. A single `extra generator in the run stops every `entity` entry writing its extractors, so an `entity` entry left without one gets none. +### Ejecting a generator hands over the emitters it writes through (Java and Kotlin) + +A generator that calls an emitter writes part of its output through it. If eject left the +emitter in the package, the copy would own the orchestration and the package would still own +the text, and a defect in that text would wait for a release. So eject copies the emitters +too, into the generator's package, and points the imports the copies have of one another at +the copies: + +| Port | Eject | Also writes | +|---|---|---| +| Java | `dto`, `value-object` | `Fr019SharedEnum` (the shared and provided enum files and their type references), `SpringRecordBuilder` (the `builder()` a record carries) | +| Java | `output-parser` | `Fr019SharedEnum` | +| Java | `output-prompt` | `OutputFormatSpecEmitter` (the `OutputFormatSpec` literal) | +| Java | `entity` | the eight files above, `ExtractorCodeGenerator` among them | +| Kotlin | `entity` | `KotlinEnumEmitter` (the enum class file) | +| Kotlin | `output-parser` | `KotlinExtractSchemaEmitter` (the extraction mirror and the literal helpers) | +| Kotlin | `output-prompt` | `KotlinOutputFormatSpecEmitter`, `KotlinExtractSchemaEmitter` | +| Kotlin | `api-docs` | the page model, builder, renderer and path math, below | + +A class two generators share is written once. A later eject finds it, says it is +`IDENTICAL` or `DIFFERS`, and leaves your edits alone; `-Dforce` replaces it with the reference, +as it replaces the generator. Each copy is the packaged source with the same package edit +as the generator and the same import rewrite, and the Java copies carry an explicit import of +the packaged sibling classes they still call, so they compile from any package. + +**What stays in the package is the engine**, public and unchanged: the generator base and +runner types, the naming and type-mapping tables (`SpringNaming`, `SpringTypeMapper`, +`KotlinNaming`, `KotlinTypeMapper`), the questions a generator asks of the model +(`FindInbound`, `TphPlan`, the M:N support, `RestSurfaceGate`), and the Kotlin +`Fr019SharedEnum`. Anything that returns emitted text is handed over; a table or a model +question is not. The Kotlin `Fr019SharedEnum` writes no output. It answers where a shared or +`@provided` enum lives, and the type mapper and the generator utilities take its +`ProvidedEnumConfig` as an argument, so it is the same kind of thing as a type-mapping table. +The Java `Fr019SharedEnum` is different: it also writes the enum files, so it is handed over. + +The line is written down and checked. `EjectContractTest`, in the Maven plugin for Java and in +`codegen-kotlin` for Kotlin, ejects each generator the registry lists alone into an empty +project, compiles it against the published artifacts, fails on any reference to a packaged +class outside the engine surface in `fixtures/generator-registry-conformance/registry.json`, +and compares what the copy emits with what the packaged generator emits, byte for byte. + +**A copy you ejected before the emitters were handed over** keeps compiling and running, with +the packaged emitters. To own them, raise your `codegen/` module's dependency to 1.1.0 or +later and re-eject the generator into a scratch project, then copy the emitter files next to +your generator: + +```bash +mvn metaobjects:eject -Dnames=dto,value-object -Dport=java -Dpackage=com.acme.scratch # in a scratch project +cp scratch/codegen/src/main/java/com/acme/scratch/{Fr019SharedEnum,SpringRecordBuilder}.java codegen/src/main/java/com/acme/codegen/ +``` + +Change the `package` line of each file you copy, and point your generator's imports of them at +your package. `mvn metaobjects:eject -Dlist` then marks the copy `identical` or `DIFFERS`. + +### `api-docs`: the api reference as an owned generator + +`mvn metaobjects:docs` writes the Java and the Kotlin api reference. `mvn metaobjects:eject +-Dnames=api-docs -Dport=java` copies the generator that writes the Java one, with the page +model (`ApiSymbol`, `ApiUnit`, `FieldShape`, `ApiSymbolKind`, `JavaApiModel`, `UnitExample`), the +builder that decides what to document (`JavaApiModelBuilder`, `JavaFieldShapes`), the renderer +(`JavaApiDocsRenderer`), the path math (`DocsPaths`) and the template provider, and writes the +page templates into `codegen/src/main/resources//templates/api/`. Eject +points the copied provider at that directory, so the page text is the one you own and no other +jar on the classpath can shadow it. `-Dport=kotlin` copies `KotlinApiDocsGenerator`, +`KotlinApiModel`, `KotlinApiModelBuilder`, `KotlinApiDocsRenderer` and `DocsPaths`; the Kotlin +renderer writes its pages in code, so it has no templates. + +The generator is a `DocsGenerator`. List it in `` with `` set to your copy, +and `metaobjects:docs` runs it for its surface in place of the packaged one. `metaobjects:generate` +and `metaobjects:verify` skip it, because it writes pages under the docs directory and not code +under `outputDir`, and say so in the build log. The docs goal sets `outputDir`, `projectName`, +`apiSubDir`, `layout` and `modelBaseUrl` on it, and the pages land under +`/`. + +**It describes what the packaged generators emit.** The Java reference documents a controller, +a filter allowlist, a render helper only if the packaged generator that emits it would, which each +answers through its static `appliesTo`. The builder asks through `GeneratorApplicability` +(`GeneratorApplicability.packaged()`), never through a generator class, which is why the copy +depends on nothing but the engine. That holds in a project that owns one of those generators +too: an owned `routes` whose `appliesTo` you changed is still documented as the packaged one +behaves. To document your own surface, change the one line of your `JavaApiDocsGenerator` copy +that builds the `GeneratorApplicability`. The Kotlin builder asks the engine's own gates, which +the owned Kotlin generators share, so it has nothing to inject. + +### The docs, diagram and schema generators (Java) + +`plantuml`, `html-docs`, `ai-docs` and `json-schema` were registered, and wirable by class name, +before they were ejectable. Each is a generator and the writer that emits its file, and eject +copies both: `PlantUMLGenerator` with `PlantUMLWriter`, `MetaDataHtmlDocumentationGenerator` with +`MetaDataHtmlDocumentationWriter`, `MetaDataAIDocumentationGenerator` with +`MetaDataAIDocumentationWriter`, `MetaDataFileJsonSchemaGenerator` with `MetaDataFileSchemaWriter`. +The generator bases (`SingleFileDirectGeneratorBase`, `SingleJsonDirectGeneratorBase`, +`SingleHtmlDirectGeneratorBase`) and the writer bases they drive (`JsonDirectWriter`, +`HtmlDirectWriter`) stay in the package as engine. The attributes the JSON Schema and AI +documentation generators add to the metamodel are metamodel vocabulary, registered by +`CodeGenMetaDataProvider` and read by the loader, so they stay in the package too. The old static +methods on the two generators forward to it. + ### What eject hands over, and what stays core An owned generator is only yours if the rules it applies and the code its output depends @@ -1005,10 +1108,9 @@ have no edits of your own (`-Dlist` says `identical`), re-eject with `-Dforce`. `-Dport` is needed only when a name exists in both ports and your project's dependencies do not say which. Every Kotlin generator is ejectable. Every Java generator is too, except -`template`, the deprecated `mustache`, and four that are registered and do not ship eject -source yet (`plantuml`, `html-docs`, `ai-docs`, `json-schema`). `template` is the Mustache -primitive: the template you supply is its emit logic, so the generator has none of its own -to hand over. That holds in every port that ships it. +`template` and the deprecated `mustache`. `template` is the Mustache primitive: the template you +supply is its emit logic, so the generator has none of its own to hand over. That holds in every +port that ships it. ## C#: `dotnet meta eject` diff --git a/docs/ports/java.md b/docs/ports/java.md index 41b6a8875..f5ca073f1 100644 --- a/docs/ports/java.md +++ b/docs/ports/java.md @@ -288,10 +288,11 @@ name in every port. | `extractor` | `ExtractorCodeGenerator` | `metaobjects-codegen-base` | One `Extractor` per concrete object, wrapping the runtime tolerant extract and turning dirty LLM text into a fully-typed flavored object graph (nested objects + arrays-of-objects populated) in one call. Args: `outputDir`, and the `pkgPrefix` / `pkgSuffix` / `namePrefix` / `nameSuffix` your `entity` generator uses; give it the same `` too. Needs `entity` with a concrete `flavor` in the same run, since an extractor casts to the flavored class. It names `MetaObjectExtractor` (in `metaobjects-om`) by FQN string only, so `codegen-base` keeps no compile dependency on `om` — the reference resolves on the consumer's classpath. | | `trace-helper` | `LlmTraceHelperGenerator` | `metaobjects-codegen-spring` | One `TraceHelper` per concrete entity that transitively `extends` `metaobjects::ai::LlmCallBase` **and** nests a `template.prompt` carrying `@responseRef` — a static `record(...)` that extracts the typed response, builds the `LlmCallBase` trace row, and persists it. Emits nothing for any other entity. | | `template` | `TemplateScopeGenerator` | `metaobjects-codegen-base` | The generic Mustache primitive: walk the model by a named `scope`, render one shared template per walk result, write each file. The declarative alternative to writing a generator class — see [Declarative template-codegen](#declarative-template-codegen-templatescopegenerator) below. It renders through `render.templategen.TemplateGenerator`, the byte-pinned cross-port factory; that factory takes a walk callback and is not itself a `Generator`, so `TemplateScopeGenerator` is the class a pom names. | -| `plantuml` | `PlantUMLGenerator` | `metaobjects-codegen-plantuml` | One PlantUML class diagram of the object model (`outputDir`, `outputFilename`; `showAttrs`, `showFields`, `showAbstracts`, `drawKeys`, `drawRefs` choose what is drawn). Not ejectable yet. | -| `html-docs` | `MetaDataHtmlDocumentationGenerator` | `metaobjects-codegen-base` | One HTML page describing the registered metamodel types (`outputDir`, `outputFilename`). It reads the type registry, not your model, so it documents the vocabulary your build loads, providers included. Not ejectable yet. | -| `ai-docs` | `MetaDataAIDocumentationGenerator` | `metaobjects-codegen-base` | The same type reference as JSON, laid out for an AI reader (`outputDir`, `outputFilename`). Not ejectable yet. | -| `json-schema` | `MetaDataFileJsonSchemaGenerator` | `metaobjects-codegen-base` | A JSON Schema that validates metadata files, built from the type registry (`outputDir`, `outputFilename`). Not ejectable yet. | +| `api-docs` | `JavaApiDocsGenerator` | `metaobjects-codegen-spring` | The Java SDK api reference `mvn metaobjects:docs` writes: one page per entity, value object, projection, served report and template, an index and `AGENT-API.md`, under `/api/java`. A `DocsGenerator`: `metaobjects:generate` and `metaobjects:verify` skip it. List an owned copy in `` and the docs goal runs it in place of the packaged one. Ejectable: the copy takes the page IR, the builder, the renderer, the page paths and the page templates with it. | +| `plantuml` | `PlantUMLGenerator` | `metaobjects-codegen-plantuml` | One PlantUML class diagram of the object model (`outputDir`, `outputFilename`; `showAttrs`, `showFields`, `showAbstracts`, `drawKeys`, `drawRefs` choose what is drawn). Ejectable: the copy takes `PlantUMLWriter` with it. | +| `html-docs` | `MetaDataHtmlDocumentationGenerator` | `metaobjects-codegen-base` | One HTML page describing the registered metamodel types (`outputDir`, `outputFilename`). It reads the type registry, not your model, so it documents the vocabulary your build loads, providers included. Ejectable: the copy takes `MetaDataHtmlDocumentationWriter` with it. | +| `ai-docs` | `MetaDataAIDocumentationGenerator` | `metaobjects-codegen-base` | The same type reference as JSON, laid out for an AI reader (`outputDir`, `outputFilename`). Ejectable: the copy takes `MetaDataAIDocumentationWriter` with it. | +| `json-schema` | `MetaDataFileJsonSchemaGenerator` | `metaobjects-codegen-base` | A JSON Schema that validates metadata files, built from the type registry (`outputDir`, `outputFilename`). Ejectable: the copy takes `MetaDataFileSchemaWriter` with it. | | `mustache` | `MustacheTemplateGenerator` | `metaobjects-codegen-mustache` | Deprecated. The Mustache generator that predates `template`; it stays so a pom that names its class keeps building. Use `template`. It is not ejectable and will not be. | Wire any generator via the Maven plugin's `` entry pointing at its class. @@ -363,7 +364,7 @@ mvn metaobjects:eject -Dnames=value-object,output-parser -Dport=java -Dforce the copy and rewrites `codegen/pom.xml`, so review both diffs. For an edited copy, diff the new reference against it and apply the hunks by hand, as in [Pulling an upstream fix into your copy](../features/own-your-codegen.md#what-eject-hands-over-and-what-stays-core). -An owned `dto` copy needs nothing for this: it asks the packaged `Fr019SharedEnum` helper for +An owned `dto` copy needs nothing for this: it carries its own `Fr019SharedEnum` and asks it for each enum's type, so it follows the new rule without a change. ### Reports diff --git a/docs/ports/kotlin.md b/docs/ports/kotlin.md index aa3e8e4d6..72a748dd0 100644 --- a/docs/ports/kotlin.md +++ b/docs/ports/kotlin.md @@ -9,7 +9,7 @@ wiring) via KotlinPoet. Two modules: -- **`metaobjects-codegen-kotlin`** — 15 KotlinPoet-based generators. +- **`metaobjects-codegen-kotlin`** — 16 KotlinPoet-based generators. - **`metaobjects-metadata-ktx`** — thin Kotlin facade over the Java loader + render engine for idiomatic Kotlin runtime use. @@ -88,7 +88,7 @@ Two modules: ## Configure -The 15 generators registered in `codegen-kotlin` (`GeneratorRegistry.kt`): +The 16 generators registered in `codegen-kotlin` (`GeneratorRegistry.kt`): | Generator | Output | Per | |---|---|---| @@ -107,6 +107,7 @@ The 15 generators registered in `codegen-kotlin` (`GeneratorRegistry.kt`): | `KotlinStoredProcGenerator` | Stored-procedure call wrappers | entities with `source.rdb @kind="storedProc"` | | `KotlinSpringControllerGenerator` | `Controller.kt` — Spring `@RestController` (5 CRUD endpoints; cross-port API contract). **Select `KotlinRelationsGenerator` with it** when the model has a M:N relationship: the traversal routes call the `Query` helpers only that generator emits, and without it the controller does not compile | entities with `source.rdb @kind="table"` | | `KotlinRequirementTestsGenerator` | Per metamodel package that holds a tested requirement, two files in `testPackage`: `Requirements__Witnesses.kt` (an interface whose default members fail with `unimplemented requirement: ...`) and `Requirements__Test.kt` (one JUnit Jupiter `@Test` per requirement, calling a project-owned witness). See "Requirement tests" below | every tested `requirement.*` (functional, level 4 or above by default) | +| `KotlinApiDocsGenerator` | The Kotlin SDK api reference `mvn metaobjects:docs` writes under `/api/kotlin`: `.md` per entity, value object, projection, served report and template, a `README.md` index and `AGENT-API.md`. A `DocsGenerator`: `metaobjects:generate` and `metaobjects:verify` skip it, and `metaobjects:docs` runs your copy in place of the packaged one when `` names it. Ejectable, with the page model, builder, renderer and path math | the whole model, once | Maven wiring: @@ -177,11 +178,13 @@ A requirement that becomes live adds a failing member (a red test, no compile br **Own a generator.** `mvn metaobjects:eject -Dnames=` copies a reference generator into a `codegen/` Maven module under your own package, and prints the module, dependency and `` to wire. See [Own your codegen → Java and Kotlin](../features/own-your-codegen.md#java-and-kotlin-mvn-metaobjectseject). +**Ejecting hands over the emitters a generator writes through.** `entity` takes `KotlinEnumEmitter` with it, `output-parser` takes `KotlinExtractSchemaEmitter`, and `output-prompt` takes `KotlinOutputFormatSpecEmitter` and `KotlinExtractSchemaEmitter`, into the same package, with the imports the copies have of one another pointed at the copies. `Fr019SharedEnum` stays in the package: it writes nothing and the type mapper takes its `ProvidedEnumConfig` as an argument. See [Own your codegen → emitters](../features/own-your-codegen.md#ejecting-a-generator-hands-over-the-emitters-it-writes-through-java-and-kotlin). + **Nothing else to hand over.** Unlike the Java `routes`/`dto`/`repository` output, no Kotlin generator's output imports helper runtime. The generated controller declares its filter parser, constraint mapping and patch handling inline, per file, and the rest imports only core: render and extract, the loader, and `metadata-ktx`. So an ejected Kotlin generator's output depends on nothing you do not own beyond the core. `EjectRuntimeRoundTripTest` in the Maven plugin checks this against every Kotlin generator's real output. `com.metaobjects.generator.kotlin.runtime.M2mJoinResolver` is a helper you may call from hand-written traversal code. No generated file imports it, so eject does not copy it; copy it yourself if you use it and want to own it. ### Declarative template-codegen (`TemplateScopeGenerator`) -The 15 generators above are a starting point, not the ceiling. When you need a shape +The 16 generators above are a starting point, not the ceiling. When you need a shape none of them emits, Kotlin has **both** authoring paths — and which to reach for is a real decision (tradeoff table: [`codegen-concepts.md` §3](../features/codegen-concepts.md)). diff --git a/fixtures/generator-registry-conformance/registry.json b/fixtures/generator-registry-conformance/registry.json index ab2cd2306..87f53715c 100644 --- a/fixtures/generator-registry-conformance/registry.json +++ b/fixtures/generator-registry-conformance/registry.json @@ -362,6 +362,8 @@ "java": { "codegenPackages": ["com.metaobjects.generator.**"], "engine": [ + "com.metaobjects.generator.CodeGenMetaDataProvider", + "com.metaobjects.generator.DocsGenerator", "com.metaobjects.generator.EmitsObjectExtractors", "com.metaobjects.generator.EmitsPhysicalNameConstants", "com.metaobjects.generator.FileEmittingGenerator", @@ -372,9 +374,15 @@ "com.metaobjects.generator.GeneratorIOWriter", "com.metaobjects.generator.MetaDataFilters", "com.metaobjects.generator.ProjectClassLoaderAware", + "com.metaobjects.generator.apidocs.GeneratorApplicability", "com.metaobjects.generator.direct.FileDirectWriter", "com.metaobjects.generator.direct.GenerationContext", "com.metaobjects.generator.direct.MultiFileDirectGeneratorBase", + "com.metaobjects.generator.direct.SingleFileDirectGeneratorBase", + "com.metaobjects.generator.direct.metadata.html.HtmlDirectWriter", + "com.metaobjects.generator.direct.metadata.html.SingleHtmlDirectGeneratorBase", + "com.metaobjects.generator.direct.metadata.json.JsonDirectWriter", + "com.metaobjects.generator.direct.metadata.json.SingleJsonDirectGeneratorBase", "com.metaobjects.generator.direct.object.BaseObjectCodeGenerator", "com.metaobjects.generator.requirement.RenderedTest", "com.metaobjects.generator.requirement.RequirementTestArgs", @@ -394,6 +402,7 @@ "kotlin": { "codegenPackages": ["com.metaobjects.generator.**"], "engine": [ + "com.metaobjects.generator.DocsGenerator", "com.metaobjects.generator.EmitsPhysicalNameConstants", "com.metaobjects.generator.FileEmittingGenerator", "com.metaobjects.generator.Generator", @@ -405,6 +414,7 @@ "com.metaobjects.generator.kotlin.ExposedApi", "com.metaobjects.generator.kotlin.ExposedImports", "com.metaobjects.generator.kotlin.FindInbound", + "com.metaobjects.generator.kotlin.Fr019SharedEnum", "com.metaobjects.generator.kotlin.KotlinGenUtil", "com.metaobjects.generator.kotlin.KotlinM2mSupport", "com.metaobjects.generator.kotlin.KotlinNaming", @@ -450,8 +460,6 @@ } ], "pending": [ - {"generator": "api-docs", "port": "java", "assertion": "census", "gap": 6}, - {"generator": "api-docs", "port": "kotlin", "assertion": "census", "gap": 6}, {"generator": "api-docs", "port": "python", "assertion": "census", "gap": 6}, { "generator": "entity", @@ -473,76 +481,6 @@ {"generator": "routes", "port": "python", "assertion": "depth", "gap": 4, "outside": ["metaobjects.codegen.fr010_field_mapping"]}, {"generator": "routes", "port": "python", "assertion": "depth", "gap": 5, "outside": ["metaobjects.codegen.generators.m2m_codegen"]}, {"generator": "trace-helper", "port": "python", "assertion": "depth", "gap": 4, "outside": ["metaobjects.codegen.extract_schema_emitter"]}, - {"generator": "ai-docs", "port": "java", "assertion": "completeness", "gap": 7}, - {"generator": "html-docs", "port": "java", "assertion": "completeness", "gap": 7}, - {"generator": "json-schema", "port": "java", "assertion": "completeness", "gap": 7}, - {"generator": "plantuml", "port": "java", "assertion": "completeness", "gap": 7}, - { - "generator": "dto", - "port": "java", - "assertion": "depth", - "gap": 5, - "outside": ["com.metaobjects.generator.spring.Fr019SharedEnum", "com.metaobjects.generator.spring.SpringRecordBuilder"] - }, - { - "generator": "entity", - "port": "java", - "assertion": "depth", - "gap": 13, - "outside": ["com.metaobjects.generator.direct.object.javacode.ExtractorCodeGenerator"] - }, - {"generator": "output-parser", "port": "java", "assertion": "depth", "gap": 5, "outside": ["com.metaobjects.generator.spring.Fr019SharedEnum"]}, - { - "generator": "output-prompt", - "port": "java", - "assertion": "depth", - "gap": 4, - "outside": ["com.metaobjects.generator.spring.OutputFormatSpecEmitter"] - }, - { - "generator": "value-object", - "port": "java", - "assertion": "depth", - "gap": 5, - "outside": ["com.metaobjects.generator.spring.Fr019SharedEnum", "com.metaobjects.generator.spring.SpringRecordBuilder"] - }, - { - "generator": "entity", - "port": "kotlin", - "assertion": "depth", - "gap": 5, - "outside": ["com.metaobjects.generator.kotlin.Fr019SharedEnum", "com.metaobjects.generator.kotlin.KotlinEnumEmitter"] - }, - { - "generator": "exposed-table", - "port": "kotlin", - "assertion": "depth", - "gap": 5, - "outside": ["com.metaobjects.generator.kotlin.Fr019SharedEnum"] - }, - { - "generator": "output-parser", - "port": "kotlin", - "assertion": "depth", - "gap": 4, - "outside": ["com.metaobjects.generator.kotlin.KotlinExtractSchemaEmitter"] - }, - { - "generator": "output-parser", - "port": "kotlin", - "assertion": "depth", - "gap": 5, - "outside": ["com.metaobjects.generator.kotlin.Fr019SharedEnum"] - }, - { - "generator": "output-prompt", - "port": "kotlin", - "assertion": "depth", - "gap": 4, - "outside": ["com.metaobjects.generator.kotlin.KotlinOutputFormatSpecEmitter"] - }, - {"generator": "relations", "port": "kotlin", "assertion": "depth", "gap": 5, "outside": ["com.metaobjects.generator.kotlin.Fr019SharedEnum"]}, - {"generator": "routes", "port": "kotlin", "assertion": "depth", "gap": 5, "outside": ["com.metaobjects.generator.kotlin.Fr019SharedEnum"]}, { "generator": "entity", "port": "typescript", diff --git a/server/java/README.md b/server/java/README.md index a9967dd45..ed4a7e36d 100644 --- a/server/java/README.md +++ b/server/java/README.md @@ -12,7 +12,7 @@ Only the loader and registry, runtime metadata access and the drift gates are a guarantee. The generators are reference helpers: copy one into your build with `mvn metaobjects:eject` and own it ([Own your codegen → Java and Kotlin](../../docs/features/own-your-codegen.md#java-and-kotlin-mvn-metaobjectseject)). -- **Codegen** *(reference generators)* — Spring REST + DTO + repository-interface emit (you supply the impl) (`codegen-spring`) and a Kotlin emit pipeline on KotlinPoet (`codegen-kotlin`), both ejectable; PlantUML diagrams (`codegen-plantuml`), registered and not ejectable yet; and the deprecated Mustache template engine (`codegen-mustache`), which is not ejectable and will not be. Output is hand-edit-preserving via three-way merge. +- **Codegen** *(reference generators)* — Spring REST + DTO + repository-interface emit (you supply the impl) (`codegen-spring`) and a Kotlin emit pipeline on KotlinPoet (`codegen-kotlin`), both ejectable; PlantUML diagrams (`codegen-plantuml`), ejectable; and the deprecated Mustache template engine (`codegen-mustache`), which is not ejectable and will not be. Output is hand-edit-preserving via three-way merge. - **Runtime metadata** — OMDB persistence layer over modernized JDBC with Spring-`@Transactional` integration. FR-003 fully shipped: binding registry, typed jsonb codec, source/origin metamodel, atomic mapping cache + JDBC codec registry + `inTransaction` template (Plan 4). Schema migrations are owned by the TypeScript toolchain (`@metaobjectsdev/cli migrate`); the `metaobjects:migrate` Maven goal was removed. Per the schema-authority consolidation the dev/test runtime auto-create path and `MetaClassDBValidatorService` were also removed — OMDB is now pure data-access (CRUD/query/codec/transactions only). - **Drift detection** — Template-drift: `Renderer.verify` checks `{{...}}` references against the payload VO at build time. The live-DB-schema mode of `metaobjects:verify` was removed; the goal now covers codegen drift (`mode=codegen`, the default) and template/prompt drift (`mode=templates`). - **Prompt construction** — `metaobjects-render` (Mustache + payload-VO + verify), FR-006 `template.output` parser-on-receipt codegen, render output byte-identical with the other four ports against the shared render-conformance corpus. diff --git a/server/java/codegen-base/pom.xml b/server/java/codegen-base/pom.xml index 7d585377d..2e300e314 100644 --- a/server/java/codegen-base/pom.xml +++ b/server/java/codegen-base/pom.xml @@ -99,8 +99,9 @@ Copied at BUILD TIME from the src/main/java files the compiler just compiled, so the resource IS the source: one file, one owner, nothing to drift. - These are "entity" (JavaObjectCodeGenerator) and "extractor" - (ExtractorCodeGenerator), and the rest of their emit, which eject copies + These are "entity" (JavaObjectCodeGenerator), "extractor" + (ExtractorCodeGenerator), "html-docs", "ai-docs" and "json-schema", and the rest + of their emit, which eject copies with them into the same owned package (GeneratorInfo.ejectWith(), ADR-0034 Amendment 6): the generator bases and the writers that emit the class body for "entity", and the writer "extractor" reads its names from. "template" @@ -137,6 +138,29 @@ BaseObjectCodeWriter.java + + + src/main/java/com/metaobjects/generator/direct/metadata/html + + MetaDataHtmlDocumentationGenerator.java + MetaDataHtmlDocumentationWriter.java + + + + src/main/java/com/metaobjects/generator/direct/metadata/ai + + MetaDataAIDocumentationGenerator.java + MetaDataAIDocumentationWriter.java + + + + src/main/java/com/metaobjects/generator/direct/metadata/file/json + + MetaDataFileJsonSchemaGenerator.java + MetaDataFileSchemaWriter.java + + diff --git a/server/java/codegen-base/src/main/java/com/metaobjects/generator/CodeGenMetaDataProvider.java b/server/java/codegen-base/src/main/java/com/metaobjects/generator/CodeGenMetaDataProvider.java index 33677a0d0..c8dd412a4 100644 --- a/server/java/codegen-base/src/main/java/com/metaobjects/generator/CodeGenMetaDataProvider.java +++ b/server/java/codegen-base/src/main/java/com/metaobjects/generator/CodeGenMetaDataProvider.java @@ -1,16 +1,16 @@ package com.metaobjects.generator; -import com.metaobjects.generator.direct.metadata.file.json.MetaDataFileJsonSchemaGenerator; -import com.metaobjects.generator.direct.metadata.ai.MetaDataAIDocumentationGenerator; import com.metaobjects.registry.MetaDataRegistry; import com.metaobjects.registry.MetaDataTypeProvider; /** * Code Generation MetaData provider that registers type extensions for code generation. * - *

This provider delegates to existing generator classes that contain the actual extension logic - * and constants. It supports JSON Schema generation and AI documentation generation by extending - * existing MetaData types with generation-specific attributes.

+ *

This provider holds the extension logic and constants itself, not the generators it serves, + * so a generator copied out with {@code mvn metaobjects:eject} owns emit code only: the attributes + * a loaded model may carry are metamodel vocabulary and stay with the loader. It supports JSON + * Schema generation and AI documentation generation by extending existing MetaData types with + * generation-specific attributes.

* *

Generators Supported:

*
    @@ -28,13 +28,104 @@ public class CodeGenMetaDataProvider implements MetaDataTypeProvider { @Override public void registerTypes(MetaDataRegistry registry) { - // Delegate to existing generator classes that contain the extension logic and constants - // Register JSON Schema generation type extensions - MetaDataFileJsonSchemaGenerator.registerJsonSchemaAttributes(registry); + registerJsonSchemaAttributes(registry); // Register AI Documentation generation type extensions - MetaDataAIDocumentationGenerator.registerAIDocAttributes(registry); + registerAIDocAttributes(registry); + } + + // JSON Schema generation attribute constants + public static final String JSON_SCHEMA_VERSION = "jsonSchemaVersion"; + public static final String JSON_SCHEMA_ID = "jsonSchemaId"; + public static final String JSON_TITLE = "jsonTitle"; + public static final String JSON_DESCRIPTION = "jsonDescription"; + public static final String JSON_FORMAT = "jsonFormat"; + public static final String JSON_PATTERN = "jsonPattern"; + public static final String JSON_ENUM = "jsonEnum"; + public static final String JSON_MINIMUM = "jsonMinimum"; + public static final String JSON_MAXIMUM = "jsonMaximum"; + + // AI Documentation generation attribute constants + public static final String AI_VERSION = "aiVersion"; + public static final String AI_DESCRIPTION = "aiDescription"; + public static final String AI_BUSINESS_RULE = "aiBusinessRule"; + public static final String AI_USAGE_CONTEXT = "aiUsageContext"; + public static final String AI_VALIDATION_RULES = "aiValidationRules"; + public static final String AI_EXAMPLES = "aiExamples"; + public static final String AI_CONSTRAINTS = "aiConstraints"; + public static final String AI_EXTENSION_GUIDANCE = "aiExtensionGuidance"; + public static final String AI_CROSS_LANGUAGE_INFO = "aiCrossLanguageInfo"; + + /** + * Registers JSON Schema generation attributes for use by the service provider pattern. + * Called by {@link #registerTypes} to extend existing MetaData types with JSON Schema-specific attributes. + */ + public static void registerJsonSchemaAttributes(MetaDataRegistry registry) { + // Object-level JSON Schema attributes + registry.findType("object", "base") + .optionalAttribute(JSON_SCHEMA_VERSION, "string") + .optionalAttribute(JSON_SCHEMA_ID, "string") + .optionalAttribute(JSON_TITLE, "string") + .optionalAttribute(JSON_DESCRIPTION, "string") + // Codegen object-level flags (template conditionals). Formerly registered on + // object.pojo; moved to object.base so the entity/value subtypes inherit them + // after the pojo/proxy/map subtype registrations were retired (ADR-0005). + .optionalAttribute("hasAuditing", "boolean") + .optionalAttribute("hasJpa", "boolean") + .optionalAttribute("hasValidation", "boolean"); + + // Field-level JSON Schema attributes + registry.findType("field", "base") + .optionalAttribute(JSON_TITLE, "string") + .optionalAttribute(JSON_DESCRIPTION, "string") + .optionalAttribute(JSON_FORMAT, "string") + .optionalAttribute(JSON_PATTERN, "string") + .optionalAttribute(JSON_ENUM, "string"); + + registry.findType("field", "string") + .optionalAttribute(JSON_PATTERN, "string") + .optionalAttribute(JSON_FORMAT, "string"); + + registry.findType("field", "int") + .optionalAttribute(JSON_MINIMUM, "int") + .optionalAttribute(JSON_MAXIMUM, "int"); + + registry.findType("field", "long") + .optionalAttribute(JSON_MINIMUM, "long") + .optionalAttribute(JSON_MAXIMUM, "long"); + } + + /** + * Registers AI Documentation generation attributes for use by the service provider pattern. + * Called by {@link #registerTypes} to extend existing MetaData types with AI documentation-specific attributes. + */ + public static void registerAIDocAttributes(MetaDataRegistry registry) { + // Object-level AI Documentation attributes + registry.findType("object", "base") + .optionalAttribute(AI_VERSION, "string") + .optionalAttribute(AI_DESCRIPTION, "string") + .optionalAttribute(AI_BUSINESS_RULE, "string") + .optionalAttribute(AI_USAGE_CONTEXT, "string") + .optionalAttribute(AI_EXTENSION_GUIDANCE, "string") + .optionalAttribute(AI_CROSS_LANGUAGE_INFO, "string"); + + // Field-level AI Documentation attributes + registry.findType("field", "base") + .optionalAttribute(AI_DESCRIPTION, "string") + .optionalAttribute(AI_BUSINESS_RULE, "string") + .optionalAttribute(AI_USAGE_CONTEXT, "string") + .optionalAttribute(AI_VALIDATION_RULES, "string") + .optionalAttribute(AI_EXAMPLES, "string") + .optionalAttribute(AI_CONSTRAINTS, "string"); + + registry.findType("field", "string") + .optionalAttribute(AI_EXAMPLES, "string") + .optionalAttribute(AI_VALIDATION_RULES, "string"); + + registry.findType("field", "int") + .optionalAttribute(AI_CONSTRAINTS, "string") + .optionalAttribute(AI_EXAMPLES, "string"); } @Override diff --git a/server/java/codegen-base/src/main/java/com/metaobjects/generator/DocsGenerator.java b/server/java/codegen-base/src/main/java/com/metaobjects/generator/DocsGenerator.java new file mode 100644 index 000000000..4d5ae091f --- /dev/null +++ b/server/java/codegen-base/src/main/java/com/metaobjects/generator/DocsGenerator.java @@ -0,0 +1,39 @@ +package com.metaobjects.generator; + +/** + * A generator that writes documentation, not code: the api reference {@code + * mvn metaobjects:docs} produces. + * + *

    {@code metaobjects:generate} writes code under {@code outputDir}, and its drift check, + * {@code metaobjects:verify}, regenerates code and diffs it against the committed tree. A docs + * generator does neither: its pages land under the docs output directory, relative to it, and + * are not code the build compiles. A generator that takes part in the docs run says so by + * implementing this interface. {@code generate} and {@code verify} skip it and say so, and + * {@code docs} runs it.

    + * + *

    One {@code } list serves all three goals. An owned {@code api-docs} copy is + * listed beside the code generators, and each goal runs the half that is its own. When the list + * holds a docs generator for a surface, {@code docs} runs it in place of the packaged one.

    + * + *

    The goal sets these args on the generator: {@link #ARG_PROJECT_NAME}, {@link + * #ARG_API_SUBDIR}, {@link #ARG_LAYOUT} and {@link #ARG_MODEL_BASE_URL}, plus {@code outputDir}, + * the docs output directory.

    + */ +public interface DocsGenerator extends Generator { + + /** The label the agent page carries in its header. */ + String ARG_PROJECT_NAME = "projectName"; + + /** Where under {@code outputDir} the api reference lands, e.g. {@code api/java}. */ + String ARG_API_SUBDIR = "apiSubDir"; + + /** The page layout: {@code flat} (one file per unit, the default) or {@code package}. */ + String ARG_LAYOUT = "layout"; + + /** When set, the base URL the model pages' back-links federate to. */ + String ARG_MODEL_BASE_URL = "modelBaseUrl"; + + /** The surface this generator documents: {@code java} or {@code kotlin}. {@code docs} hands + * the surface to the docs generator in the project's list that names it. */ + String surface(); +} diff --git a/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/metadata/ai/MetaDataAIDocumentationGenerator.java b/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/metadata/ai/MetaDataAIDocumentationGenerator.java index 32c94d019..3d99d3d76 100644 --- a/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/metadata/ai/MetaDataAIDocumentationGenerator.java +++ b/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/metadata/ai/MetaDataAIDocumentationGenerator.java @@ -1,5 +1,6 @@ package com.metaobjects.generator.direct.metadata.ai; +import com.metaobjects.generator.CodeGenMetaDataProvider; import com.metaobjects.generator.direct.metadata.json.SingleJsonDirectGeneratorBase; import com.metaobjects.generator.direct.metadata.json.JsonDirectWriter; import com.metaobjects.loader.MetaDataLoader; @@ -144,47 +145,42 @@ public String getConfigurationSummary() { /////////////////////////////////////////////////// // Service Provider Pattern Registration - - // AI Documentation generation attribute constants - public static final String AI_VERSION = "aiVersion"; - public static final String AI_DESCRIPTION = "aiDescription"; - public static final String AI_BUSINESS_RULE = "aiBusinessRule"; - public static final String AI_USAGE_CONTEXT = "aiUsageContext"; - public static final String AI_VALIDATION_RULES = "aiValidationRules"; - public static final String AI_EXAMPLES = "aiExamples"; - public static final String AI_CONSTRAINTS = "aiConstraints"; - public static final String AI_EXTENSION_GUIDANCE = "aiExtensionGuidance"; - public static final String AI_CROSS_LANGUAGE_INFO = "aiCrossLanguageInfo"; - - /** - * Registers AI Documentation generation attributes for use by the service provider pattern. - * Called by CodeGenMetaDataProvider to extend existing MetaData types with AI documentation-specific attributes. - */ + // + // The attributes and their registration live in CodeGenMetaDataProvider, which the loader + // consults, so a copy of this generator made by `mvn metaobjects:eject` owns emit code only. + // These names stay here for existing callers. + + /** @deprecated use {@link CodeGenMetaDataProvider#AI_VERSION} */ + @Deprecated + public static final String AI_VERSION = CodeGenMetaDataProvider.AI_VERSION; + /** @deprecated use {@link CodeGenMetaDataProvider#AI_DESCRIPTION} */ + @Deprecated + public static final String AI_DESCRIPTION = CodeGenMetaDataProvider.AI_DESCRIPTION; + /** @deprecated use {@link CodeGenMetaDataProvider#AI_BUSINESS_RULE} */ + @Deprecated + public static final String AI_BUSINESS_RULE = CodeGenMetaDataProvider.AI_BUSINESS_RULE; + /** @deprecated use {@link CodeGenMetaDataProvider#AI_USAGE_CONTEXT} */ + @Deprecated + public static final String AI_USAGE_CONTEXT = CodeGenMetaDataProvider.AI_USAGE_CONTEXT; + /** @deprecated use {@link CodeGenMetaDataProvider#AI_VALIDATION_RULES} */ + @Deprecated + public static final String AI_VALIDATION_RULES = CodeGenMetaDataProvider.AI_VALIDATION_RULES; + /** @deprecated use {@link CodeGenMetaDataProvider#AI_EXAMPLES} */ + @Deprecated + public static final String AI_EXAMPLES = CodeGenMetaDataProvider.AI_EXAMPLES; + /** @deprecated use {@link CodeGenMetaDataProvider#AI_CONSTRAINTS} */ + @Deprecated + public static final String AI_CONSTRAINTS = CodeGenMetaDataProvider.AI_CONSTRAINTS; + /** @deprecated use {@link CodeGenMetaDataProvider#AI_EXTENSION_GUIDANCE} */ + @Deprecated + public static final String AI_EXTENSION_GUIDANCE = CodeGenMetaDataProvider.AI_EXTENSION_GUIDANCE; + /** @deprecated use {@link CodeGenMetaDataProvider#AI_CROSS_LANGUAGE_INFO} */ + @Deprecated + public static final String AI_CROSS_LANGUAGE_INFO = CodeGenMetaDataProvider.AI_CROSS_LANGUAGE_INFO; + + /** @deprecated use {@link CodeGenMetaDataProvider#registerAIDocAttributes} */ + @Deprecated public static void registerAIDocAttributes(com.metaobjects.registry.MetaDataRegistry registry) { - // Object-level AI Documentation attributes - registry.findType("object", "base") - .optionalAttribute(AI_VERSION, "string") - .optionalAttribute(AI_DESCRIPTION, "string") - .optionalAttribute(AI_BUSINESS_RULE, "string") - .optionalAttribute(AI_USAGE_CONTEXT, "string") - .optionalAttribute(AI_EXTENSION_GUIDANCE, "string") - .optionalAttribute(AI_CROSS_LANGUAGE_INFO, "string"); - - // Field-level AI Documentation attributes - registry.findType("field", "base") - .optionalAttribute(AI_DESCRIPTION, "string") - .optionalAttribute(AI_BUSINESS_RULE, "string") - .optionalAttribute(AI_USAGE_CONTEXT, "string") - .optionalAttribute(AI_VALIDATION_RULES, "string") - .optionalAttribute(AI_EXAMPLES, "string") - .optionalAttribute(AI_CONSTRAINTS, "string"); - - registry.findType("field", "string") - .optionalAttribute(AI_EXAMPLES, "string") - .optionalAttribute(AI_VALIDATION_RULES, "string"); - - registry.findType("field", "int") - .optionalAttribute(AI_CONSTRAINTS, "string") - .optionalAttribute(AI_EXAMPLES, "string"); + CodeGenMetaDataProvider.registerAIDocAttributes(registry); } } \ No newline at end of file diff --git a/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/metadata/file/json/MetaDataFileJsonSchemaGenerator.java b/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/metadata/file/json/MetaDataFileJsonSchemaGenerator.java index 1a0707ca1..c2ed31ec3 100644 --- a/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/metadata/file/json/MetaDataFileJsonSchemaGenerator.java +++ b/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/metadata/file/json/MetaDataFileJsonSchemaGenerator.java @@ -1,5 +1,6 @@ package com.metaobjects.generator.direct.metadata.file.json; +import com.metaobjects.generator.CodeGenMetaDataProvider; import com.metaobjects.generator.GeneratorIOException; import com.metaobjects.generator.direct.metadata.json.SingleJsonDirectGeneratorBase; import com.metaobjects.generator.direct.metadata.json.JsonDirectWriter; @@ -78,54 +79,42 @@ public String toString() { /////////////////////////////////////////////////// // Service Provider Pattern Registration - - // JSON Schema generation attribute constants - public static final String JSON_SCHEMA_VERSION = "jsonSchemaVersion"; - public static final String JSON_SCHEMA_ID = "jsonSchemaId"; - public static final String JSON_TITLE = "jsonTitle"; - public static final String JSON_DESCRIPTION = "jsonDescription"; - public static final String JSON_FORMAT = "jsonFormat"; - public static final String JSON_PATTERN = "jsonPattern"; - public static final String JSON_ENUM = "jsonEnum"; - public static final String JSON_MINIMUM = "jsonMinimum"; - public static final String JSON_MAXIMUM = "jsonMaximum"; - - /** - * Registers JSON Schema generation attributes for use by the service provider pattern. - * Called by CodeGenMetaDataProvider to extend existing MetaData types with JSON Schema-specific attributes. - */ + // + // The attributes and their registration live in CodeGenMetaDataProvider, which the loader + // consults, so a copy of this generator made by `mvn metaobjects:eject` owns emit code only. + // These names stay here for existing callers. + + /** @deprecated use {@link CodeGenMetaDataProvider#JSON_SCHEMA_VERSION} */ + @Deprecated + public static final String JSON_SCHEMA_VERSION = CodeGenMetaDataProvider.JSON_SCHEMA_VERSION; + /** @deprecated use {@link CodeGenMetaDataProvider#JSON_SCHEMA_ID} */ + @Deprecated + public static final String JSON_SCHEMA_ID = CodeGenMetaDataProvider.JSON_SCHEMA_ID; + /** @deprecated use {@link CodeGenMetaDataProvider#JSON_TITLE} */ + @Deprecated + public static final String JSON_TITLE = CodeGenMetaDataProvider.JSON_TITLE; + /** @deprecated use {@link CodeGenMetaDataProvider#JSON_DESCRIPTION} */ + @Deprecated + public static final String JSON_DESCRIPTION = CodeGenMetaDataProvider.JSON_DESCRIPTION; + /** @deprecated use {@link CodeGenMetaDataProvider#JSON_FORMAT} */ + @Deprecated + public static final String JSON_FORMAT = CodeGenMetaDataProvider.JSON_FORMAT; + /** @deprecated use {@link CodeGenMetaDataProvider#JSON_PATTERN} */ + @Deprecated + public static final String JSON_PATTERN = CodeGenMetaDataProvider.JSON_PATTERN; + /** @deprecated use {@link CodeGenMetaDataProvider#JSON_ENUM} */ + @Deprecated + public static final String JSON_ENUM = CodeGenMetaDataProvider.JSON_ENUM; + /** @deprecated use {@link CodeGenMetaDataProvider#JSON_MINIMUM} */ + @Deprecated + public static final String JSON_MINIMUM = CodeGenMetaDataProvider.JSON_MINIMUM; + /** @deprecated use {@link CodeGenMetaDataProvider#JSON_MAXIMUM} */ + @Deprecated + public static final String JSON_MAXIMUM = CodeGenMetaDataProvider.JSON_MAXIMUM; + + /** @deprecated use {@link CodeGenMetaDataProvider#registerJsonSchemaAttributes} */ + @Deprecated public static void registerJsonSchemaAttributes(com.metaobjects.registry.MetaDataRegistry registry) { - // Object-level JSON Schema attributes - registry.findType("object", "base") - .optionalAttribute(JSON_SCHEMA_VERSION, "string") - .optionalAttribute(JSON_SCHEMA_ID, "string") - .optionalAttribute(JSON_TITLE, "string") - .optionalAttribute(JSON_DESCRIPTION, "string") - // Codegen object-level flags (template conditionals). Formerly registered on - // object.pojo; moved to object.base so the entity/value subtypes inherit them - // after the pojo/proxy/map subtype registrations were retired (ADR-0005). - .optionalAttribute("hasAuditing", "boolean") - .optionalAttribute("hasJpa", "boolean") - .optionalAttribute("hasValidation", "boolean"); - - // Field-level JSON Schema attributes - registry.findType("field", "base") - .optionalAttribute(JSON_TITLE, "string") - .optionalAttribute(JSON_DESCRIPTION, "string") - .optionalAttribute(JSON_FORMAT, "string") - .optionalAttribute(JSON_PATTERN, "string") - .optionalAttribute(JSON_ENUM, "string"); - - registry.findType("field", "string") - .optionalAttribute(JSON_PATTERN, "string") - .optionalAttribute(JSON_FORMAT, "string"); - - registry.findType("field", "int") - .optionalAttribute(JSON_MINIMUM, "int") - .optionalAttribute(JSON_MAXIMUM, "int"); - - registry.findType("field", "long") - .optionalAttribute(JSON_MINIMUM, "long") - .optionalAttribute(JSON_MAXIMUM, "long"); + CodeGenMetaDataProvider.registerJsonSchemaAttributes(registry); } } \ No newline at end of file diff --git a/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/metadata/html/HtmlDirectWriter.java b/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/metadata/html/HtmlDirectWriter.java new file mode 100644 index 000000000..0db4cc8a4 --- /dev/null +++ b/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/metadata/html/HtmlDirectWriter.java @@ -0,0 +1,21 @@ +package com.metaobjects.generator.direct.metadata.html; + +import com.metaobjects.generator.GeneratorIOException; +import com.metaobjects.generator.GeneratorIOWriter; +import com.metaobjects.loader.MetaDataLoader; + +/** + * Base class for HTML direct writers: what {@link SingleHtmlDirectGeneratorBase} drives. This is + * the HTML equivalent of {@code JsonDirectWriter}. The base names this type and not a writer + * that emits a particular page, so a generator copied out with {@code mvn metaobjects:eject} + * pairs with the copy of its own writer. + */ +public abstract class HtmlDirectWriter extends GeneratorIOWriter { + + public HtmlDirectWriter(MetaDataLoader loader) throws GeneratorIOException { + super(loader); + } + + /** Write the whole page to the stream the writer was given. */ + public abstract void writeHtml() throws GeneratorIOException; +} diff --git a/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/metadata/html/MetaDataHtmlDocumentationGenerator.java b/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/metadata/html/MetaDataHtmlDocumentationGenerator.java index 795850c3f..f04415096 100644 --- a/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/metadata/html/MetaDataHtmlDocumentationGenerator.java +++ b/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/metadata/html/MetaDataHtmlDocumentationGenerator.java @@ -1,5 +1,9 @@ package com.metaobjects.generator.direct.metadata.html; +// Eject note (ADR-0034 Amendment 8): `mvn metaobjects:eject` copies this file with its writer. The +// base class stays in com.metaobjects.generator.direct.metadata.html as engine, so it is imported by +// name — a bare same-package reference would not resolve from the copy. +import com.metaobjects.generator.direct.metadata.html.SingleHtmlDirectGeneratorBase; import com.metaobjects.generator.GeneratorIOException; import com.metaobjects.loader.MetaDataLoader; import org.slf4j.Logger; diff --git a/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/metadata/html/MetaDataHtmlDocumentationWriter.java b/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/metadata/html/MetaDataHtmlDocumentationWriter.java index 63a18877c..e8c4a8011 100644 --- a/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/metadata/html/MetaDataHtmlDocumentationWriter.java +++ b/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/metadata/html/MetaDataHtmlDocumentationWriter.java @@ -1,6 +1,7 @@ package com.metaobjects.generator.direct.metadata.html; -import com.metaobjects.generator.GeneratorIOWriter; +// Imported by name: this file is copied out by `mvn metaobjects:eject`, and the base stays in the package. +import com.metaobjects.generator.direct.metadata.html.HtmlDirectWriter; import com.metaobjects.generator.GeneratorIOException; import com.metaobjects.loader.MetaDataLoader; import com.metaobjects.registry.MetaDataRegistry; @@ -38,7 +39,7 @@ * - Plugin and extension development * - Cross-team collaboration and knowledge sharing */ -public class MetaDataHtmlDocumentationWriter extends GeneratorIOWriter { +public class MetaDataHtmlDocumentationWriter extends HtmlDirectWriter { private static final Logger log = LoggerFactory.getLogger(MetaDataHtmlDocumentationWriter.class); @@ -91,6 +92,7 @@ public MetaDataHtmlDocumentationWriter withExtensionGuide(boolean includeExtensi return this; } + @Override public void writeHtml() throws GeneratorIOException { try { log.info("Generating professional HTML documentation for {} types", diff --git a/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/metadata/html/SingleHtmlDirectGeneratorBase.java b/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/metadata/html/SingleHtmlDirectGeneratorBase.java index 6b74cbc81..304f34190 100644 --- a/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/metadata/html/SingleHtmlDirectGeneratorBase.java +++ b/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/metadata/html/SingleHtmlDirectGeneratorBase.java @@ -19,7 +19,7 @@ public void execute(MetaDataLoader loader) { File outf = null; OutputStream fos = null; - MetaDataHtmlDocumentationWriter writer = null; + HtmlDirectWriter writer = null; parseArgs(); @@ -54,9 +54,9 @@ public void execute(MetaDataLoader loader) { /////////////////////////////////////////////////////////////////////////////////////// // Implementation Methods - protected abstract MetaDataHtmlDocumentationWriter getWriter(MetaDataLoader loader, OutputStream os) throws GeneratorIOException; + protected abstract HtmlDirectWriter getWriter(MetaDataLoader loader, OutputStream os) throws GeneratorIOException; - protected void writeHtml(MetaDataHtmlDocumentationWriter writer) throws GeneratorIOException { + protected void writeHtml(HtmlDirectWriter writer) throws GeneratorIOException { log.info("Writing HTML file: " + writer.getFilename()); writer.writeHtml(); } 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..f4f93ab25 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 { @@ -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..1a2c5ed6b --- /dev/null +++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/apidocs/KotlinApiDocsGenerator.kt @@ -0,0 +1,61 @@ +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.DocsGenerator +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`. + * + * Run by `mvn metaobjects:docs`, not by `mvn metaobjects:generate`: it implements [DocsGenerator], + * which `generate` and `verify` skip. Its files land under `outputDir`, the docs directory, in + * [DocsGenerator.ARG_API_SUBDIR]. The project label, the layout and the model back-link base come + * from the [DocsGenerator] args the goal sets, and where a `@provided` enum is referenced from + * comes from the same enum args every Kotlin generator takes ([Fr019SharedEnum.ProvidedEnumConfig]). + */ +class KotlinApiDocsGenerator : FileEmittingGenerator(), DocsGenerator { + + override fun surface(): String = "kotlin" + + override fun generate(loader: MetaDataLoader): List { + val project = getArg(DocsGenerator.ARG_PROJECT_NAME, "project") + val subDir = getArg(DocsGenerator.ARG_API_SUBDIR, DEFAULT_API_SUBDIR) + val modelBaseUrl = if (hasArg(DocsGenerator.ARG_MODEL_BASE_URL)) getArg(DocsGenerator.ARG_MODEL_BASE_URL) else null + val layout = if (getArg(DocsGenerator.ARG_LAYOUT, "flat").equals("package", ignoreCase = true)) { + DocsPaths.Layout.PACKAGE + } else { + DocsPaths.Layout.FLAT + } + + 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 { + /** The api-surface subdirectory of the cross-port docs contract. */ + const val DEFAULT_API_SUBDIR: String = "api/kotlin" + } +} 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..71f005255 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.factory() !is com.metaobjects.generator.DocsGenerator } + /** 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..a9ae91535 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 @@ -235,13 +277,38 @@ public String toString() { private static final List ENTITY_EMIT = List.of( JavaCodeGenerator.class.getName(), BaseObjectCodeGenerator.class.getName(), BaseObjectCodeWriter.class.getName(), JavaCodeWriter.class.getName(), - PojoAwareCodeWriter.class.getName(), ValueObjectCodeWriter.class.getName()); + PojoAwareCodeWriter.class.getName(), ValueObjectCodeWriter.class.getName(), + ExtractorCodeGenerator.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 +338,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,10 +376,10 @@ 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)); @@ -343,12 +418,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 +438,34 @@ 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(), + "api-docs: `mvn metaobjects:docs` runs this generator, not `metaobjects:generate`, which " + + "skips it. List it in with {owned} and the " + + "docs goal runs your copy for the java surface in place of the packaged one.", + 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 +494,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..b330cc178 --- /dev/null +++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/apidocs/JavaApiDocsGenerator.java @@ -0,0 +1,78 @@ +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.DocsGenerator; +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}. + * + *

    Run by {@code mvn metaobjects:docs}, not by {@code mvn metaobjects:generate}: it implements + * {@link DocsGenerator}, which {@code generate} and {@code verify} skip. Its files land under + * {@code outputDir}, the docs directory, in {@link #ARG_API_SUBDIR}. The project label, the layout + * and the model back-link base come from the {@link DocsGenerator} args the goal sets.

    + * + *

    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 implements DocsGenerator { + + /** The api-surface subdirectory of the cross-port docs contract. */ + public static final String DEFAULT_API_SUBDIR = "api/java"; + + 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 + public String surface() { + return "java"; + } + + @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 = LAYOUT_PACKAGE.equalsIgnoreCase(getArg(ARG_LAYOUT, "flat")) + ? DocsPaths.Layout.PACKAGE : DocsPaths.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; + } + + /** 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/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..f2052cd27 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()); } 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..d2d063c25 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 @@ -7,6 +7,7 @@ import com.metaobjects.agentcontext.AgentContextScaffold; import com.metaobjects.generator.Generator; import com.metaobjects.generator.GeneratorBase; +import com.metaobjects.generator.DocsGenerator; import com.metaobjects.generator.ProjectClassLoaderAware; import com.metaobjects.generator.direct.MultiFileDirectGeneratorBase; import com.metaobjects.generator.spring.SpringRenderHelperGenerator; @@ -295,6 +296,24 @@ protected void warnIfAgentContextStale() { protected abstract void executeGenerators(MetaDataLoader loader, List generatorImpls); + /** + * Whether {@code g} names a {@link DocsGenerator}: a generator that writes documentation, + * which {@code metaobjects:docs} runs and {@code generate} and {@code verify} leave alone. + */ + protected boolean isDocsGenerator(GeneratorParam g, ClassLoader projectClassLoader) { + try { + return DocsGenerator.class.isAssignableFrom(projectClassLoader.loadClass(g.getClassname())); + } catch (ClassNotFoundException e) { + throw new MetaDataException( "Error running generator ["+g.getClassname()+"]: "+e, e ); + } + } + + /** Say that {@code generate} / {@code verify} is skipping a docs generator, and which goal runs it. */ + protected void logSkippedDocsGenerator(String goal, String classname) { + getLog().info(goal + " > Skipping docs generator " + classname + + ": it writes documentation, and `mvn metaobjects:docs` runs it."); + } + protected MetaDataLoader createLoader(ClassLoader projectClassLoader) { LoaderConfigurable configurable = null; 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..9c0c0345e 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,6 +1,8 @@ package com.metaobjects.mojo; +import com.metaobjects.generator.DocsGenerator; import com.metaobjects.generator.Generator; +import com.metaobjects.generator.GeneratorBase; import com.metaobjects.loader.MetaDataLoader; import org.apache.maven.plugin.MojoExecutionException; import org.apache.maven.plugin.MojoFailureException; @@ -9,12 +11,7 @@ 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.HashMap; import java.util.List; import java.util.Locale; import java.util.Map; @@ -26,8 +23,9 @@ *

    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. + * hook. This goal needs only the loaded loader. It reads the configured {@code } + * for one thing: a {@link DocsGenerator} it names for a surface, which it runs in place of the + * packaged one. Every other configured generator is left alone. * *

    The {@link #language} parameter selects the native SDK surface: *

      @@ -38,9 +36,12 @@ * 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 + *

      The Mojo is wiring only: each surface is a {@link DocsGenerator} ({@code api-docs}) that + * writes its pages under {@link #outputDirectory}. When the project's {@code } list + * holds a docs generator for a surface, the goal runs that one, so a project that ejected + * {@code api-docs} runs its own copy. 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}. */ @Mojo(name = "docs", @@ -100,119 +101,64 @@ protected void executeGenerators(MetaDataLoader loader, List generato + "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); + runSurface(lang, subDir, loader, generatorImpls); } } - // ----- 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)); + /** + * Run the docs generator for one surface: the one the project's {@code } list + * names for it, when it names one (an owned copy of {@code api-docs}), else the packaged one. + * The goal is wiring only: every page, name and path comes from the generator. + */ + private void runSurface(String lang, String subDir, MetaDataLoader loader, List generatorImpls) { + DocsGenerator docs = null; + Map args = null; + for (int i = 0; i < generatorImpls.size(); i++) { + if (generatorImpls.get(i) instanceof DocsGenerator + && lang.equals(((DocsGenerator) generatorImpls.get(i)).surface())) { + docs = (DocsGenerator) generatorImpls.get(i); + args = mergeAndOverwriteArgs(getGenerators().get(i)); + break; + } } - 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)); + boolean owned = docs != null; + if (!owned) { + docs = packaged(lang); + args = new HashMap<>(); + if (getGlobals() != null) args.putAll(getGlobals()); + docs.setFilters(List.of()); } - 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); + args.put(GeneratorBase.ARG_OUTPUTDIR, outputDirectory.getAbsolutePath()); + args.put(DocsGenerator.ARG_PROJECT_NAME, project != null ? project.getArtifactId() : "project"); + args.put(DocsGenerator.ARG_API_SUBDIR, subDir); + args.put(DocsGenerator.ARG_LAYOUT, "package".equalsIgnoreCase(layout) ? "package" : "flat"); + if (modelBaseUrl != null && !modelBaseUrl.isEmpty()) { + args.put(DocsGenerator.ARG_MODEL_BASE_URL, modelBaseUrl); } - getLog().info("metaobjects:docs — wrote " + emitted.size() + " api pages into " + apiRoot); + docs.setArgs(args); + docs.execute(loader); + getLog().info("metaobjects:docs — ran " + (owned ? "your" : "the packaged") + " " + lang + + " api-docs generator " + docs.getClass().getName() + " into " + + outputDirectory.toPath().resolve(subDir)); } - /** 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); + /** The packaged {@code api-docs} generator for a surface. */ + private static DocsGenerator packaged(String lang) { + switch (lang) { + case LANG_JAVA: + return new com.metaobjects.generator.apidocs.JavaApiDocsGenerator(); + case LANG_KOTLIN: + return new com.metaobjects.generator.kotlin.apidocs.KotlinApiDocsGenerator(); + default: + throw new IllegalStateException( + "metaobjects:docs — unknown language '" + lang + "' (supported: java, kotlin)"); } - emitted.put(path, content); } } 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..07874159d 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,6 +83,13 @@ 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; @@ -102,8 +112,11 @@ 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 @@ -141,31 +154,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 +273,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 +294,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 +470,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 +479,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..9b3ff64f1 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 @@ -205,6 +205,9 @@ public void execute() throws MojoExecutionException, MojoFailureException { if (e.port == EjectSupport.Port.JAVA) { support.addAll(EjectSupport.supportFor(rewritten)); rewritten = EjectSupport.rewriteSupportImports(rewritten, pkg); + if (!e.resources.isEmpty()) { + rewritten = EjectSupport.rewriteTemplatePrefix(rewritten, pkg); + } } String what = generator ? "\"" + e.stableName + "\"" @@ -216,6 +219,7 @@ public void execute() throws MojoExecutionException, MojoFailureException { } 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); @@ -234,6 +238,49 @@ 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 owned package's path. 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<>(); + Path dir = resourceRoot.resolve(EjectSupport.ownedTemplatePrefix(pkg)); + for (EjectSupport.Resolution r : resolved) { + for (String ref : r.entry.resources) { + if (!written.add(ref)) 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); + } + Path target = dir.resolve(ref + EjectSupport.TEMPLATE_EXTENSION); + 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 // ------------------------------------------------------------------------------------ diff --git a/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/MetaDataGeneratorMojo.java b/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/MetaDataGeneratorMojo.java index a2e75ae49..6d1f7cf2b 100644 --- a/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/MetaDataGeneratorMojo.java +++ b/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/MetaDataGeneratorMojo.java @@ -1,5 +1,6 @@ package com.metaobjects.mojo; +import com.metaobjects.generator.DocsGenerator; import com.metaobjects.generator.Generator; import com.metaobjects.generator.GeneratorBase; import com.metaobjects.generator.util.GeneratedFileWriter; @@ -43,6 +44,10 @@ protected void executeGenerators(MetaDataLoader loader, List generato // a green build. Opening the run here is what makes the check reach a real build. try (GeneratedFileWriter.Run run = GeneratedFileWriter.beginRun()) { for( Generator gen : generatorImpls ) { + if ( gen instanceof DocsGenerator ) { + logSkippedDocsGenerator("MetaData Mojo", gen.getClass().getName()); + continue; + } getLog().info("MetaData Mojo > Executing Generator: " + gen.getClass().getName() ); run.attributeTo( gen.getClass().getSimpleName() ); gen.execute( loader ); 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..e6b3172df 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 @@ -1,5 +1,6 @@ package com.metaobjects.mojo; +import com.metaobjects.generator.DocsGenerator; import com.metaobjects.generator.Generator; import com.metaobjects.generator.GeneratorBase; import com.metaobjects.generator.verify.TemplateVerify; @@ -335,6 +336,8 @@ private void verifyCodegen() throws MojoExecutionException, MojoFailureException if (getGenerators() != null) { for (GeneratorParam g : getGenerators()) { + // A docs generator writes pages, not code: nothing here to drift-check. + if (isDocsGenerator(g, projectClassLoader)) continue; Map merged = mergeAndOverwriteArgs(g); String realDir = merged.get(ARG_OUTPUT_DIR); if (realDir == null) { @@ -360,6 +363,10 @@ private void verifyCodegen() throws MojoExecutionException, MojoFailureException List generatorImpls = buildGenerators(projectClassLoader, overrides); try { for (Generator gen : generatorImpls) { + if (gen instanceof DocsGenerator) { + logSkippedDocsGenerator("MetaData Verify Mojo", gen.getClass().getName()); + continue; + } getLog().info("MetaData Verify Mojo > Regenerating (temp): " + gen.getClass().getName()); gen.execute(loader); } diff --git a/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/DocsGeneratorGoalsTest.java b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/DocsGeneratorGoalsTest.java new file mode 100644 index 000000000..573c446a3 --- /dev/null +++ b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/DocsGeneratorGoalsTest.java @@ -0,0 +1,128 @@ +package com.metaobjects.mojo; + +import com.metaobjects.generator.kotlin.KotlinEntityGenerator; +import com.metaobjects.mojo.test.OwnedDocsGenerator; +import org.apache.maven.plugin.testing.MojoRule; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; + +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; + +/** + * A {@link com.metaobjects.generator.DocsGenerator} writes documentation, not code: the docs goal + * runs the one the project lists for a surface in place of the packaged one, and the generate and + * verify goals leave it alone. + */ +public class DocsGeneratorGoalsTest { + + private static final String OWNED = OwnedDocsGenerator.class.getName(); + private static final String ENTITY = KotlinEntityGenerator.class.getName(); + + @Rule + public MojoRule rule = new MojoRule(); + + private Path work; + + @Before + public void scratch() throws Exception { + work = Files.createTempDirectory("docs-goals"); + } + + private void configure(AbstractMetaDataMojo mojo, GeneratorParam... generators) { + LoaderParam loader = LoaderParam.builder("docs-goals-test") + .withClassname("com.metaobjects.loader.MetaDataLoader") + .withSourceDir("./src/test/resources") + .withSource("mojo/verify-test-metadata.json") + .build(); + mojo.setLoader(loader); + mojo.setGenerators(new ArrayList<>(List.of(generators))); + mojo.setGlobals(Collections.emptyMap()); + } + + private GeneratorParam generator(String classname, Path outputDir) { + GeneratorParam.Builder b = GeneratorParam.builder(classname); + if (outputDir != null) b.withArg("outputDir", outputDir.toString()); + return b.build(); + } + + private static List files(Path root) throws Exception { + if (!Files.isDirectory(root)) return List.of(); + try (Stream s = Files.walk(root)) { + return s.filter(Files::isRegularFile).map(p -> root.relativize(p).toString()).sorted() + .collect(Collectors.toList()); + } + } + + @Test + public void theDocsGoalRunsTheOwnedGeneratorForItsSurfaceInPlaceOfThePackagedOne() throws Exception { + DocsMojo docs = new DocsMojo(); + configure(docs, generator(OWNED, null)); + Path docsDir = work.resolve("docs"); + rule.setVariableValueToObject(docs, "outputDirectory", docsDir.toFile()); + rule.setVariableValueToObject(docs, "language", "java"); + docs.execute(); + + // The owned generator's page, in the surface subdirectory the goal named, and none of the + // packaged reference's. + assertEquals(List.of("api/java/OWNED.md"), files(docsDir)); + assertEquals("owned docs for project\n", Files.readString(docsDir.resolve("api/java/OWNED.md"))); + } + + @Test + public void aSurfaceNoGeneratorOwnsStillGetsThePackagedReference() throws Exception { + DocsMojo docs = new DocsMojo(); + configure(docs, generator(OWNED, null)); + Path docsDir = work.resolve("docs"); + rule.setVariableValueToObject(docs, "outputDirectory", docsDir.toFile()); + rule.setVariableValueToObject(docs, "language", "java,kotlin"); + docs.execute(); + + List written = files(docsDir); + assertTrue(written.toString(), written.contains("api/java/OWNED.md")); + assertTrue(written.toString(), written.contains("api/kotlin/README.md")); + assertTrue(written.toString(), written.contains("api/kotlin/AGENT-API.md")); + assertFalse(written.toString(), written.contains("api/java/README.md")); + } + + @Test + public void theGenerateGoalSkipsADocsGeneratorAndRunsTheRest() throws Exception { + Path out = work.resolve("generated"); + MetaDataGeneratorMojo gen = new MetaDataGeneratorMojo(); + configure(gen, generator(ENTITY, out), generator(OWNED, out)); + gen.execute(); + + List written = files(out); + assertTrue("the code generator ran: " + written, written.stream().anyMatch(f -> f.endsWith(".kt"))); + assertFalse("the docs generator was skipped: " + written, written.stream().anyMatch(f -> f.endsWith("OWNED.md"))); + } + + @Test + public void theVerifyGoalSkipsADocsGeneratorEvenWithNoOutputDirOrCommittedPages() throws Exception { + Path committed = work.resolve("committed"); + MetaDataGeneratorMojo gen = new MetaDataGeneratorMojo(); + configure(gen, generator(ENTITY, committed)); + gen.execute(); + + // A docs generator with no outputDir would fail verify as unverifiable, and one with an + // outputDir would read as a page the repository lacks, if verify ran it. + MetaDataVerifyMojo verify = new MetaDataVerifyMojo(); + configure(verify, generator(ENTITY, committed), generator(OWNED, null)); + verify.execute(); + + MetaDataVerifyMojo withDir = new MetaDataVerifyMojo(); + configure(withDir, generator(ENTITY, committed), generator(OWNED, work.resolve("docs-not-committed"))); + withDir.execute(); + } +} diff --git a/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/EjectContractTest.java b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/EjectContractTest.java index 9d843c221..74e5e6414 100644 --- a/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/EjectContractTest.java +++ b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/EjectContractTest.java @@ -7,6 +7,8 @@ import com.metaobjects.generator.Generator; import com.metaobjects.generator.GeneratorRegistry; import com.metaobjects.generator.ProjectClassLoaderAware; +import com.metaobjects.generator.direct.DirectGeneratorBase; +import com.metaobjects.generator.direct.MultiFileDirectGeneratorBase; import com.metaobjects.loader.DirectorySource; import com.metaobjects.loader.LoaderOptions; import com.metaobjects.loader.MetaDataLoader; @@ -314,6 +316,9 @@ public void completenessTheShippedReferenceSourcesAreExactlyTheOnesTheRegistryNa if (e.resourcePath == null) continue; for (String classname : e.sources()) named.add(root + EjectSupport.simpleNameOf(classname) + ".java"); for (String runtime : e.runtime) named.add(GeneratorRegistry.EJECT_RUNTIME_RESOURCE_ROOT + runtime + ".java"); + for (String template : e.resources) { + named.add(GeneratorRegistry.EJECT_TEMPLATE_RESOURCE_ROOT + template + EjectSupport.TEMPLATE_EXTENSION); + } } for (String support : GeneratorRegistry.EJECT_SUPPORT) { named.add(GeneratorRegistry.EJECT_SUPPORT_RESOURCE_ROOT + support + ".java"); @@ -506,6 +511,11 @@ private Map run(Generator generator, Corpus corpus, MetaDataLoad args.put("testPackage", "com.example.requirements"); args.put("witnessClass", "com.example.Witnesses"); args.put("warnUncovered", "false"); + // The file the single-file generators write: the docs, diagram and schema generators. The + // per-object ones read this arg as the name of a file to merge into, so they must not get it. + if (generator instanceof DirectGeneratorBase && !(generator instanceof MultiFileDirectGeneratorBase)) { + args.put("outputFilename", "contract.out"); + } // The one difference an owned copy's output is allowed: it imports the helper runtime // the adopter owns. The packaged generator is pointed at the same package to compare. if (packaged) args.put("runtimePackage", OWNED_RUNTIME_PACKAGE); @@ -526,9 +536,13 @@ private Map run(Generator generator, Corpus corpus, MetaDataLoad public void depthAnUnchangedOwnedCopyEmitsWhatThePackagedGeneratorEmits() throws Exception { List silent = new ArrayList<>(); for (EjectSupport.Entry e : ejectable()) { - Path classes = compileAlone(e.stableName, ejectAlone(e.stableName)); + Path projectDir = ejectAlone(e.stableName); + Path classes = compileAlone(e.stableName, projectDir); int emitted = 0; - try (URLClassLoader cl = new URLClassLoader(new URL[]{classes.toUri().toURL()}, getClass().getClassLoader())) { + // The owned classes, and the resources eject wrote beside them: a generator that reads + // its page text from the classpath reads the owned copy, whose path no package shares. + URL[] ownedUrls = {classes.toUri().toURL(), projectDir.resolve("codegen/src/main/resources/").toUri().toURL()}; + try (URLClassLoader cl = new URLClassLoader(ownedUrls, getClass().getClassLoader())) { for (Corpus corpus : CORPORA) { String where = e.stableName + " over " + corpus.label; Generator packaged = (Generator) Class.forName(e.classname).getDeclaredConstructor().newInstance(); diff --git a/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/EjectRuntimeRoundTripTest.java b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/EjectRuntimeRoundTripTest.java index 0a5123ce6..b77e111c0 100644 --- a/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/EjectRuntimeRoundTripTest.java +++ b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/EjectRuntimeRoundTripTest.java @@ -140,6 +140,11 @@ public void eachJavaGeneratorsOutputImportsExactlyTheRuntimeItsEntryDeclares() t Generator gen = (Generator) Class.forName(e.classname).getDeclaredConstructor().newInstance(); Map a = args(out); a.put("runtimePackage", probe); + // A single-file generator (the docs, diagram and schema ones) is told its file's name; + // a per-object one given one would merge every object into it. + if (!(gen instanceof com.metaobjects.generator.direct.MultiFileDirectGeneratorBase)) { + a.put("outputFilename", e.stableName + ".out"); + } gen.setArgs(a).execute(loader); Set all = imports(out); diff --git a/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/EjectSupportTest.java b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/EjectSupportTest.java index 280dbc26a..ecc73216a 100644 --- a/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/EjectSupportTest.java +++ b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/EjectSupportTest.java @@ -55,16 +55,16 @@ public void entityAndExtractorAreEjectableOnBothPortsAndSoNeedAPort() { } @Test - public void onlyTheJavaExtractorCarriesAWiringNoteAndItNamesTheOwnedClass() { + public void onlyTheJavaExtractorAndApiDocsCarryAWiringNoteAndItNamesTheOwnedClass() { for (EjectSupport.Entry e : EjectSupport.allEntries()) { boolean javaExtractor = e.port == EjectSupport.Port.JAVA && e.stableName.equals("extractor"); - if (!javaExtractor) { + boolean javaApiDocs = e.port == EjectSupport.Port.JAVA && e.stableName.equals("api-docs"); + if (!javaExtractor && !javaApiDocs) { assertNull(e.port.id + " " + e.stableName, e.wiringNote("com.acme.codegen")); continue; } String note = e.wiringNote("com.acme.codegen"); - assertTrue(note, note.contains( - "com.acme.codegen.ExtractorCodeGenerator")); + assertTrue(note, note.contains("com.acme.codegen." + e.simpleName() + "")); assertFalse(note, note.contains("{owned}")); } } @@ -163,8 +163,8 @@ public void rewritePackageRejectsSourceWithNoPackageLine() { /** * ADR-0034 Amendment 6: an owned generator extends and calls the classes copied with it, * never the packaged ones. So an import of a copied class follows the copy, and an - * import of anything else (here the {@code extractor} generator, which is not part of - * {@code entity}'s copy, and a class whose name merely starts like a copied one) stays. + * import of anything else (here the file writer, which is engine, and a class whose name + * merely starts like a copied one) stays. */ @Test public void anImportOfAClassCopiedAlongFollowsTheCopyAndNoOtherImportMoves() { @@ -173,14 +173,14 @@ public void anImportOfAClassCopiedAlongFollowsTheCopyAndNoOtherImportMoves() { String src = "package com.metaobjects.generator.direct.object.javacode;\n\n" + "import com.metaobjects.generator.direct.object.BaseObjectCodeWriter;\n" + "import com.metaobjects.generator.direct.object.BaseObjectCodeWriterFactory;\n" - + "import com.metaobjects.generator.direct.object.javacode.ExtractorCodeGenerator;\n" + + "import com.metaobjects.generator.util.GeneratedFileWriter;\n" + "import com.metaobjects.generator.direct.GenerationContext;\n\n" + "public class Foo extends BaseObjectCodeWriter {}\n"; assertEquals("package com.metaobjects.generator.direct.object.javacode;\n\n" + "import com.acme.codegen.BaseObjectCodeWriter;\n" + "import com.metaobjects.generator.direct.object.BaseObjectCodeWriterFactory;\n" - + "import com.metaobjects.generator.direct.object.javacode.ExtractorCodeGenerator;\n" + + "import com.metaobjects.generator.util.GeneratedFileWriter;\n" + "import com.metaobjects.generator.direct.GenerationContext;\n\n" + "public class Foo extends BaseObjectCodeWriter {}\n", EjectSupport.rewriteCopiedImports(src, entity.sources(), "com.acme.codegen")); @@ -282,4 +282,66 @@ public void onlyTheJavaWebTierCarriesRuntime() { assertEquals(e.port.id + " " + e.stableName, expected, !e.runtime.isEmpty()); } } + + // ------------------------------------------------------------------------------------ + // Kotlin imports, template prefix, and the ownership comparison across copies + // ------------------------------------------------------------------------------------ + + @Test + public void aKotlinImportOfAClassCopiedAlongFollowsTheCopyWithoutGainingASemicolon() { + String src = "package com.metaobjects.generator.kotlin\n\n" + + "import com.metaobjects.generator.kotlin.KotlinEnumEmitter\n" + + "import com.metaobjects.generator.kotlin.KotlinEnumEmitterFactory\n" + + "import com.metaobjects.generator.kotlin.KotlinNaming\n\n" + + "class Foo\n"; + assertEquals("package com.metaobjects.generator.kotlin\n\n" + + "import com.acme.codegen.KotlinEnumEmitter\n" + + "import com.metaobjects.generator.kotlin.KotlinEnumEmitterFactory\n" + + "import com.metaobjects.generator.kotlin.KotlinNaming\n\n" + + "class Foo\n", + EjectSupport.rewriteCopiedImports(src, + List.of("com.metaobjects.generator.kotlin.KotlinEnumEmitter"), "com.acme.codegen")); + } + + @Test + public void everyKotlinEntryResolvesItsCopiedClassesToKotlinSources() { + EjectSupport.Entry entity = EjectSupport.resolve( + "entity", EjectSupport.ejectableByStableName(), EjectSupport.Port.KOTLIN, null).entry; + assertEquals(List.of("com.metaobjects.generator.kotlin.KotlinEntityGenerator", + "com.metaobjects.generator.kotlin.KotlinEnumEmitter"), entity.sources()); + assertTrue(entity.resourceOf("com.metaobjects.generator.kotlin.KotlinEnumEmitter"), + entity.resourceOf("com.metaobjects.generator.kotlin.KotlinEnumEmitter") + .endsWith("/kotlin/KotlinEnumEmitter.kt")); + } + + @Test + public void aCopiedTemplateProviderIsPointedAtTheOwnedPackagesTemplates() { + String src = "package com.metaobjects.generator.apidocs;\n\npublic final class P {\n" + + " private static final String BASE_PREFIX = \"templates/\";\n}\n"; + assertEquals("com/acme/codegen/templates/", EjectSupport.ownedTemplatePrefix("com.acme.codegen")); + String owned = EjectSupport.rewriteTemplatePrefix(src, "com.acme.codegen"); + assertTrue(owned, owned.contains("BASE_PREFIX = \"com/acme/codegen/templates/\";")); + // A source with no such declaration is returned as it came. + assertEquals("class X {}\n", EjectSupport.rewriteTemplatePrefix("class X {}\n", "com.acme.codegen")); + // The rewritten line is eject's own edit, so the copy still compares identical. + assertEquals(EjectSupport.Verdict.IDENTICAL, EjectSupport.compare( + EjectSupport.rewritePackage(owned, "com.acme.codegen"), src).verdict); + } + + @Test + public void aFileTwoGeneratorsCopyComparesIdenticalWhicheverOneWroteIt() { + // The extractor is copied with entity, which points its import of the entity's base at the + // owned copy, and on its own, which leaves that import on the engine. Either way it is the + // reference: the import is eject's own edit. + String ref = "package com.metaobjects.generator.direct.object.javacode;\n\n" + + "import com.metaobjects.generator.direct.object.BaseObjectCodeGenerator;\n" + + "import com.metaobjects.generator.util.GeneratorUtil;\n\npublic class E {}\n"; + String viaEntity = ref.replace("com.metaobjects.generator.direct.object.BaseObjectCodeGenerator", + "com.acme.codegen.BaseObjectCodeGenerator") + .replace("package com.metaobjects.generator.direct.object.javacode;", "package com.acme.codegen;"); + assertEquals(EjectSupport.Verdict.IDENTICAL, EjectSupport.compare(viaEntity, ref).verdict); + // An import of anything else is still compared. + assertEquals(EjectSupport.Verdict.DIFFERS, + EjectSupport.compare(viaEntity.replace("GeneratorUtil", "OtherUtil"), ref).verdict); + } } diff --git a/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/EntityExtractorEjectRoundTripTest.java b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/EntityExtractorEjectRoundTripTest.java index 69b2e0f53..ab5ced60a 100644 --- a/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/EntityExtractorEjectRoundTripTest.java +++ b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/EntityExtractorEjectRoundTripTest.java @@ -255,8 +255,9 @@ public void anEditToEachOwnedCopyShowsInItsOutput() throws Exception { Map entity = generate(owned(classes, OWNED_ENTITY), POJO); assertTrue(entity.get("com/metaobjects/generated/GeneratedObjectClassBindingProvider.java") .contains("GENERATED (owned entity copy)")); - // The owned entity still calls the PACKAGED extractor: it imports that class by name. - assertFalse(entity.get("fitness/ProgramExtractor.java").contains("owned extractor copy")); + // The owned entity writes its extractors through the owned extractor copy beside it, so an + // edit there reaches the files entity writes too, with no extractor generator in the run. + assertTrue(entity.get("fitness/ProgramExtractor.java").contains("owned extractor copy")); Map extractor = generate(owned(classes, OWNED_EXTRACTOR), Map.of()); assertTrue(extractor.get("fitness/ProgramExtractor.java").contains("GENERATED (owned extractor copy)")); @@ -373,15 +374,18 @@ public void theListingReportsBothAsEjectableAndMarksTheirOwnedCopies() throws Ex String edited = list(projectDir); assertTrue(row(edited, "extractor"), row(edited, "extractor").endsWith("[owned — DIFFERS: 1 behind, 1 of your own]")); - assertTrue(row(edited, "entity"), row(edited, "entity").endsWith("[owned — identical]")); + // entity copies the extractor too, so an edit to it shows on entity's row as well. + assertTrue(row(edited, "entity"), + row(edited, "entity").endsWith("[owned — DIFFERS: 1 behind, 1 of your own]")); // A writer is part of the generator it was copied with: an edit to one only entity - // has shows on entity's row, and leaves the extractor's count where it was. + // has shows on entity's row, on top of the extractor edit it already carries, and leaves + // the extractor's count where it was. edit(ownedDir.resolve("PojoAwareCodeWriter.java"), "println(true, \"super(mo);\");", "println(true, \"super(mo); // mine\");"); String writerEdited = list(projectDir); assertTrue(row(writerEdited, "entity"), - row(writerEdited, "entity").endsWith("[owned — DIFFERS: 1 behind, 1 of your own]")); + row(writerEdited, "entity").endsWith("[owned — DIFFERS: 2 behind, 2 of your own]")); assertTrue(row(writerEdited, "extractor"), row(writerEdited, "extractor").endsWith("[owned — DIFFERS: 1 behind, 1 of your own]")); } diff --git a/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/MetaDataEjectMojoTest.java b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/MetaDataEjectMojoTest.java index 79bd685ff..a6e1ca923 100644 --- a/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/MetaDataEjectMojoTest.java +++ b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/MetaDataEjectMojoTest.java @@ -330,4 +330,30 @@ public void namesIsRequiredWhenNotListing() { fail("expected MojoFailureException, got " + e); } } + + // ------------------------------------------------------------------------------------ + // api-docs: the templates its page text reads come with it + // ------------------------------------------------------------------------------------ + + @Test + public void ejectingTheJavaApiDocsWritesTheTemplatesAndPointsTheCopiedProviderAtThem() throws Exception { + mojo("api-docs", false, "java", null).execute(); + + Path src = codegenRoot().resolve("src/main/java/com/acme/codegen"); + for (String file : List.of("JavaApiDocsGenerator", "JavaApiModelBuilder", "JavaApiDocsRenderer", + "DocsPaths", "ApiUnit", "ClasspathTemplateProvider")) { + assertTrue(file, Files.isRegularFile(src.resolve(file + ".java"))); + } + // The page text is the adopter's, under the owned package's path so no jar can shadow it. + Path templates = codegenRoot().resolve("src/main/resources/com/acme/codegen/templates/api"); + for (String name : List.of("entity-api", "index", "agent-api")) { + assertTrue(name, Files.isRegularFile(templates.resolve(name + ".md.mustache"))); + } + String provider = Files.readString(src.resolve("ClasspathTemplateProvider.java"), StandardCharsets.UTF_8); + assertTrue(provider, provider.contains("BASE_PREFIX = \"com/acme/codegen/templates/\";")); + // The generator names the engine's applicability, never a generator class. + String generator = Files.readString(src.resolve("JavaApiDocsGenerator.java"), StandardCharsets.UTF_8); + assertFalse(generator, generator.contains("SpringDtoGenerator")); + assertTrue(generator, generator.contains("package com.acme.codegen;")); + } } diff --git a/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/test/OwnedDocsGenerator.java b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/test/OwnedDocsGenerator.java new file mode 100644 index 000000000..f13a9e241 --- /dev/null +++ b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/test/OwnedDocsGenerator.java @@ -0,0 +1,23 @@ +package com.metaobjects.mojo.test; + +import com.metaobjects.generator.DocsGenerator; +import com.metaobjects.generator.FileEmittingGenerator; +import com.metaobjects.loader.MetaDataLoader; + +import java.util.List; + +/** A docs generator of the adopter's own, for the goals that run or skip one. */ +public class OwnedDocsGenerator extends FileEmittingGenerator implements DocsGenerator { + + @Override + public String surface() { + return "java"; + } + + @Override + protected List generate(MetaDataLoader loader) { + String project = getArg(ARG_PROJECT_NAME, "none"); + String subDir = getArg(ARG_API_SUBDIR, "api/unset"); + return List.of(new EmittedFile(subDir + "/OWNED.md", "owned docs for " + project + "\n")); + } +} diff --git a/spec/decisions/ADR-0034-codegen-scaffold-and-own.md b/spec/decisions/ADR-0034-codegen-scaffold-and-own.md index fe8b74f20..95394b0fe 100644 --- a/spec/decisions/ADR-0034-codegen-scaffold-and-own.md +++ b/spec/decisions/ADR-0034-codegen-scaffold-and-own.md @@ -729,8 +729,8 @@ checked further by the guard. |---|---| | TypeScript | `requirement-walk.ts`, `relation-resolver.ts`, `render-context.ts`, `templates/find-inbound.ts`, `templates/find-templates.ts`, `render-engine/framework-provider.ts` | | C# | `ReportRows`, `PackageBindingResolver`, `GeneratorApplicability` | -| Java | `BaseObjectCodeGenerator`, `SpringM2mSupport`, `AutoSetSupport`, `TphPlan`, `FindInbound` | -| Kotlin | `ExposedApi`, `ExposedImports`, `KotlinGenUtil`, `KotlinM2mSupport` | +| Java | `BaseObjectCodeGenerator`, `SpringM2mSupport`, `AutoSetSupport`, `TphPlan`, `FindInbound`, `GeneratorApplicability`, `DocsGenerator`, `CodeGenMetaDataProvider`, the single-file generator bases and the JSON and HTML writer bases (`SingleFileDirectGeneratorBase`, `SingleJsonDirectGeneratorBase`, `SingleHtmlDirectGeneratorBase`, `JsonDirectWriter`, `HtmlDirectWriter`) | +| Kotlin | `ExposedApi`, `ExposedImports`, `KotlinGenUtil`, `KotlinM2mSupport`, `Fr019SharedEnum`, `DocsGenerator` | | Python | `value_objects`, `find_inbound`, `tph_plan`, `requirement_walk` | **Exemptions.** Each is an entry in the shared manifest with its reason. @@ -828,16 +828,16 @@ refusal is then `scripts/finish-release.mjs`, which withholds the tag. | 1 | Java `routes`, `dto` and `value-object` did not compile when ejected alone. | Closed before this amendment | | 2 | TypeScript `requirementsFile` and `agentDocsFile` were in no registry. | Registered here as `requirements` and `agent-docs`; reference templates pending | | 3 | TypeScript reference templates that call packaged composers. | Pending | -| 4 | Prompt-tier emitters that stay packaged in C#, Python, Java and Kotlin. | Closed in C#. Pending in Python, Java and Kotlin | -| 5 | Fragment emitters behind otherwise whole generators (shared enums, M:N navigation, doc comments, record builders). | Closed in C#. Pending in Python, Java and Kotlin | -| 6 | The docs command's emitter in C#, Python, Java and Kotlin is not a registered `api-docs`. | Closed in C#. Pending in Python, Java and Kotlin | -| 7 | Java generators outside the registry. | Registered here as `plantuml`, `html-docs`, `ai-docs`, `json-schema` and `mustache`; eject source pending for the first four | +| 4 | Prompt-tier emitters that stay packaged in C#, Python, Java and Kotlin. | Closed in C#, Java and Kotlin. Pending in Python | +| 5 | Fragment emitters behind otherwise whole generators (shared enums, M:N navigation, doc comments, record builders). | Closed in C#, Java and Kotlin. Pending in Python. Kotlin `Fr019SharedEnum` is engine, not handed over: it writes nothing, and the type mapper takes its `ProvidedEnumConfig` as an argument | +| 6 | The docs command's emitter in C#, Python, Java and Kotlin is not a registered `api-docs`. | Closed in C#, Java and Kotlin. Pending in Python | +| 7 | Java generators outside the registry. | Registered here as `plantuml`, `html-docs`, `ai-docs`, `json-schema` and `mustache`. The first four ship eject source, each with its writer; `mustache` is exempt | | 8 | The docs-site builders. | Exempt (ruling 3) | | 9 | Python generators that called another generator's packaged module. | Closed before this amendment | | 10 | Angular has no reference templates. | Outside the census until the package is published | | 11 | Tests that asserted a gap, and hand-kept lists. | Closed here | | 12 | Documents that claimed more than the code delivered. | Corrected here, below | -| 13 | An owned Java `entity` constructs the packaged `extractor` generator when no extractor is in the run. Found by the guard on its first run. | Pending. It closes with the Java work on gaps 4, 5 and 7, which owns the `entity` and `extractor` copies | +| 13 | An owned Java `entity` constructs the packaged `extractor` generator when no extractor is in the run. Found by the guard on its first run. | Closed. `entity` copies `ExtractorCodeGenerator` with it, so the owned `entity` constructs the owned one | **What the guard found on its first run**, beyond the gaps the audit listed: @@ -871,9 +871,15 @@ is. - **Kotlin depth does not run the eject goal.** The goal lives in `maven-plugin`, which depends on `codegen-kotlin`, and the Kotlin compile test has to live in `codegen-kotlin`. - The test reads the reference source the jar ships and rewrites its package line, which - is the one edit eject makes to a Kotlin copy. If eject starts doing more than that, this - test will not see it. + The test reads the reference sources the jar ships (the generator and the classes eject + copies with it), rewrites each package line and the imports the copies have of one + another, and compiles them together. Those are the two edits eject makes to a Kotlin copy. + If eject starts doing more than that, this test will not see it. +- **The Java docs generators' page templates are not in the reference scan.** `api-docs` + reads its page text from the classpath. Eject copies the templates into the adopter's + resources and points the copied provider at them, and the depth test puts those resources + on the owned classes' path so the copy reads them, but the scan sees classes, not text. A + template that called a packaged helper would not be seen. - **C# compiles the generator and the emitters eject hands over with it**, which is every file eject writes under `codegen/generators`. The helper-runtime files eject writes elsewhere are compiled by `HelperRuntimeTests`, against the web framework they need. From 181e7dafb9f11e0c6f52f1a38cb3701aa3fccdc2 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Mon, 12 Oct 2026 01:36:07 -0400 Subject: [PATCH 02/17] fix(eject): keep the 1.0 GeneratorInfo constructor and the HTML writeHtml hook; verify the emitter recipe - Kotlin GeneratorInfo keeps its six-argument constructor for JVM callers (@JvmOverloads), with a Java caller test. - SingleHtmlDirectGeneratorBase still calls a subclass's writeHtml(MetaDataHtmlDocumentationWriter) through a deprecated bridge, with a test. - The recipe for owning the emitters of an older owned copy is run against copies made by the last released artifacts, in Java and in Kotlin, and the docs say what it took: the copied Kotlin emitters import one another through the scratch package. --- CHANGELOG.md | 7 ++- docs/features/own-your-codegen.md | 37 +++++++++--- .../html/SingleHtmlDirectGeneratorBase.java | 17 ++++++ .../html/SingleHtmlBaseHookCompatTest.java | 60 +++++++++++++++++++ .../generator/kotlin/GeneratorRegistry.kt | 2 +- .../KotlinGeneratorInfoJavaCallerTest.java | 25 ++++++++ 6 files changed, 137 insertions(+), 11 deletions(-) create mode 100644 server/java/codegen-base/src/test/java/com/metaobjects/generator/direct/metadata/html/SingleHtmlBaseHookCompatTest.java create mode 100644 server/java/maven-plugin/src/test/java/com/metaobjects/mojo/KotlinGeneratorInfoJavaCallerTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 41e291b24..8a142a6f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -161,9 +161,10 @@ generator catalog and the Atlas evaluation are 1.2 work. - **The Java `plantuml`, `html-docs`, `ai-docs` and `json-schema` generators ship eject source**, each with the writer that emits its file. `SingleHtmlDirectGeneratorBase` now drives the new `HtmlDirectWriter`, as the JSON base drives `JsonDirectWriter`, so a copied - HTML generator pairs with the copy of its writer. A subclass of the base that overrode - `getWriter` or `writeHtml` with `MetaDataHtmlDocumentationWriter` in the signature needs - `HtmlDirectWriter` there. The attributes the JSON Schema and AI documentation generators + HTML generator pairs with the copy of its writer. A subclass that overrode + `writeHtml(MetaDataHtmlDocumentationWriter)` keeps being called, through a deprecated bridge the + base still calls; one that returns the stock writer from `getWriter` still compiles. A subclass + compiled against 1.0.x needs a recompile, because the method's return type changed. The attributes the JSON Schema and AI documentation generators add to the metamodel moved to `CodeGenMetaDataProvider`, where the loader registers them, so a copy owns emit code only; the old static methods and constants on the two generators stay as deprecated forwarders. diff --git a/docs/features/own-your-codegen.md b/docs/features/own-your-codegen.md index 4466fb4a7..6e2f1571f 100644 --- a/docs/features/own-your-codegen.md +++ b/docs/features/own-your-codegen.md @@ -938,17 +938,40 @@ class outside the engine surface in `fixtures/generator-registry-conformance/reg and compares what the copy emits with what the packaged generator emits, byte for byte. **A copy you ejected before the emitters were handed over** keeps compiling and running, with -the packaged emitters. To own them, raise your `codegen/` module's dependency to 1.1.0 or -later and re-eject the generator into a scratch project, then copy the emitter files next to -your generator: +the packaged emitters. A copy made by 1.0.13 compiled against the 1.1.0 package unchanged, +for the Java generators ejected together (`dto`, `repository`, `value-object`) and for Kotlin +`entity` and `output-prompt`. A Java `routes`, `dto` or `value-object` ejected alone never +compiled; that is the 1.0.x defect, and re-ejecting it with `-Dforce=true` is the fix. + +To own the emitters, raise your `codegen/` module's dependency on the codegen artifact to +1.1.0 or later, eject the same names into a scratch project under a package of its own, copy +the emitter files next to your generators and point the generators at them. Kotlin, for +`entity` and `output-prompt`: + +```bash +# in a scratch project (any pom), with the 1.1.0 plugin +mvn metaobjects:eject -Dnames=entity,output-prompt -Dport=kotlin -Dpackage=com.acme.scratch +# in yours +d=codegen/src/main/kotlin/com/acme/codegen +cp scratch/codegen/src/main/kotlin/com/acme/scratch/{KotlinEnumEmitter,KotlinOutputFormatSpecEmitter,KotlinExtractSchemaEmitter}.kt $d/ +sed -i 's/com\.acme\.scratch/com.acme.codegen/g' $d/{KotlinEnumEmitter,KotlinOutputFormatSpecEmitter,KotlinExtractSchemaEmitter}.kt +sed -i -E 's/^import com\.metaobjects\.generator\.kotlin\.(KotlinEnumEmitter|KotlinOutputFormatSpecEmitter|KotlinExtractSchemaEmitter)$/import com.acme.codegen.\1/' $d/KotlinEntityGenerator.kt $d/KotlinOutputPromptGenerator.kt +``` + +Java, for `dto` and `value-object` (Java imports end with `;`, and the copied files need only +their package line changed): ```bash -mvn metaobjects:eject -Dnames=dto,value-object -Dport=java -Dpackage=com.acme.scratch # in a scratch project -cp scratch/codegen/src/main/java/com/acme/scratch/{Fr019SharedEnum,SpringRecordBuilder}.java codegen/src/main/java/com/acme/codegen/ +mvn metaobjects:eject -Dnames=dto,value-object -Dport=java -Dpackage=com.acme.scratch # in the scratch project +d=codegen/src/main/java/com/acme/codegen +cp scratch/codegen/src/main/java/com/acme/scratch/{Fr019SharedEnum,SpringRecordBuilder}.java $d/ +sed -i 's/com\.acme\.scratch/com.acme.codegen/g' $d/{Fr019SharedEnum,SpringRecordBuilder}.java +sed -i -E 's/^import com\.metaobjects\.generator\.spring\.(Fr019SharedEnum|SpringRecordBuilder);/import com.acme.codegen.\1;/' $d/SpringDtoGenerator.java $d/SpringValueObjectGenerator.java ``` -Change the `package` line of each file you copy, and point your generator's imports of them at -your package. `mvn metaobjects:eject -Dlist` then marks the copy `identical` or `DIFFERS`. +Both were run against copies made by 1.0.13 and built against the 1.1.0 artifacts, and the owned module +compiled. `mvn metaobjects:eject -Dlist` keeps reporting such a copy as `DIFFERS`, because a copy +that old differs from the 1.1.0 reference in other lines too, not only in the emitters. ### `api-docs`: the api reference as an owned generator diff --git a/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/metadata/html/SingleHtmlDirectGeneratorBase.java b/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/metadata/html/SingleHtmlDirectGeneratorBase.java index 304f34190..e38dbcaaf 100644 --- a/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/metadata/html/SingleHtmlDirectGeneratorBase.java +++ b/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/metadata/html/SingleHtmlDirectGeneratorBase.java @@ -57,6 +57,23 @@ public void execute(MetaDataLoader loader) { protected abstract HtmlDirectWriter getWriter(MetaDataLoader loader, OutputStream os) throws GeneratorIOException; protected void writeHtml(HtmlDirectWriter writer) throws GeneratorIOException { + // A subclass written against the stock writer overrides the method below; keep calling it. + if (writer instanceof MetaDataHtmlDocumentationWriter) { + writeHtml((MetaDataHtmlDocumentationWriter) writer); + return; + } + log.info("Writing HTML file: " + writer.getFilename()); + writer.writeHtml(); + } + + /** + * The hook as it was before the base drove {@link HtmlDirectWriter}: a subclass that overrides + * this keeps being called whenever its {@link #getWriter} returns the stock writer. + * + * @deprecated override {@link #writeHtml(HtmlDirectWriter)} + */ + @Deprecated + protected void writeHtml(MetaDataHtmlDocumentationWriter writer) throws GeneratorIOException { log.info("Writing HTML file: " + writer.getFilename()); writer.writeHtml(); } diff --git a/server/java/codegen-base/src/test/java/com/metaobjects/generator/direct/metadata/html/SingleHtmlBaseHookCompatTest.java b/server/java/codegen-base/src/test/java/com/metaobjects/generator/direct/metadata/html/SingleHtmlBaseHookCompatTest.java new file mode 100644 index 000000000..774ac3500 --- /dev/null +++ b/server/java/codegen-base/src/test/java/com/metaobjects/generator/direct/metadata/html/SingleHtmlBaseHookCompatTest.java @@ -0,0 +1,60 @@ +package com.metaobjects.generator.direct.metadata.html; + +import com.metaobjects.generator.GeneratorIOException; +import com.metaobjects.generator.GeneratorTestBase; +import com.metaobjects.loader.MetaDataLoader; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.OutputStream; +import java.util.HashMap; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +/** + * A subclass written before the base drove {@link HtmlDirectWriter} overrode + * {@code writeHtml(MetaDataHtmlDocumentationWriter)}. It has to keep being called. + */ +public class SingleHtmlBaseHookCompatTest extends GeneratorTestBase { + + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + + /** The shape of a pre-1.1 subclass: stock writer in both signatures. */ + @SuppressWarnings("deprecation") + static final class OldStyleGenerator extends SingleHtmlDirectGeneratorBase { + int hookCalls; + + @Override + protected MetaDataHtmlDocumentationWriter getWriter(MetaDataLoader loader, OutputStream os) + throws GeneratorIOException { + return new MetaDataHtmlDocumentationWriter(loader, os); + } + + @Override + protected void writeHtml(MetaDataHtmlDocumentationWriter writer) throws GeneratorIOException { + hookCalls++; + writer.writeHtml(); + } + } + + @Test + public void anOverrideOfTheOldHookIsStillCalled() throws Exception { + OldStyleGenerator generator = new OldStyleGenerator(); + Map 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-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 f4f93ab25..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 @@ -77,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`. */ diff --git a/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/KotlinGeneratorInfoJavaCallerTest.java b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/KotlinGeneratorInfoJavaCallerTest.java new file mode 100644 index 000000000..9026dfa9e --- /dev/null +++ b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/KotlinGeneratorInfoJavaCallerTest.java @@ -0,0 +1,25 @@ +package com.metaobjects.mojo; + +import com.metaobjects.generator.kotlin.GeneratorInfo; +import com.metaobjects.generator.kotlin.GeneratorLayer; +import com.metaobjects.generator.kotlin.GeneratorTier; +import com.metaobjects.generator.kotlin.KotlinNamesGenerator; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +/** A Java caller of the six-argument {@code GeneratorInfo} constructor, as 1.0 shipped it, still links. */ +public class KotlinGeneratorInfoJavaCallerTest { + + @Test + public void theSixArgumentConstructorStillExistsAndLeavesNothingToCopyWithIt() { + GeneratorInfo info = new GeneratorInfo("names", "d", GeneratorTier.NATIVE, GeneratorLayer.MODEL, + KotlinNamesGenerator::new, "META-INF/metaobjects/reference/kotlin/KotlinNamesGenerator.kt"); + assertEquals("names", info.getName()); + assertEquals(java.util.List.of(), info.getEjectWith()); + // And the five-argument form it was a default-argument overload of. + assertNull(new GeneratorInfo("names", "d", GeneratorTier.NATIVE, GeneratorLayer.MODEL, + KotlinNamesGenerator::new).getEjectResourcePath()); + } +} From 160aa0fefae0346ad74f95e4f792ac7ee2939e92 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Mon, 12 Oct 2026 04:34:04 -0400 Subject: [PATCH 03/17] no-mistakes(review): fix(eject): per-port api-docs subpackage, shared docs wiring note --- CHANGELOG.md | 14 +- docs/features/own-your-codegen.md | 21 ++- docs/ports/java.md | 2 +- .../javacode/JavaObjectCodeGenerator.java | 8 +- .../javacode/README-flavored-objects.md | 13 +- .../EjectedBaseGeneratorsCompileTest.java | 3 +- .../generator/GeneratorRegistry.java | 5 +- server/java/maven-plugin/pom.xml | 9 + .../com/metaobjects/mojo/EjectSupport.java | 50 ++++- .../metaobjects/mojo/MetaDataEjectMojo.java | 26 +-- .../mojo/ApiDocsEjectBothPortsTest.java | 173 ++++++++++++++++++ .../metaobjects/mojo/EjectContractTest.java | 2 +- .../metaobjects/mojo/EjectSupportTest.java | 29 ++- .../mojo/MetaDataEjectMojoTest.java | 13 +- 14 files changed, 315 insertions(+), 53 deletions(-) create mode 100644 server/java/maven-plugin/src/test/java/com/metaobjects/mojo/ApiDocsEjectBothPortsTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a142a6f2..259b77bb5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -152,11 +152,15 @@ generator catalog and the Atlas evaluation are 1.2 work. through a new `DocsGenerator` marker (`metaobjects-codegen-base`). `metaobjects:generate` and `metaobjects:verify` skip a `DocsGenerator` and say so in the build log; `metaobjects:docs` runs the one your `` list names for a surface, else the packaged one. Eject - copies the generator, the page model, the builder, the renderer and the path math, and for - Java the page templates too, into `codegen/src/main/resources//templates/`, - with the copied template provider pointed there. What the Java reference documents comes - from `GeneratorApplicability.packaged()`, so it describes the packaged generators even in a - project that owns a copy of one. Page output and the goal's parameters + copies the generator, the page model, the builder, the renderer and the path math into a + subpackage per port, `.apidocs.java` or `.apidocs.kotlin`: the two ports + name those classes alike, and one `codegen/` module can own both. Eject prints the class + name to list. For Java the page templates come too, into + `codegen/src/main/resources//apidocs/java/templates/`, with the copied + template provider pointed there. What the Java reference documents is still what the + packaged generators emit for (`GeneratorApplicability.packaged()`): an owned `routes` whose + `appliesTo` you changed is documented as the packaged one behaves, unless you change the + line of your `JavaApiDocsGenerator` copy that builds it. Page output and the goal's parameters (`language`, `apiSubDir`, `layout`, `modelBaseUrl`, `outputDirectory`) are unchanged. - **The Java `plantuml`, `html-docs`, `ai-docs` and `json-schema` generators ship eject source**, each with the writer that emits its file. `SingleHtmlDirectGeneratorBase` now diff --git a/docs/features/own-your-codegen.md b/docs/features/own-your-codegen.md index 6e2f1571f..6023ae257 100644 --- a/docs/features/own-your-codegen.md +++ b/docs/features/own-your-codegen.md @@ -803,7 +803,9 @@ The JVM plugin loads generator classes from your project's classpath, but only f classes compiled **before** `generate-sources` runs, and the plugin's own copy of a class always wins over one with the same name. So eject puts each copy into a separate `codegen/` Maven module, under your own package (`-Dpackage`, default -`.codegen`). Every copy compiles on its own, and none calls another generator. The +`.codegen`). The `api-docs` copies alone go one level below it, in a subpackage per +port ([below](#api-docs-the-api-reference-as-an-owned-generator)). Every copy compiles on its +own, and none calls another generator. The package declaration is the only line it changes, except for a generator that is copied with other classes (the emitters it writes through, below, and Java `entity` and `extractor`) and for the support imports and the template prefix described below: there the imports those @@ -980,12 +982,23 @@ that old differs from the 1.1.0 reference in other lines too, not only in the em model (`ApiSymbol`, `ApiUnit`, `FieldShape`, `ApiSymbolKind`, `JavaApiModel`, `UnitExample`), the builder that decides what to document (`JavaApiModelBuilder`, `JavaFieldShapes`), the renderer (`JavaApiDocsRenderer`), the path math (`DocsPaths`) and the template provider, and writes the -page templates into `codegen/src/main/resources//templates/api/`. Eject -points the copied provider at that directory, so the page text is the one you own and no other -jar on the classpath can shadow it. `-Dport=kotlin` copies `KotlinApiDocsGenerator`, +page templates into `codegen/src/main/resources//apidocs/java/templates/api/`. +Eject points the copied provider at that directory, so the page text is the one you own and no +other jar on the classpath can shadow it. `-Dport=kotlin` copies `KotlinApiDocsGenerator`, `KotlinApiModel`, `KotlinApiModelBuilder`, `KotlinApiDocsRenderer` and `DocsPaths`; the Kotlin renderer writes its pages in code, so it has no templates. +**The copies take a subpackage per port.** The Java copies are written in +`.apidocs.java` and the Kotlin ones in `.apidocs.kotlin`, where `` is +`-Dpackage` (default `.codegen`). The two ports name their page model and path classes +alike (`ApiUnit`, `ApiSymbol`, `FieldShape`, `ApiSymbolKind`, `DocsPaths`), so a project that +documents both surfaces can own both generators in one `codegen/` module without declaring a +class twice. Eject prints each copy's class name, for example +`com.acme.codegen.apidocs.java.JavaApiDocsGenerator`. No other generator's package changes. Eject +never edits an existing pom, so the `codegen/pom.xml` the first eject wrote builds that port +only: when you eject the other port into the same module, add its `metaobjects-codegen-*` +dependency and its compiler to that pom yourself. + The generator is a `DocsGenerator`. List it in `` with `` set to your copy, and `metaobjects:docs` runs it for its surface in place of the packaged one. `metaobjects:generate` and `metaobjects:verify` skip it, because it writes pages under the docs directory and not code diff --git a/docs/ports/java.md b/docs/ports/java.md index f5ca073f1..7a9907be2 100644 --- a/docs/ports/java.md +++ b/docs/ports/java.md @@ -588,7 +588,7 @@ copy imports them. The model and the other ports' spelling are in **Own a generator.** `mvn metaobjects:eject -Dnames=` copies a reference generator into a `codegen/` Maven module under your own package, and prints the module, dependency and `` to wire. See [Own your codegen → Java and Kotlin](../features/own-your-codegen.md#java-and-kotlin-mvn-metaobjectseject). Every generator in the table above can be ejected except `template`. -**`entity` and `extractor` eject from `metaobjects-codegen-base`, each with the rest of its emit.** `mvn metaobjects:eject -Dnames=entity,extractor -Dport=java` (pass `-Dport`: both names are ejectable on both JVM ports). `entity` copies seven files into one package: `JavaObjectCodeGenerator`, the bases it extends (`JavaCodeGenerator`, `BaseObjectCodeGenerator`) and the four writers that emit each class body (`BaseObjectCodeWriter`, `JavaCodeWriter`, `PojoAwareCodeWriter`, `ValueObjectCodeWriter`). The copies use one another, never the packaged classes, so you change a field, a constructor or an accessor by editing the writer. `extractor` copies `ExtractorCodeGenerator` with `JavaCodeWriter` and `BaseObjectCodeWriter`, where it reads class names from; in the same package as `entity` the two share that one copy. The file-by-file table is in [Own your codegen → Java `entity` and `extractor`](../features/own-your-codegen.md#java-entity-and-extractor). +**`entity` and `extractor` eject from `metaobjects-codegen-base`, each with the rest of its emit.** `mvn metaobjects:eject -Dnames=entity,extractor -Dport=java` (pass `-Dport`: both names are ejectable on both JVM ports). `entity` copies eight files into one package: `JavaObjectCodeGenerator`, the bases it extends (`JavaCodeGenerator`, `BaseObjectCodeGenerator`), the four writers that emit each class body (`BaseObjectCodeWriter`, `JavaCodeWriter`, `PojoAwareCodeWriter`, `ValueObjectCodeWriter`) and `ExtractorCodeGenerator`, which writes the `Extractor` classes. The copies use one another, never the packaged classes, so you change a field, a constructor or an accessor by editing the writer. `extractor` copies `ExtractorCodeGenerator` with `JavaCodeWriter` and `BaseObjectCodeWriter`, where it reads class names from; in the same package as `entity` the two share that one copy. The file-by-file table is in [Own your codegen → Java `entity` and `extractor`](../features/own-your-codegen.md#java-entity-and-extractor). **Class headers changed once, and `mvn metaobjects:verify` no longer depends on the clock.** The header comment of every class `entity` writes used to carry an `ObjectCodeWriter:` line (the writer's class name) and a `Generated On:` line (the wall-clock second of the run). Both are gone, so the first regeneration after upgrading changes every generated class file in its header comment and nowhere else. Because of the timestamp, `mvn metaobjects:verify` reported `[content-differs]` for every one of those files unless it ran in the same second as the generate it was checking. Output now holds nothing that varies between runs: regenerating an unchanged model writes the same bytes, and verify passes whenever it runs. diff --git a/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/object/javacode/JavaObjectCodeGenerator.java b/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/object/javacode/JavaObjectCodeGenerator.java index 8dc07b625..7b8520b33 100644 --- a/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/object/javacode/JavaObjectCodeGenerator.java +++ b/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/object/javacode/JavaObjectCodeGenerator.java @@ -1,10 +1,10 @@ package com.metaobjects.generator.direct.object.javacode; // Eject note (ADR-0034 Amendment 6): `mvn metaobjects:eject -Dnames=entity` copies this file -// together with JavaCodeGenerator, BaseObjectCodeGenerator and the four writers, into one -// package. The classes named bare below are those copies. ExtractorCodeGenerator is the -// `extractor` generator and is NOT part of this copy, so it is imported by name: that is -// what keeps the ejected copy compiling, and calling the packaged one. +// together with JavaCodeGenerator, BaseObjectCodeGenerator, the four writers and +// ExtractorCodeGenerator, into one package. The classes named bare below are those copies. +// ExtractorCodeGenerator is the `extractor` generator too, and eject points the import below +// at its copy, so an owned `entity` writes its extractors through owned code. import com.metaobjects.generator.direct.object.javacode.ExtractorCodeGenerator; import com.metaobjects.generator.EmitsObjectExtractors; diff --git a/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/object/javacode/README-flavored-objects.md b/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/object/javacode/README-flavored-objects.md index 6124c8564..578839aa0 100644 --- a/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/object/javacode/README-flavored-objects.md +++ b/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/object/javacode/README-flavored-objects.md @@ -65,7 +65,7 @@ This module's pom ships the sources under `META-INF/metaobjects/reference/java/` | Generator | Copied with it, into the same owned package | |---|---| -| `entity` | `JavaCodeGenerator`, `BaseObjectCodeGenerator`, `BaseObjectCodeWriter`, `JavaCodeWriter`, `PojoAwareCodeWriter`, `ValueObjectCodeWriter` | +| `entity` | `JavaCodeGenerator`, `BaseObjectCodeGenerator`, `BaseObjectCodeWriter`, `JavaCodeWriter`, `PojoAwareCodeWriter`, `ValueObjectCodeWriter`, `ExtractorCodeGenerator` | | `extractor` | `JavaCodeWriter`, `BaseObjectCodeWriter` | Eject rewrites each file's package line and, where one copied class imports another, that @@ -74,14 +74,15 @@ class body by editing a writer, not by subclassing one. What a copy may still re this module, and so what has to keep working for it: - `MultiFileDirectGeneratorBase` and `FileDirectWriter`, which the copies extend; -- `GenerationContext`, `GeneratorUtil`, `JavaIdentifiers` and `GeneratedFileWriter`; -- `ExtractorCodeGenerator.emit(...)`, which an owned `entity` calls until an `extractor` - generator is wired beside it. +- `GenerationContext`, `GeneratorUtil`, `JavaIdentifiers` and `GeneratedFileWriter`. + +An owned `entity` writes its extractors through its own copy of `ExtractorCodeGenerator`, +until an `extractor` generator is wired beside it. Two rules keep a copy set ejectable, and `EjectedBaseGeneratorsCompileTest` fails when either is broken. A class in a copy set may name another class of this package by its bare -name only if that class is in the same set; anything else (the owned `entity` naming -`ExtractorCodeGenerator`) is imported explicitly. And a copied class calls nothing that is +name only if that class is in the same set; anything else (the owned `extractor` naming +`BaseObjectCodeGenerator`) is imported explicitly. And a copied class calls nothing that is not `public` on a class left behind. A new class the writers come to depend on is added to the set in three places: this module's pom, `GeneratorRegistry`, and that test. diff --git a/server/java/codegen-base/src/test/java/com/metaobjects/generator/direct/object/EjectedBaseGeneratorsCompileTest.java b/server/java/codegen-base/src/test/java/com/metaobjects/generator/direct/object/EjectedBaseGeneratorsCompileTest.java index cce3db6f2..3fe0f4d5c 100644 --- a/server/java/codegen-base/src/test/java/com/metaobjects/generator/direct/object/EjectedBaseGeneratorsCompileTest.java +++ b/server/java/codegen-base/src/test/java/com/metaobjects/generator/direct/object/EjectedBaseGeneratorsCompileTest.java @@ -45,7 +45,8 @@ public class EjectedBaseGeneratorsCompileTest { "BaseObjectCodeWriter", "javacode.JavaCodeWriter", "javacode.PojoAwareCodeWriter", - "javacode.ValueObjectCodeWriter"); + "javacode.ValueObjectCodeWriter", + "javacode.ExtractorCodeGenerator"); /** What eject copies for {@code extractor}. */ private static final List EXTRACTOR = List.of( 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 a9ae91535..2c50b7b0c 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 @@ -443,10 +443,7 @@ private static Map buildRegistry() { // 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(), - "api-docs: `mvn metaobjects:docs` runs this generator, not `metaobjects:generate`, which " - + "skips it. List it in with {owned} and the " - + "docs goal runs your copy for the java surface in place of the packaged one.", + 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, diff --git a/server/java/maven-plugin/pom.xml b/server/java/maven-plugin/pom.xml index dfe7ad280..f48583ebb 100644 --- a/server/java/maven-plugin/pom.xml +++ b/server/java/maven-plugin/pom.xml @@ -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/EjectSupport.java b/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/EjectSupport.java index 07874159d..b3bf052e9 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 @@ -1,5 +1,6 @@ package com.metaobjects.mojo; +import com.metaobjects.generator.DocsGenerator; import com.metaobjects.generator.GeneratorRegistry; import com.metaobjects.generator.kotlin.GeneratorInfo; import com.metaobjects.generator.kotlin.GeneratorRegistryKt; @@ -65,6 +66,9 @@ public static final class Entry { /** Wiring the generic eject report cannot know, with {@code {owned}} standing for the * owned copy's class name, or {@code null} (see {@link #wiringNote}). */ public final String note; + /** Whether this generator is a {@link DocsGenerator}: {@code metaobjects:docs} runs it + * and {@code metaobjects:generate} skips it. */ + public final boolean docs; /** 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; @@ -96,7 +100,8 @@ public Entry(Port port, String stableName, String classname, String description, this.description = description; this.resourcePath = resourcePath; this.runtime = List.copyOf(runtime); - this.note = note; + this.docs = isDocsGenerator(classname); + this.note = docs ? stableName + DOCS_NOTE.replace("{surface}", port.id) : note; this.with = List.copyOf(with); } @@ -121,8 +126,24 @@ public String resourceOf(String source) { /** {@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. A {@link DocsGenerator}'s 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 docs ? basePackage + "." + 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. */ @@ -131,6 +152,29 @@ public String simpleName() { } } + /** The subpackage of the owned package a {@link DocsGenerator}'s copies are written under, + * above the one named for its port (see {@link Entry#ownedPackage}). */ + public static final String DOCS_SUBPACKAGE = "apidocs"; + + /** What eject prints for a {@link DocsGenerator}, on either port, after its stable name: no pom + * lists one until its owner adds it. {@code {surface}} is the port it documents. */ + private static final String DOCS_NOTE = + ": `mvn metaobjects:docs` runs this generator, not `metaobjects:generate`, which " + + "skips it. List it in with {owned} and the " + + "docs goal runs your copy for the {surface} surface in place of the packaged one."; + + /** Whether the registered generator class {@code classname} is a {@link DocsGenerator}. + * Every registered class is on this plugin's classpath. */ + private static boolean isDocsGenerator(String classname) { + if (classname == null) return false; + try { + return DocsGenerator.class.isAssignableFrom( + Class.forName(classname, false, EjectSupport.class.getClassLoader())); + } catch (ClassNotFoundException e) { + throw new IllegalStateException("registered generator class is not on the classpath: " + classname, e); + } + } + /** The simple name of a fully-qualified class name. */ public static String simpleNameOf(String classname) { int dot = classname.lastIndexOf('.'); 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 9b3ff64f1..0357f8f7c 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 @@ -92,7 +92,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; @@ -181,7 +183,8 @@ public void execute() throws MojoExecutionException, MojoFailureException { 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,7 +201,7 @@ 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); } @@ -206,7 +209,7 @@ public void execute() throws MojoExecutionException, MojoFailureException { support.addAll(EjectSupport.supportFor(rewritten)); rewritten = EjectSupport.rewriteSupportImports(rewritten, pkg); if (!e.resources.isEmpty()) { - rewritten = EjectSupport.rewriteTemplatePrefix(rewritten, pkg); + rewritten = EjectSupport.rewriteTemplatePrefix(rewritten, ownedPkg); } } @@ -215,7 +218,7 @@ 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); @@ -244,16 +247,18 @@ public void execute() throws MojoExecutionException, MojoFailureException { /** * Write the templates the ejected generators read from the classpath into the owned module's - * resources, under the owned package's path. The copied template provider is pointed there - * ({@link EjectSupport#rewriteTemplatePrefix}), so the owned page text is what runs. + * 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<>(); - Path dir = resourceRoot.resolve(EjectSupport.ownedTemplatePrefix(pkg)); + 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) { - if (!written.add(ref)) continue; + 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 { @@ -262,7 +267,6 @@ private void ejectTemplates(List resolved, Path resourc throw new MojoExecutionException("Could not read reference template \"" + ref + "\" at classpath resource " + resource, ex); } - Path target = dir.resolve(ref + EjectSupport.TEMPLATE_EXTENSION); switch (write(target.getParent(), target, content, force)) { case CREATED: getLog().info("Copied template " + ref + " -> " + target 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..88923d6f0 --- /dev/null +++ b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/ApiDocsEjectBothPortsTest.java @@ -0,0 +1,173 @@ +package com.metaobjects.mojo; + +import com.metaobjects.generator.DocsGenerator; +import com.tschuchort.compiletesting.KotlinCompilation; +import com.tschuchort.compiletesting.SourceFile; +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.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.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.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.assertTrue; + +/** + * A project that documents both surfaces ({@code metaobjects:docs} with {@code language=java,kotlin}) + * can own both {@code api-docs} 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 the docs generator of that surface.

      + */ +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 owned classname eject printed for it. */ + private String ejectApiDocs(String port) throws Exception { + MetaDataEjectMojo mojo = new MetaDataEjectMojo(); + mojo.project = project; + mojo.names = "api-docs"; + mojo.port = port; + Log log = Mockito.mock(Log.class); + mojo.setLog(log); + mojo.execute(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(CharSequence.class); + Mockito.verify(log, 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; + } + + 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 void assertDocsGeneratorOf(String surface, String classname, ClassLoader loader) throws Exception { + Object generator = Class.forName(classname, true, loader).getDeclaredConstructor().newInstance(); + assertTrue(classname + " is not a DocsGenerator", generator instanceof DocsGenerator); + assertEquals(classname, surface, ((DocsGenerator) generator).surface()); + } + + @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())) { + assertDocsGeneratorOf("java", ownedJava, java); + } + assertDocsGeneratorOf("kotlin", ownedKotlin, kotlin.getClassLoader()); + } +} diff --git a/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/EjectContractTest.java b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/EjectContractTest.java index 74e5e6414..f7c99a10e 100644 --- a/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/EjectContractTest.java +++ b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/EjectContractTest.java @@ -546,7 +546,7 @@ public void depthAnUnchangedOwnedCopyEmitsWhatThePackagedGeneratorEmits() throws for (Corpus corpus : CORPORA) { String where = e.stableName + " over " + corpus.label; Generator packaged = (Generator) Class.forName(e.classname).getDeclaredConstructor().newInstance(); - Generator owned = (Generator) Class.forName(OWNED_PACKAGE + "." + e.simpleName(), true, cl) + Generator owned = (Generator) Class.forName(e.ownedClassname(OWNED_PACKAGE), true, cl) .getDeclaredConstructor().newInstance(); Map a = run(packaged, corpus, corpus.loader(), true); Map b = run(owned, corpus, corpus.loader(), false); diff --git a/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/EjectSupportTest.java b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/EjectSupportTest.java index ecc73216a..be6dada09 100644 --- a/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/EjectSupportTest.java +++ b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/EjectSupportTest.java @@ -55,17 +55,34 @@ public void entityAndExtractorAreEjectableOnBothPortsAndSoNeedAPort() { } @Test - public void onlyTheJavaExtractorAndApiDocsCarryAWiringNoteAndItNamesTheOwnedClass() { + public void theJavaExtractorAndApiDocsOnBothPortsCarryAWiringNoteAndItNamesTheOwnedClass() { + int noted = 0; for (EjectSupport.Entry e : EjectSupport.allEntries()) { boolean javaExtractor = e.port == EjectSupport.Port.JAVA && e.stableName.equals("extractor"); - boolean javaApiDocs = e.port == EjectSupport.Port.JAVA && e.stableName.equals("api-docs"); - if (!javaExtractor && !javaApiDocs) { - assertNull(e.port.id + " " + e.stableName, e.wiringNote("com.acme.codegen")); + boolean apiDocs = e.stableName.equals("api-docs"); + String note = e.wiringNote("com.acme.codegen"); + if (!javaExtractor && !apiDocs) { + assertNull(e.port.id + " " + e.stableName, note); continue; } - String note = e.wiringNote("com.acme.codegen"); - assertTrue(note, note.contains("com.acme.codegen." + e.simpleName() + "")); + noted++; + String ownedPackage = apiDocs ? "com.acme.codegen.apidocs." + e.port.id : "com.acme.codegen"; + assertTrue(note, note.contains("" + ownedPackage + "." + e.simpleName() + "")); assertFalse(note, note.contains("{owned}")); + // No pom lists a docs generator until its owner adds it, on either port. + if (apiDocs) assertTrue(note, note.contains("the " + e.port.id + " surface")); + } + assertEquals("the Java extractor, and api-docs on each port", 3, noted); + } + + @Test + public void onlyTheApiDocsCopiesTakeASubpackageAndEachPortHasItsOwn() { + // The two ports' api reference classes share simple names, and one module may own both. + // Every other generator keeps the package it ejected into before 1.1. + for (EjectSupport.Entry e : EjectSupport.allEntries()) { + String expected = e.stableName.equals("api-docs") + ? "com.acme.codegen.apidocs." + e.port.id : "com.acme.codegen"; + assertEquals(e.port.id + " " + e.stableName, expected, e.ownedPackage("com.acme.codegen")); } } diff --git a/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/MetaDataEjectMojoTest.java b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/MetaDataEjectMojoTest.java index a6e1ca923..8baf5a704 100644 --- a/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/MetaDataEjectMojoTest.java +++ b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/MetaDataEjectMojoTest.java @@ -339,21 +339,20 @@ public void namesIsRequiredWhenNotListing() { public void ejectingTheJavaApiDocsWritesTheTemplatesAndPointsTheCopiedProviderAtThem() throws Exception { mojo("api-docs", false, "java", null).execute(); - Path src = codegenRoot().resolve("src/main/java/com/acme/codegen"); + // The api-docs copies take the Java subpackage of the owned package. + Path src = codegenRoot().resolve("src/main/java/com/acme/codegen/apidocs/java"); for (String file : List.of("JavaApiDocsGenerator", "JavaApiModelBuilder", "JavaApiDocsRenderer", "DocsPaths", "ApiUnit", "ClasspathTemplateProvider")) { assertTrue(file, Files.isRegularFile(src.resolve(file + ".java"))); } - // The page text is the adopter's, under the owned package's path so no jar can shadow it. - Path templates = codegenRoot().resolve("src/main/resources/com/acme/codegen/templates/api"); + // The page text is the adopter's, under the copies' package path so no jar can shadow it. + Path templates = codegenRoot().resolve("src/main/resources/com/acme/codegen/apidocs/java/templates/api"); for (String name : List.of("entity-api", "index", "agent-api")) { assertTrue(name, Files.isRegularFile(templates.resolve(name + ".md.mustache"))); } String provider = Files.readString(src.resolve("ClasspathTemplateProvider.java"), StandardCharsets.UTF_8); - assertTrue(provider, provider.contains("BASE_PREFIX = \"com/acme/codegen/templates/\";")); - // The generator names the engine's applicability, never a generator class. + assertTrue(provider, provider.contains("BASE_PREFIX = \"com/acme/codegen/apidocs/java/templates/\";")); String generator = Files.readString(src.resolve("JavaApiDocsGenerator.java"), StandardCharsets.UTF_8); - assertFalse(generator, generator.contains("SpringDtoGenerator")); - assertTrue(generator, generator.contains("package com.acme.codegen;")); + assertTrue(generator, generator.contains("package com.acme.codegen.apidocs.java;")); } } From fdb8616f0ccd7c98128c7f7c56ac446ff69617a1 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Mon, 12 Oct 2026 04:50:01 -0400 Subject: [PATCH 04/17] no-mistakes(review): fix(eject): print pom changes a second port needs --- CHANGELOG.md | 12 +- docs/features/own-your-codegen.md | 30 +++- docs/ports/java.md | 2 +- docs/ports/kotlin.md | 2 +- .../metaobjects/generator/DocsGenerator.java | 10 +- .../java/com/metaobjects/mojo/DocsMojo.java | 7 +- .../com/metaobjects/mojo/EjectSupport.java | 7 +- .../metaobjects/mojo/MetaDataEjectMojo.java | 130 ++++++++++++++---- .../mojo/ApiDocsEjectBothPortsTest.java | 67 ++++++++- 9 files changed, 214 insertions(+), 53 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 259b77bb5..2a674468f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -151,11 +151,17 @@ generator catalog and the Atlas evaluation are 1.2 work. `mvn metaobjects:docs` writes is now `JavaApiDocsGenerator` / `KotlinApiDocsGenerator`, run through a new `DocsGenerator` marker (`metaobjects-codegen-base`). `metaobjects:generate` and `metaobjects:verify` skip a `DocsGenerator` and say so in the build log; `metaobjects:docs` - runs the one your `` list names for a surface, else the packaged one. Eject + runs the one the `` list it reads names for a surface, else the packaged one. + That list is the plugin-level ``'s, or the `docs` execution's own, never the + `generate` execution's. Eject copies the generator, the page model, the builder, the renderer and the path math into a subpackage per port, `.apidocs.java` or `.apidocs.kotlin`: the two ports - name those classes alike, and one `codegen/` module can own both. Eject prints the class - name to list. For Java the page templates come too, into + name those classes alike, so one `codegen/` module can hold both without a class declared + twice. Eject prints the class name to list. The `codegen/pom.xml` the first eject wrote + builds that port only, and eject never edits it: when it does not declare what a port in the + module needs (the `metaobjects-codegen-*` dependency, `kotlin-maven-plugin`, or + `src/main/java` as a source root after a Kotlin-first eject), eject now warns and prints + what to add. For Java the page templates come too, into `codegen/src/main/resources//apidocs/java/templates/`, with the copied template provider pointed there. What the Java reference documents is still what the packaged generators emit for (`GeneratorApplicability.packaged()`): an owned `routes` whose diff --git a/docs/features/own-your-codegen.md b/docs/features/own-your-codegen.md index 6023ae257..04aba4e11 100644 --- a/docs/features/own-your-codegen.md +++ b/docs/features/own-your-codegen.md @@ -994,13 +994,29 @@ renderer writes its pages in code, so it has no templates. alike (`ApiUnit`, `ApiSymbol`, `FieldShape`, `ApiSymbolKind`, `DocsPaths`), so a project that documents both surfaces can own both generators in one `codegen/` module without declaring a class twice. Eject prints each copy's class name, for example -`com.acme.codegen.apidocs.java.JavaApiDocsGenerator`. No other generator's package changes. Eject -never edits an existing pom, so the `codegen/pom.xml` the first eject wrote builds that port -only: when you eject the other port into the same module, add its `metaobjects-codegen-*` -dependency and its compiler to that pom yourself. - -The generator is a `DocsGenerator`. List it in `` with `` set to your copy, -and `metaobjects:docs` runs it for its surface in place of the packaged one. `metaobjects:generate` +`com.acme.codegen.apidocs.java.JavaApiDocsGenerator`. No other generator's package changes. + +**The second port needs the pom changed, and eject prints the change.** Eject never edits an +existing pom, so the `codegen/pom.xml` the first eject wrote builds that port only, and the +module still builds green with the other port's copies left out. When the pom does not declare +what a port in the module needs, eject warns and prints what to add, in either order: + +- **Java first, then Kotlin.** Add the `metaobjects-codegen-kotlin` dependency, the + `kotlin.compiler.jvmTarget` property, and `kotlin-maven-plugin` with `src/main/kotlin` as its + ``. +- **Kotlin first, then Java.** Add the `metaobjects-codegen-spring` dependency, remove + `src/main/kotlin` so that Maven compiles `src/main/java` + again, and give `kotlin-maven-plugin` `src/main/kotlin` as its ``. + +Either way the pom ends up with both dependencies, Maven's default Java source root, and the +Kotlin plugin pointed at its own. + +The generator is a `DocsGenerator`. List it, with `` set to your copy, in the +`` the docs goal reads, and `metaobjects:docs` runs it for its surface in place of the +packaged one. That list is the one in the plugin-level ``, or in the `docs` +execution's own. A `` list inside the `generate` execution's `` is read +by that execution only, so an entry added there leaves `metaobjects:docs` on the packaged +generator, and its log line says which one it ran. `metaobjects:generate` and `metaobjects:verify` skip it, because it writes pages under the docs directory and not code under `outputDir`, and say so in the build log. The docs goal sets `outputDir`, `projectName`, `apiSubDir`, `layout` and `modelBaseUrl` on it, and the pages land under diff --git a/docs/ports/java.md b/docs/ports/java.md index 7a9907be2..785d2b571 100644 --- a/docs/ports/java.md +++ b/docs/ports/java.md @@ -288,7 +288,7 @@ name in every port. | `extractor` | `ExtractorCodeGenerator` | `metaobjects-codegen-base` | One `Extractor` per concrete object, wrapping the runtime tolerant extract and turning dirty LLM text into a fully-typed flavored object graph (nested objects + arrays-of-objects populated) in one call. Args: `outputDir`, and the `pkgPrefix` / `pkgSuffix` / `namePrefix` / `nameSuffix` your `entity` generator uses; give it the same `` too. Needs `entity` with a concrete `flavor` in the same run, since an extractor casts to the flavored class. It names `MetaObjectExtractor` (in `metaobjects-om`) by FQN string only, so `codegen-base` keeps no compile dependency on `om` — the reference resolves on the consumer's classpath. | | `trace-helper` | `LlmTraceHelperGenerator` | `metaobjects-codegen-spring` | One `TraceHelper` per concrete entity that transitively `extends` `metaobjects::ai::LlmCallBase` **and** nests a `template.prompt` carrying `@responseRef` — a static `record(...)` that extracts the typed response, builds the `LlmCallBase` trace row, and persists it. Emits nothing for any other entity. | | `template` | `TemplateScopeGenerator` | `metaobjects-codegen-base` | The generic Mustache primitive: walk the model by a named `scope`, render one shared template per walk result, write each file. The declarative alternative to writing a generator class — see [Declarative template-codegen](#declarative-template-codegen-templatescopegenerator) below. It renders through `render.templategen.TemplateGenerator`, the byte-pinned cross-port factory; that factory takes a walk callback and is not itself a `Generator`, so `TemplateScopeGenerator` is the class a pom names. | -| `api-docs` | `JavaApiDocsGenerator` | `metaobjects-codegen-spring` | The Java SDK api reference `mvn metaobjects:docs` writes: one page per entity, value object, projection, served report and template, an index and `AGENT-API.md`, under `/api/java`. A `DocsGenerator`: `metaobjects:generate` and `metaobjects:verify` skip it. List an owned copy in `` and the docs goal runs it in place of the packaged one. Ejectable: the copy takes the page IR, the builder, the renderer, the page paths and the page templates with it. | +| `api-docs` | `JavaApiDocsGenerator` | `metaobjects-codegen-spring` | The Java SDK api reference `mvn metaobjects:docs` writes: one page per entity, value object, projection, served report and template, an index and `AGENT-API.md`, under `/api/java`. A `DocsGenerator`: `metaobjects:generate` and `metaobjects:verify` skip it. List an owned copy in the `` the docs goal reads (the plugin-level ``, or the `docs` execution's own; a list inside the `generate` execution's ``, as in [Configure](#configure), is not read by `metaobjects:docs`) and the docs goal runs it in place of the packaged one. Ejectable: the copy takes the page IR, the builder, the renderer, the page paths and the page templates with it. | | `plantuml` | `PlantUMLGenerator` | `metaobjects-codegen-plantuml` | One PlantUML class diagram of the object model (`outputDir`, `outputFilename`; `showAttrs`, `showFields`, `showAbstracts`, `drawKeys`, `drawRefs` choose what is drawn). Ejectable: the copy takes `PlantUMLWriter` with it. | | `html-docs` | `MetaDataHtmlDocumentationGenerator` | `metaobjects-codegen-base` | One HTML page describing the registered metamodel types (`outputDir`, `outputFilename`). It reads the type registry, not your model, so it documents the vocabulary your build loads, providers included. Ejectable: the copy takes `MetaDataHtmlDocumentationWriter` with it. | | `ai-docs` | `MetaDataAIDocumentationGenerator` | `metaobjects-codegen-base` | The same type reference as JSON, laid out for an AI reader (`outputDir`, `outputFilename`). Ejectable: the copy takes `MetaDataAIDocumentationWriter` with it. | diff --git a/docs/ports/kotlin.md b/docs/ports/kotlin.md index 72a748dd0..fb0b837a3 100644 --- a/docs/ports/kotlin.md +++ b/docs/ports/kotlin.md @@ -107,7 +107,7 @@ The 16 generators registered in `codegen-kotlin` (`GeneratorRegistry.kt`): | `KotlinStoredProcGenerator` | Stored-procedure call wrappers | entities with `source.rdb @kind="storedProc"` | | `KotlinSpringControllerGenerator` | `Controller.kt` — Spring `@RestController` (5 CRUD endpoints; cross-port API contract). **Select `KotlinRelationsGenerator` with it** when the model has a M:N relationship: the traversal routes call the `Query` helpers only that generator emits, and without it the controller does not compile | entities with `source.rdb @kind="table"` | | `KotlinRequirementTestsGenerator` | Per metamodel package that holds a tested requirement, two files in `testPackage`: `Requirements__Witnesses.kt` (an interface whose default members fail with `unimplemented requirement: ...`) and `Requirements__Test.kt` (one JUnit Jupiter `@Test` per requirement, calling a project-owned witness). See "Requirement tests" below | every tested `requirement.*` (functional, level 4 or above by default) | -| `KotlinApiDocsGenerator` | The Kotlin SDK api reference `mvn metaobjects:docs` writes under `/api/kotlin`: `.md` per entity, value object, projection, served report and template, a `README.md` index and `AGENT-API.md`. A `DocsGenerator`: `metaobjects:generate` and `metaobjects:verify` skip it, and `metaobjects:docs` runs your copy in place of the packaged one when `` names it. Ejectable, with the page model, builder, renderer and path math | the whole model, once | +| `KotlinApiDocsGenerator` | The Kotlin SDK api reference `mvn metaobjects:docs` writes under `/api/kotlin`: `.md` per entity, value object, projection, served report and template, a `README.md` index and `AGENT-API.md`. A `DocsGenerator`: `metaobjects:generate` and `metaobjects:verify` skip it, and `metaobjects:docs` runs your copy in place of the packaged one when the `` it reads names it (the plugin-level ``, or the `docs` execution's own, not the `generate` execution's). Ejectable, with the page model, builder, renderer and path math | the whole model, once | Maven wiring: diff --git a/server/java/codegen-base/src/main/java/com/metaobjects/generator/DocsGenerator.java b/server/java/codegen-base/src/main/java/com/metaobjects/generator/DocsGenerator.java index 4d5ae091f..140c501c3 100644 --- a/server/java/codegen-base/src/main/java/com/metaobjects/generator/DocsGenerator.java +++ b/server/java/codegen-base/src/main/java/com/metaobjects/generator/DocsGenerator.java @@ -11,9 +11,13 @@ * implementing this interface. {@code generate} and {@code verify} skip it and say so, and * {@code docs} runs it.

      * - *

      One {@code } list serves all three goals. An owned {@code api-docs} copy is - * listed beside the code generators, and each goal runs the half that is its own. When the list - * holds a docs generator for a surface, {@code docs} runs it in place of the packaged one.

      + *

      One {@code } list serves all three goals when it sits in the plugin-level + * {@code }: an owned {@code api-docs} copy is listed beside the code generators, + * and each goal runs the half that is its own. A list inside one execution's {@code + * } is read by that execution only, so {@code docs} sees an entry in the {@code + * generate} execution's list only if the {@code docs} execution's own list repeats it. When the + * list {@code docs} reads holds a docs generator for a surface, it runs that one in place of the + * packaged one.

      * *

      The goal sets these args on the generator: {@link #ARG_PROJECT_NAME}, {@link * #ARG_API_SUBDIR}, {@link #ARG_LAYOUT} and {@link #ARG_MODEL_BASE_URL}, plus {@code outputDir}, 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 9c0c0345e..35927e969 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 @@ -37,9 +37,10 @@ * {@link #apiSubDir} explicitly only when documenting a single language into a custom dir. * *

      The Mojo is wiring only: each surface is a {@link DocsGenerator} ({@code api-docs}) that - * writes its pages under {@link #outputDirectory}. When the project's {@code } list - * holds a docs generator for a surface, the goal runs that one, so a project that ejected - * {@code api-docs} runs its own copy. Every name comes from the per-language IR and every path + * writes its pages under {@link #outputDirectory}. When the {@code } list this goal + * reads (the plugin-level {@code }, or the docs execution's own; never another + * execution's) holds a docs generator for a surface, the goal runs that one, so a project that + * ejected {@code api-docs} runs its own copy. 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}. 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 b3bf052e9..0676ce341 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 @@ -160,8 +160,11 @@ public String simpleName() { * lists one until its owner adds it. {@code {surface}} is the port it documents. */ private static final String DOCS_NOTE = ": `mvn metaobjects:docs` runs this generator, not `metaobjects:generate`, which " - + "skips it. List it in with {owned} and the " - + "docs goal runs your copy for the {surface} surface in place of the packaged one."; + + "skips it. List it with {owned} in the the docs " + + "goal reads, which is the plugin-level or the docs execution's own, " + + "and the docs goal runs your copy for the {surface} surface in place of the packaged " + + "one. A list inside another execution's , the generate " + + "execution's for one, is not read by the docs goal."; /** Whether the registered generator class {@code classname} is a {@link DocsGenerator}. * Every registered class is on this plugin's classpath. */ 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 0357f8f7c..f58c937a4 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. */ @@ -225,6 +228,7 @@ public void execute() throws MojoExecutionException, MojoFailureException { ejectTemplates(resolved, codegenRoot.resolve("src/main/resources"), pkg); boolean pomWritten = writeCodegenPomIfNeeded(codegenRoot, pkg, portsTouched); + reportWhatThePomLacks(codegenRoot); if (!runtime.isEmpty()) { ejectRuntime(runtime, runtimeRoot, runtimePkg); @@ -508,39 +512,59 @@ 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"; + } + 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 : EjectSupport.Port.values()) { + if (ports.contains(port)) deps.append(dependency(port)); } String kotlinPlugin = ports.contains(EjectSupport.Port.KOTLIN) ? " \n" + " 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("") + " \n" + " \n" : ""; @@ -559,9 +583,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 @@ -570,6 +592,54 @@ 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 of one call and never edits it, so a port + * ejected into the module in another 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 : EjectSupport.Port.values()) { + String sourceRoot = "src/main/" + port.id; + if (!Files.isDirectory(codegenRoot.resolve(sourceRoot))) continue; + 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/test/java/com/metaobjects/mojo/ApiDocsEjectBothPortsTest.java b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/ApiDocsEjectBothPortsTest.java index 88923d6f0..2dc78af3c 100644 --- 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 @@ -72,8 +72,8 @@ public void setUp() throws IOException { 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 owned classname eject printed for it. */ - private String ejectApiDocs(String port) throws Exception { + /** Eject {@code api-docs} for one port, and return the log eject wrote to. */ + private Log eject(String port) throws Exception { MetaDataEjectMojo mojo = new MetaDataEjectMojo(); mojo.project = project; mojo.names = "api-docs"; @@ -81,9 +81,13 @@ private String ejectApiDocs(String port) throws Exception { Log log = Mockito.mock(Log.class); mojo.setLog(log); mojo.execute(); + return log; + } + /** 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(log, Mockito.atLeastOnce()).info(captor.capture()); + Mockito.verify(eject(port), Mockito.atLeastOnce()).info(captor.capture()); List printed = new ArrayList<>(); for (CharSequence line : captor.getAllValues()) { Matcher m = CLASSNAME_CHANGE.matcher(line); @@ -97,6 +101,13 @@ private String ejectApiDocs(String port) throws Exception { 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()); @@ -150,6 +161,56 @@ private static void assertDocsGeneratorOf(String surface, String classname, Clas assertEquals(classname, surface, ((DocsGenerator) generator).surface()); } + // The pom eject writes builds the ports of that call. 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 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"); From f21b8aa497d95dd9dfa1363b8f7398d0a21b6ea0 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Mon, 12 Oct 2026 05:00:41 -0400 Subject: [PATCH 05/17] no-mistakes(review): fix(eject): two-port pom builds both source trees --- CHANGELOG.md | 11 +-- docs/features/own-your-codegen.md | 34 +++++---- .../metaobjects/mojo/MetaDataEjectMojo.java | 31 +++++---- .../mojo/ApiDocsEjectBothPortsTest.java | 69 ++++++++++++++++++- 4 files changed, 110 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a674468f..e5f3f11fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -157,11 +157,12 @@ generator catalog and the Atlas evaluation are 1.2 work. copies the generator, the page model, the builder, the renderer and the path math into a subpackage per port, `.apidocs.java` or `.apidocs.kotlin`: the two ports name those classes alike, so one `codegen/` module can hold both without a class declared - twice. Eject prints the class name to list. The `codegen/pom.xml` the first eject wrote - builds that port only, and eject never edits it: when it does not declare what a port in the - module needs (the `metaobjects-codegen-*` dependency, `kotlin-maven-plugin`, or - `src/main/java` as a source root after a Kotlin-first eject), eject now warns and prints - what to add. For Java the page templates come too, into + twice. Eject prints the class name to list. Eject writes `codegen/pom.xml` for the ports + the module holds at that moment and never edits it afterwards. One call that ejects for + both ports now gets a pom that builds both source trees; it used to leave `src/main/java` + out. When a later call adds a port, the pom does not declare what that port needs (the + `metaobjects-codegen-*` dependency, `kotlin-maven-plugin`, or `src/main/java` as a source + root after a Kotlin-first eject), and eject now warns and prints what to add. For Java the page templates come too, into `codegen/src/main/resources//apidocs/java/templates/`, with the copied template provider pointed there. What the Java reference documents is still what the packaged generators emit for (`GeneratorApplicability.packaged()`): an owned `routes` whose diff --git a/docs/features/own-your-codegen.md b/docs/features/own-your-codegen.md index 04aba4e11..e83af6ce7 100644 --- a/docs/features/own-your-codegen.md +++ b/docs/features/own-your-codegen.md @@ -811,7 +811,8 @@ other classes (the emitters it writes through, below, and Java `entity` and `ext for the support imports and the template prefix described below: there the imports those copies have of one another change too. On first use it writes `codegen/pom.xml`, which depends on `metaobjects-codegen-spring` or -`metaobjects-codegen-kotlin` at the plugin's version. +`metaobjects-codegen-kotlin` at the plugin's version, or on both when that call ejects for both +ports. Eject changes a copy's package declaration, and for a Java generator up to three more lines, each described below: its import of `SpringGeneratorSupport`, its @@ -996,20 +997,23 @@ documents both surfaces can own both generators in one `codegen/` module without class twice. Eject prints each copy's class name, for example `com.acme.codegen.apidocs.java.JavaApiDocsGenerator`. No other generator's package changes. -**The second port needs the pom changed, and eject prints the change.** Eject never edits an -existing pom, so the `codegen/pom.xml` the first eject wrote builds that port only, and the -module still builds green with the other port's copies left out. When the pom does not declare -what a port in the module needs, eject warns and prints what to add, in either order: - -- **Java first, then Kotlin.** Add the `metaobjects-codegen-kotlin` dependency, the - `kotlin.compiler.jvmTarget` property, and `kotlin-maven-plugin` with `src/main/kotlin` as its - ``. -- **Kotlin first, then Java.** Add the `metaobjects-codegen-spring` dependency, remove - `src/main/kotlin` so that Maven compiles `src/main/java` - again, and give `kotlin-maven-plugin` `src/main/kotlin` as its ``. - -Either way the pom ends up with both dependencies, Maven's default Java source root, and the -Kotlin plugin pointed at its own. +**The pom builds the ports the module held when eject wrote it.** Eject writes +`codegen/pom.xml` once and never edits an existing one, so how a module comes to hold both ports +decides what is left for you to do: + +- **Both ports in one call** (a Java-only name beside a Kotlin-only one, for example + `-Dnames=html-docs,exposed-table`). The pom eject writes builds both. Nothing to change. +- **Java first, then Kotlin in a later call.** Add the `metaobjects-codegen-kotlin` dependency, + the `kotlin.compiler.jvmTarget` property, and `kotlin-maven-plugin` with `src/main/kotlin` as + its ``. +- **Kotlin first, then Java in a later call.** Add the `metaobjects-codegen-spring` dependency, + remove `src/main/kotlin` so that Maven compiles + `src/main/java` again, and give `kotlin-maven-plugin` `src/main/kotlin` as its ``. + +In the two later-call orders the module still builds green with the new port's copies left out, +so eject warns and prints what to add. All three orders end with the same pom: both +dependencies, Maven's default Java source root, and the Kotlin plugin pointed at its own. +`-Dforce` rewrites the pom for every port the module holds. The generator is a `DocsGenerator`. List it, with `` set to your copy, in the `` the docs goal reads, and `metaobjects:docs` runs it for its surface in place of the 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 f58c937a4..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 @@ -165,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<>(); @@ -183,7 +182,6 @@ 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"; String ownedPkg = e.ownedPackage(pkg); @@ -227,7 +225,7 @@ public void execute() throws MojoExecutionException, MojoFailureException { 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()) { @@ -555,16 +553,26 @@ private static String kotlinPlugin(String configuration) { + " \n"; } - private String codegenPom(String artifactId, Set ports) { - StringBuilder deps = new StringBuilder(); + /** 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 (ports.contains(port)) deps.append(dependency(port)); + if (Files.isDirectory(codegenRoot.resolve("src/main/" + port.id))) out.add(port); } + return out; + } + + private String codegenPom(String artifactId, Set ports) { + StringBuilder deps = new StringBuilder(); + 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" - + kotlinPlugin("") + + kotlinPlugin(java ? KOTLIN_SOURCE_DIRS : "") + " \n" + " \n" : ""; @@ -601,8 +609,8 @@ private static boolean declares(String pom, String 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 of one call and never edits it, so a port - * ejected into the module in another call is left out of the build, and the build passes. + * 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"); @@ -612,9 +620,8 @@ private void reportWhatThePomLacks(Path codegenRoot) throws MojoExecutionExcepti } catch (IOException ex) { throw new MojoExecutionException("Could not read " + pom, ex); } - for (EjectSupport.Port port : EjectSupport.Port.values()) { + for (EjectSupport.Port port : portsIn(codegenRoot)) { String sourceRoot = "src/main/" + port.id; - if (!Files.isDirectory(codegenRoot.resolve(sourceRoot))) continue; List changes = new ArrayList<>(); if (!declares(content, codegenArtifact(port))) { changes.add(" Add to :\n" + dependency(port)); 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 index 2dc78af3c..bf2e757bd 100644 --- 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 @@ -3,11 +3,16 @@ import com.metaobjects.generator.DocsGenerator; 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; @@ -22,6 +27,7 @@ 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; @@ -38,6 +44,7 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; /** @@ -74,16 +81,50 @@ public void setUp() throws IOException { /** 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 = "api-docs"; + 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); @@ -161,8 +202,8 @@ private static void assertDocsGeneratorOf(String surface, String classname, Clas assertEquals(classname, surface, ((DocsGenerator) generator).surface()); } - // The pom eject writes builds the ports of that call. 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 + // 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. @@ -172,6 +213,28 @@ public void aPomWrittenForThePortBeingEjectedDrawsNoWarning() throws Exception { 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"); From 32aca6f8836fbd123bd2263b939ed238bcc49bd4 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Mon, 12 Oct 2026 06:58:12 -0400 Subject: [PATCH 06/17] no-mistakes(document): Fix Java ejectable-exemption sentence to exclude deprecated mustache --- docs/ports/java.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ports/java.md b/docs/ports/java.md index 785d2b571..7b174fc45 100644 --- a/docs/ports/java.md +++ b/docs/ports/java.md @@ -586,7 +586,7 @@ copy imports them. The model and the other ports' spelling are in ### Authoring your own — two paths -**Own a generator.** `mvn metaobjects:eject -Dnames=` copies a reference generator into a `codegen/` Maven module under your own package, and prints the module, dependency and `` to wire. See [Own your codegen → Java and Kotlin](../features/own-your-codegen.md#java-and-kotlin-mvn-metaobjectseject). Every generator in the table above can be ejected except `template`. +**Own a generator.** `mvn metaobjects:eject -Dnames=` copies a reference generator into a `codegen/` Maven module under your own package, and prints the module, dependency and `` to wire. See [Own your codegen → Java and Kotlin](../features/own-your-codegen.md#java-and-kotlin-mvn-metaobjectseject). Every generator in the table above can be ejected except `template` and the deprecated `mustache` (see [the primitive](../features/own-your-codegen.md#java-and-kotlin-mvn-metaobjectseject)). **`entity` and `extractor` eject from `metaobjects-codegen-base`, each with the rest of its emit.** `mvn metaobjects:eject -Dnames=entity,extractor -Dport=java` (pass `-Dport`: both names are ejectable on both JVM ports). `entity` copies eight files into one package: `JavaObjectCodeGenerator`, the bases it extends (`JavaCodeGenerator`, `BaseObjectCodeGenerator`), the four writers that emit each class body (`BaseObjectCodeWriter`, `JavaCodeWriter`, `PojoAwareCodeWriter`, `ValueObjectCodeWriter`) and `ExtractorCodeGenerator`, which writes the `Extractor` classes. The copies use one another, never the packaged classes, so you change a field, a constructor or an accessor by editing the writer. `extractor` copies `ExtractorCodeGenerator` with `JavaCodeWriter` and `BaseObjectCodeWriter`, where it reads class names from; in the same package as `entity` the two share that one copy. The file-by-file table is in [Own your codegen → Java `entity` and `extractor`](../features/own-your-codegen.md#java-entity-and-extractor). From abb3109cc6046e085132f115a3c2751330bffe2b Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Mon, 12 Oct 2026 01:29:49 -0400 Subject: [PATCH 07/17] feat(eject): hand over the Java and Kotlin emitters; api-docs and four Java docs generators eject An ejected Java or Kotlin generator was one file over packaged emitters, so a defect in the enum, record-builder, extraction-mirror or prompt-format writers waited for a release. Eject now copies those emitters with the generator, into its package, and points the imports the copies have of one another at the copies. - Java dto and value-object take Fr019SharedEnum and SpringRecordBuilder, output-parser takes Fr019SharedEnum, output-prompt takes OutputFormatSpecEmitter, and entity takes ExtractorCodeGenerator, so an owned entity writes its extractors through the owned copy. - Kotlin entity takes KotlinEnumEmitter, output-parser takes KotlinExtractSchemaEmitter and output-prompt takes KotlinOutputFormatSpecEmitter. Kotlin Fr019SharedEnum stays in the package as engine: it writes nothing and the type mapper takes its config as an argument. - api-docs is a generator in Java and Kotlin, run through a new DocsGenerator marker. metaobjects:docs runs the one the project lists for a surface in place of the packaged one; generate and verify skip it. Eject copies the page model, builder, renderer and path math, and for Java the page templates, with the copied template provider pointed at them. - plantuml, html-docs, ai-docs and json-schema ship eject source, each with its writer. SingleHtmlDirectGeneratorBase drives the new HtmlDirectWriter, and the JSON Schema and AI documentation attributes move to CodeGenMetaDataProvider, with deprecated forwarders left on the two generators. The eject contract's pending list holds no java or kotlin entry. Packaged output is unchanged. --- CHANGELOG.md | 54 +++++- README.md | 2 +- docs/features/own-your-codegen.md | 132 ++++++++++++-- docs/ports/java.md | 11 +- docs/ports/kotlin.md | 9 +- .../registry.json | 82 ++------- server/java/README.md | 2 +- server/java/codegen-base/pom.xml | 28 ++- .../generator/CodeGenMetaDataProvider.java | 109 +++++++++++- .../metaobjects/generator/DocsGenerator.java | 39 +++++ .../ai/MetaDataAIDocumentationGenerator.java | 78 ++++----- .../json/MetaDataFileJsonSchemaGenerator.java | 85 ++++----- .../metadata/html/HtmlDirectWriter.java | 21 +++ .../MetaDataHtmlDocumentationGenerator.java | 4 + .../html/MetaDataHtmlDocumentationWriter.java | 6 +- .../html/SingleHtmlDirectGeneratorBase.java | 6 +- server/java/codegen-kotlin/pom.xml | 18 ++ .../generator/kotlin/Fr019SharedEnum.kt | 10 +- .../generator/kotlin/GeneratorRegistry.kt | 41 +++++ .../generator/kotlin/KotlinEnumEmitter.kt | 7 + .../kotlin/KotlinExtractSchemaEmitter.kt | 9 + .../kotlin/KotlinOutputFormatSpecEmitter.kt | 6 + .../kotlin/apidocs/KotlinApiDocsGenerator.kt | 61 +++++++ .../generator/kotlin/EjectContractTest.kt | 42 ++++- .../kotlin/EjectedGeneratorsCompileTest.kt | 16 +- .../generator/kotlin/ReportingInertTest.kt | 38 ++-- server/java/codegen-plantuml/pom.xml | 30 ++++ server/java/codegen-spring/pom.xml | 44 +++++ .../generator/GeneratorRegistry.java | 123 +++++++++++-- .../apidocs/GeneratorApplicability.java | 55 ++++++ .../apidocs/JavaApiDocsGenerator.java | 78 +++++++++ .../apidocs/JavaApiModelBuilder.java | 36 ++-- .../generator/spring/Fr019SharedEnum.java | 5 + .../spring/OutputFormatSpecEmitter.java | 4 + ...edPathNeverReachesPackagedSupportTest.java | 14 ++ .../generator/spring/ReportingInertTest.java | 27 ++- .../mojo/AbstractMetaDataMojo.java | 19 ++ .../java/com/metaobjects/mojo/DocsMojo.java | 164 ++++++------------ .../com/metaobjects/mojo/EjectSupport.java | 97 ++++++++--- .../metaobjects/mojo/MetaDataEjectMojo.java | 47 +++++ .../mojo/MetaDataGeneratorMojo.java | 5 + .../metaobjects/mojo/MetaDataVerifyMojo.java | 7 + .../mojo/DocsGeneratorGoalsTest.java | 128 ++++++++++++++ .../metaobjects/mojo/EjectContractTest.java | 18 +- .../mojo/EjectRuntimeRoundTripTest.java | 5 + .../metaobjects/mojo/EjectSupportTest.java | 78 ++++++++- .../EntityExtractorEjectRoundTripTest.java | 14 +- .../mojo/MetaDataEjectMojoTest.java | 26 +++ .../mojo/test/OwnedDocsGenerator.java | 23 +++ .../ADR-0034-codegen-scaffold-and-own.md | 26 +-- 50 files changed, 1567 insertions(+), 422 deletions(-) create mode 100644 server/java/codegen-base/src/main/java/com/metaobjects/generator/DocsGenerator.java create mode 100644 server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/metadata/html/HtmlDirectWriter.java create mode 100644 server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/apidocs/KotlinApiDocsGenerator.kt create mode 100644 server/java/codegen-spring/src/main/java/com/metaobjects/generator/apidocs/GeneratorApplicability.java create mode 100644 server/java/codegen-spring/src/main/java/com/metaobjects/generator/apidocs/JavaApiDocsGenerator.java create mode 100644 server/java/maven-plugin/src/test/java/com/metaobjects/mojo/DocsGeneratorGoalsTest.java create mode 100644 server/java/maven-plugin/src/test/java/com/metaobjects/mojo/test/OwnedDocsGenerator.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d06b489a..dcade21e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -125,8 +125,48 @@ generator catalog and the Atlas evaluation are 1.2 work. pages `meta docs` writes) and `agent-docs` (the pages `meta docs --agent` writes), both neutral and both still run by `meta docs`; a `generators` entry naming either string is refused the way `"docs"` is. Java: `plantuml`, `html-docs`, `ai-docs`, `json-schema` and the - deprecated `mustache`, each by the class name a pom already uses. None of the seven ships - eject source yet; `mustache` will not. + deprecated `mustache`, each by the class name a pom already uses. `mustache` will not ship + eject source. The four Java generators that can ship it do: see **Java and Kotlin** below. + +- **Java and Kotlin: eject hands over the emitters a generator writes through, and + `api-docs`, `plantuml`, `html-docs`, `ai-docs` and `json-schema` are ejectable.** An ejected + Java or Kotlin generator used to be one file over packaged emitters, so a defect in the enum, + record-builder, extraction-mirror or prompt-format writers still waited for a release. Eject + now copies those emitters with the generator, into its package, and points the imports the + copies have of one another at the copies. Java `dto` and `value-object` take + `Fr019SharedEnum` and `SpringRecordBuilder`; Java `output-parser` takes `Fr019SharedEnum`; + Java `output-prompt` takes `OutputFormatSpecEmitter`; Kotlin `entity` takes + `KotlinEnumEmitter`; Kotlin `output-parser` takes `KotlinExtractSchemaEmitter`; Kotlin + `output-prompt` takes `KotlinOutputFormatSpecEmitter` and `KotlinExtractSchemaEmitter`. A + class two generators share is written once and never overwritten. The naming and type-mapping + tables, the model walks and the generator bases stay in the package as the engine. Kotlin + `Fr019SharedEnum` is engine: it writes nothing, and the type mapper takes its + `ProvidedEnumConfig` as an argument, so its `providedInDeclarationPackage` and + `DECLARATION_PACKAGE` members are now public. The owned Java `entity` writes its extractors + through the owned `ExtractorCodeGenerator`, where it used to construct the packaged one. + Packaged output is unchanged. A copy you ejected earlier keeps compiling with the packaged + emitters; own them with the recipe in + [own-your-codegen](docs/features/own-your-codegen.md#ejecting-a-generator-hands-over-the-emitters-it-writes-through-java-and-kotlin). + - **`api-docs` is a generator, and ejectable, in Java and Kotlin.** The api reference + `mvn metaobjects:docs` writes is now `JavaApiDocsGenerator` / `KotlinApiDocsGenerator`, run + through a new `DocsGenerator` marker (`metaobjects-codegen-base`). `metaobjects:generate` and + `metaobjects:verify` skip a `DocsGenerator` and say so in the build log; `metaobjects:docs` + runs the one your `` list names for a surface, else the packaged one. Eject + copies the generator, the page model, the builder, the renderer and the path math, and for + Java the page templates too, into `codegen/src/main/resources//templates/`, + with the copied template provider pointed there. What the Java reference documents comes + from `GeneratorApplicability.packaged()`, so it describes the packaged generators even in a + project that owns a copy of one. Page output and the goal's parameters + (`language`, `apiSubDir`, `layout`, `modelBaseUrl`, `outputDirectory`) are unchanged. + - **The Java `plantuml`, `html-docs`, `ai-docs` and `json-schema` generators ship eject + source**, each with the writer that emits its file. `SingleHtmlDirectGeneratorBase` now + drives the new `HtmlDirectWriter`, as the JSON base drives `JsonDirectWriter`, so a copied + HTML generator pairs with the copy of its writer. A subclass of the base that overrode + `getWriter` or `writeHtml` with `MetaDataHtmlDocumentationWriter` in the signature needs + `HtmlDirectWriter` there. The attributes the JSON Schema and AI documentation generators + add to the metamodel moved to `CodeGenMetaDataProvider`, where the loader registers them, + so a copy owns emit code only; the old static methods and constants on the two generators + stay as deprecated forwarders. - **The Java `entity` and `extractor` generators can be ejected.** `mvn metaobjects:eject -Dnames=entity,extractor -Dport=java` copies each generator, with @@ -137,14 +177,16 @@ generator catalog and the Atlas evaluation are 1.2 work. `identical` or `DIFFERS`, and `mvn metaobjects:verify` runs them when your pom names them. Every Java generator is now ejectable except `template`. Ruling and reasoning: ADR-0034 Amendment 6. - - **The owned `entity` includes the class body.** Eject copies seven files into one + - **The owned `entity` includes the class body.** Eject copies eight files into one package: `JavaObjectCodeGenerator`, the two bases it extends (`JavaCodeGenerator`, - `BaseObjectCodeGenerator`), and the four writers that emit each class + `BaseObjectCodeGenerator`), the four writers that emit each class (`BaseObjectCodeWriter`, `JavaCodeWriter`, `PojoAwareCodeWriter`, - `ValueObjectCodeWriter`). The copies use one another, never the packaged classes: where + `ValueObjectCodeWriter`) and `ExtractorCodeGenerator`, which writes the + `Extractor` classes `entity` emits when no separate `extractor` is in the run. The + copies use one another, never the packaged classes: where one imports another, eject rewrites that import along with the package line. You change a field, a constructor or an accessor by editing the writer, with nothing to subclass. - `-Dlist` gives `entity` one verdict over all seven files. Details: + `-Dlist` gives `entity` one verdict over all eight files. Details: [own-your-codegen](docs/features/own-your-codegen.md#java-entity-and-extractor). - **`extractor` is a generator of its own (Java).** `ExtractorCodeGenerator` can be named in ``, with the same `outputDir`, `pkgPrefix`, `pkgSuffix`, `namePrefix`, diff --git a/README.md b/README.md index 6ddbb233a..411ba70bc 100644 --- a/README.md +++ b/README.md @@ -134,7 +134,7 @@ first-week wedge plan — and `meta init` picks up from there. |---|---|---|---| | TypeScript | **npm `1.0.13`** — the `@metaobjectsdev/*` packages | [`docs/ports/typescript.md`](docs/ports/typescript.md) | [`server/typescript/`](server/typescript/) · [`client/web/`](client/web/) | | Java | **Maven Central `8.0.13`** (`com.metaobjects:*` — the JVM major is npm major + 7) — loader + OMDB + render + Maven plugin all shipped; full conformance green | [`docs/ports/java.md`](docs/ports/java.md) | [`server/java/`](server/java/) | -| Kotlin | **Maven Central `8.0.13`** — codegen tier on top of Java: 15 generators (entity, Exposed table, names, relations, repository, output-parser, output-prompt, render-helper, extractor, filter-allowlist, validator, Spring config, storedProc, Spring controller, requirement-tests); 24 / 24 persistence-conformance | [`docs/ports/kotlin.md`](docs/ports/kotlin.md) | [`server/java/codegen-kotlin/`](server/java/codegen-kotlin/) · [`server/java/metadata-ktx/`](server/java/metadata-ktx/) | +| Kotlin | **Maven Central `8.0.13`** — codegen tier on top of Java: 16 generators (entity, Exposed table, names, relations, repository, output-parser, output-prompt, render-helper, extractor, filter-allowlist, validator, Spring config, storedProc, Spring controller, requirement-tests, api-docs); 24 / 24 persistence-conformance | [`docs/ports/kotlin.md`](docs/ports/kotlin.md) | [`server/java/codegen-kotlin/`](server/java/codegen-kotlin/) · [`server/java/metadata-ktx/`](server/java/metadata-ktx/) | | C# | **NuGet `1.0.13`** — loader + conformance + EF Core codegen + render engine + `dotnet meta` CLI all shipped | [`docs/ports/csharp.md`](docs/ports/csharp.md) | [`server/csharp/`](server/csharp/) | | Python | **PyPI `1.0.13`** — loader + conformance + render + entity-model codegen + ObjectManager runtime shipped; schema migrations are TS-owned (ADR-0015) | [`docs/ports/python.md`](docs/ports/python.md) | [`server/python/`](server/python/) | diff --git a/docs/features/own-your-codegen.md b/docs/features/own-your-codegen.md index 362332f60..bcafc2b4f 100644 --- a/docs/features/own-your-codegen.md +++ b/docs/features/own-your-codegen.md @@ -896,14 +896,16 @@ always wins over one with the same name. So eject puts each copy into a separate `codegen/` Maven module, under your own package (`-Dpackage`, default `.codegen`). Every copy compiles on its own, and none calls another generator. The package declaration is the only line it changes, except for a generator that is copied with -other classes (Java `entity` and `extractor`, below) and for the support imports described -below: there the imports those copies have of one another change too. On first use +other classes (the emitters it writes through, below, and Java `entity` and `extractor`) and +for the support imports and the template prefix described below: there the imports those +copies have of one another change too. On first use it writes `codegen/pom.xml`, which depends on `metaobjects-codegen-spring` or `metaobjects-codegen-kotlin` at the plugin's version. -Eject changes a copy's package declaration, and for a Java generator up to two more -lines, both described below: its import of `SpringGeneratorSupport`, and its -`RUNTIME_PACKAGE` constant. +Eject changes a copy's package declaration, and for a Java generator up to three more +lines, each described below: its import of `SpringGeneratorSupport`, its +`RUNTIME_PACKAGE` constant, and, for `api-docs`, the classpath prefix its template +provider reads from. Eject never edits an existing pom. It prints the three things to wire: the `` entry for your parent pom, a plugin `` on the codegen module for the module @@ -929,7 +931,7 @@ Both names exist in both ports, so pass `-Dport`. Their reference source ships i `metaobjects-codegen-base`, not `metaobjects-codegen-spring`; your `codegen/` module reaches it through the same dependency. -**`entity` copies seven files, and together they are the generator.** +**`entity` copies eight files, and together they are the generator.** `JavaObjectCodeGenerator` does not emit a class body itself. A writer does, one per flavor, so the writers come with it: @@ -940,19 +942,22 @@ flavor, so the writers come with it: | `BaseObjectCodeWriter` | The class header comment, and the per-field loop every flavor shares. | | `JavaCodeWriter` | The Java type of each field; accessor, parameter, class and package names; the body of the interface flavor. | | `PojoAwareCodeWriter`, `ValueObjectCodeWriter` | The fields, the constructor and the accessor bodies of the two concrete flavors. | +| `ExtractorCodeGenerator` | The `Extractor` classes `entity` writes beside each concrete class, unless a separate `extractor` generator is in the run. | -All seven land in one package and use one another, never the packaged classes: eject +All eight land in one package and use one another, never the packaged classes: eject rewrites the package line of each and, where one imports another, that import. To change a field, a constructor or an accessor, edit the writer. You subclass nothing. `-Dlist` gives -`entity` one verdict over all seven files. +`entity` one verdict over all eight files. What stays in the package holds no emit: `MultiFileDirectGeneratorBase` (the one-file-per-object loop), `FileDirectWriter` (indentation and `println`) and `GeneratedFileWriter` (the write guard: a file with no `GENERATED` marker is left alone). -The owned `entity` still calls the packaged `ExtractorCodeGenerator` to write the -extractors, until you wire an `extractor` generator of your own. +The extractors the owned `entity` writes come from the owned copy of +`ExtractorCodeGenerator` beside it, so changing how an extractor reads is an edit in code you +own whether or not you also wire an `extractor` generator. -**`extractor` copies three files.** `ExtractorCodeGenerator.java` holds its emit. +**`extractor` copies three files.** `ExtractorCodeGenerator.java` holds its emit. Eject it +beside `entity`, in the same package, and they share that one copy. `JavaCodeWriter` and `BaseObjectCodeWriter` come with it because that is where it reads the package and class name of the type each extractor casts to. Eject both generators into the same package, which is the default, and they share the one `JavaCodeWriter`: a naming @@ -982,6 +987,104 @@ each with that entry's `outputDir`, naming args and ``. A single `extra generator in the run stops every `entity` entry writing its extractors, so an `entity` entry left without one gets none. +### Ejecting a generator hands over the emitters it writes through (Java and Kotlin) + +A generator that calls an emitter writes part of its output through it. If eject left the +emitter in the package, the copy would own the orchestration and the package would still own +the text, and a defect in that text would wait for a release. So eject copies the emitters +too, into the generator's package, and points the imports the copies have of one another at +the copies: + +| Port | Eject | Also writes | +|---|---|---| +| Java | `dto`, `value-object` | `Fr019SharedEnum` (the shared and provided enum files and their type references), `SpringRecordBuilder` (the `builder()` a record carries) | +| Java | `output-parser` | `Fr019SharedEnum` | +| Java | `output-prompt` | `OutputFormatSpecEmitter` (the `OutputFormatSpec` literal) | +| Java | `entity` | the eight files above, `ExtractorCodeGenerator` among them | +| Kotlin | `entity` | `KotlinEnumEmitter` (the enum class file) | +| Kotlin | `output-parser` | `KotlinExtractSchemaEmitter` (the extraction mirror and the literal helpers) | +| Kotlin | `output-prompt` | `KotlinOutputFormatSpecEmitter`, `KotlinExtractSchemaEmitter` | +| Kotlin | `api-docs` | the page model, builder, renderer and path math, below | + +A class two generators share is written once. A later eject finds it, says it is +`IDENTICAL` or `DIFFERS`, and leaves your edits alone; `-Dforce` replaces it with the reference, +as it replaces the generator. Each copy is the packaged source with the same package edit +as the generator and the same import rewrite, and the Java copies carry an explicit import of +the packaged sibling classes they still call, so they compile from any package. + +**What stays in the package is the engine**, public and unchanged: the generator base and +runner types, the naming and type-mapping tables (`SpringNaming`, `SpringTypeMapper`, +`KotlinNaming`, `KotlinTypeMapper`), the questions a generator asks of the model +(`FindInbound`, `TphPlan`, the M:N support, `RestSurfaceGate`), and the Kotlin +`Fr019SharedEnum`. Anything that returns emitted text is handed over; a table or a model +question is not. The Kotlin `Fr019SharedEnum` writes no output. It answers where a shared or +`@provided` enum lives, and the type mapper and the generator utilities take its +`ProvidedEnumConfig` as an argument, so it is the same kind of thing as a type-mapping table. +The Java `Fr019SharedEnum` is different: it also writes the enum files, so it is handed over. + +The line is written down and checked. `EjectContractTest`, in the Maven plugin for Java and in +`codegen-kotlin` for Kotlin, ejects each generator the registry lists alone into an empty +project, compiles it against the published artifacts, fails on any reference to a packaged +class outside the engine surface in `fixtures/generator-registry-conformance/registry.json`, +and compares what the copy emits with what the packaged generator emits, byte for byte. + +**A copy you ejected before the emitters were handed over** keeps compiling and running, with +the packaged emitters. To own them, raise your `codegen/` module's dependency to 1.1.0 or +later and re-eject the generator into a scratch project, then copy the emitter files next to +your generator: + +```bash +mvn metaobjects:eject -Dnames=dto,value-object -Dport=java -Dpackage=com.acme.scratch # in a scratch project +cp scratch/codegen/src/main/java/com/acme/scratch/{Fr019SharedEnum,SpringRecordBuilder}.java codegen/src/main/java/com/acme/codegen/ +``` + +Change the `package` line of each file you copy, and point your generator's imports of them at +your package. `mvn metaobjects:eject -Dlist` then marks the copy `identical` or `DIFFERS`. + +### `api-docs`: the api reference as an owned generator + +`mvn metaobjects:docs` writes the Java and the Kotlin api reference. `mvn metaobjects:eject +-Dnames=api-docs -Dport=java` copies the generator that writes the Java one, with the page +model (`ApiSymbol`, `ApiUnit`, `FieldShape`, `ApiSymbolKind`, `JavaApiModel`, `UnitExample`), the +builder that decides what to document (`JavaApiModelBuilder`, `JavaFieldShapes`), the renderer +(`JavaApiDocsRenderer`), the path math (`DocsPaths`) and the template provider, and writes the +page templates into `codegen/src/main/resources//templates/api/`. Eject +points the copied provider at that directory, so the page text is the one you own and no other +jar on the classpath can shadow it. `-Dport=kotlin` copies `KotlinApiDocsGenerator`, +`KotlinApiModel`, `KotlinApiModelBuilder`, `KotlinApiDocsRenderer` and `DocsPaths`; the Kotlin +renderer writes its pages in code, so it has no templates. + +The generator is a `DocsGenerator`. List it in `` with `` set to your copy, +and `metaobjects:docs` runs it for its surface in place of the packaged one. `metaobjects:generate` +and `metaobjects:verify` skip it, because it writes pages under the docs directory and not code +under `outputDir`, and say so in the build log. The docs goal sets `outputDir`, `projectName`, +`apiSubDir`, `layout` and `modelBaseUrl` on it, and the pages land under +`/`. + +**It describes what the packaged generators emit.** The Java reference documents a controller, +a filter allowlist, a render helper only if the packaged generator that emits it would, which each +answers through its static `appliesTo`. The builder asks through `GeneratorApplicability` +(`GeneratorApplicability.packaged()`), never through a generator class, which is why the copy +depends on nothing but the engine. That holds in a project that owns one of those generators +too: an owned `routes` whose `appliesTo` you changed is still documented as the packaged one +behaves. To document your own surface, change the one line of your `JavaApiDocsGenerator` copy +that builds the `GeneratorApplicability`. The Kotlin builder asks the engine's own gates, which +the owned Kotlin generators share, so it has nothing to inject. + +### The docs, diagram and schema generators (Java) + +`plantuml`, `html-docs`, `ai-docs` and `json-schema` were registered, and wirable by class name, +before they were ejectable. Each is a generator and the writer that emits its file, and eject +copies both: `PlantUMLGenerator` with `PlantUMLWriter`, `MetaDataHtmlDocumentationGenerator` with +`MetaDataHtmlDocumentationWriter`, `MetaDataAIDocumentationGenerator` with +`MetaDataAIDocumentationWriter`, `MetaDataFileJsonSchemaGenerator` with `MetaDataFileSchemaWriter`. +The generator bases (`SingleFileDirectGeneratorBase`, `SingleJsonDirectGeneratorBase`, +`SingleHtmlDirectGeneratorBase`) and the writer bases they drive (`JsonDirectWriter`, +`HtmlDirectWriter`) stay in the package as engine. The attributes the JSON Schema and AI +documentation generators add to the metamodel are metamodel vocabulary, registered by +`CodeGenMetaDataProvider` and read by the loader, so they stay in the package too. The old static +methods on the two generators forward to it. + ### What eject hands over, and what stays core An owned generator is only yours if the rules it applies and the code its output depends @@ -1096,10 +1199,9 @@ have no edits of your own (`-Dlist` says `identical`), re-eject with `-Dforce`. `-Dport` is needed only when a name exists in both ports and your project's dependencies do not say which. Every Kotlin generator is ejectable. Every Java generator is too, except -`template`, the deprecated `mustache`, and four that are registered and do not ship eject -source yet (`plantuml`, `html-docs`, `ai-docs`, `json-schema`). `template` is the Mustache -primitive: the template you supply is its emit logic, so the generator has none of its own -to hand over. That holds in every port that ships it. +`template` and the deprecated `mustache`. `template` is the Mustache primitive: the template you +supply is its emit logic, so the generator has none of its own to hand over. That holds in every +port that ships it. ## C#: `dotnet meta eject` diff --git a/docs/ports/java.md b/docs/ports/java.md index 41b6a8875..f5ca073f1 100644 --- a/docs/ports/java.md +++ b/docs/ports/java.md @@ -288,10 +288,11 @@ name in every port. | `extractor` | `ExtractorCodeGenerator` | `metaobjects-codegen-base` | One `Extractor` per concrete object, wrapping the runtime tolerant extract and turning dirty LLM text into a fully-typed flavored object graph (nested objects + arrays-of-objects populated) in one call. Args: `outputDir`, and the `pkgPrefix` / `pkgSuffix` / `namePrefix` / `nameSuffix` your `entity` generator uses; give it the same `` too. Needs `entity` with a concrete `flavor` in the same run, since an extractor casts to the flavored class. It names `MetaObjectExtractor` (in `metaobjects-om`) by FQN string only, so `codegen-base` keeps no compile dependency on `om` — the reference resolves on the consumer's classpath. | | `trace-helper` | `LlmTraceHelperGenerator` | `metaobjects-codegen-spring` | One `TraceHelper` per concrete entity that transitively `extends` `metaobjects::ai::LlmCallBase` **and** nests a `template.prompt` carrying `@responseRef` — a static `record(...)` that extracts the typed response, builds the `LlmCallBase` trace row, and persists it. Emits nothing for any other entity. | | `template` | `TemplateScopeGenerator` | `metaobjects-codegen-base` | The generic Mustache primitive: walk the model by a named `scope`, render one shared template per walk result, write each file. The declarative alternative to writing a generator class — see [Declarative template-codegen](#declarative-template-codegen-templatescopegenerator) below. It renders through `render.templategen.TemplateGenerator`, the byte-pinned cross-port factory; that factory takes a walk callback and is not itself a `Generator`, so `TemplateScopeGenerator` is the class a pom names. | -| `plantuml` | `PlantUMLGenerator` | `metaobjects-codegen-plantuml` | One PlantUML class diagram of the object model (`outputDir`, `outputFilename`; `showAttrs`, `showFields`, `showAbstracts`, `drawKeys`, `drawRefs` choose what is drawn). Not ejectable yet. | -| `html-docs` | `MetaDataHtmlDocumentationGenerator` | `metaobjects-codegen-base` | One HTML page describing the registered metamodel types (`outputDir`, `outputFilename`). It reads the type registry, not your model, so it documents the vocabulary your build loads, providers included. Not ejectable yet. | -| `ai-docs` | `MetaDataAIDocumentationGenerator` | `metaobjects-codegen-base` | The same type reference as JSON, laid out for an AI reader (`outputDir`, `outputFilename`). Not ejectable yet. | -| `json-schema` | `MetaDataFileJsonSchemaGenerator` | `metaobjects-codegen-base` | A JSON Schema that validates metadata files, built from the type registry (`outputDir`, `outputFilename`). Not ejectable yet. | +| `api-docs` | `JavaApiDocsGenerator` | `metaobjects-codegen-spring` | The Java SDK api reference `mvn metaobjects:docs` writes: one page per entity, value object, projection, served report and template, an index and `AGENT-API.md`, under `/api/java`. A `DocsGenerator`: `metaobjects:generate` and `metaobjects:verify` skip it. List an owned copy in `` and the docs goal runs it in place of the packaged one. Ejectable: the copy takes the page IR, the builder, the renderer, the page paths and the page templates with it. | +| `plantuml` | `PlantUMLGenerator` | `metaobjects-codegen-plantuml` | One PlantUML class diagram of the object model (`outputDir`, `outputFilename`; `showAttrs`, `showFields`, `showAbstracts`, `drawKeys`, `drawRefs` choose what is drawn). Ejectable: the copy takes `PlantUMLWriter` with it. | +| `html-docs` | `MetaDataHtmlDocumentationGenerator` | `metaobjects-codegen-base` | One HTML page describing the registered metamodel types (`outputDir`, `outputFilename`). It reads the type registry, not your model, so it documents the vocabulary your build loads, providers included. Ejectable: the copy takes `MetaDataHtmlDocumentationWriter` with it. | +| `ai-docs` | `MetaDataAIDocumentationGenerator` | `metaobjects-codegen-base` | The same type reference as JSON, laid out for an AI reader (`outputDir`, `outputFilename`). Ejectable: the copy takes `MetaDataAIDocumentationWriter` with it. | +| `json-schema` | `MetaDataFileJsonSchemaGenerator` | `metaobjects-codegen-base` | A JSON Schema that validates metadata files, built from the type registry (`outputDir`, `outputFilename`). Ejectable: the copy takes `MetaDataFileSchemaWriter` with it. | | `mustache` | `MustacheTemplateGenerator` | `metaobjects-codegen-mustache` | Deprecated. The Mustache generator that predates `template`; it stays so a pom that names its class keeps building. Use `template`. It is not ejectable and will not be. | Wire any generator via the Maven plugin's `` entry pointing at its class. @@ -363,7 +364,7 @@ mvn metaobjects:eject -Dnames=value-object,output-parser -Dport=java -Dforce the copy and rewrites `codegen/pom.xml`, so review both diffs. For an edited copy, diff the new reference against it and apply the hunks by hand, as in [Pulling an upstream fix into your copy](../features/own-your-codegen.md#what-eject-hands-over-and-what-stays-core). -An owned `dto` copy needs nothing for this: it asks the packaged `Fr019SharedEnum` helper for +An owned `dto` copy needs nothing for this: it carries its own `Fr019SharedEnum` and asks it for each enum's type, so it follows the new rule without a change. ### Reports diff --git a/docs/ports/kotlin.md b/docs/ports/kotlin.md index aa3e8e4d6..72a748dd0 100644 --- a/docs/ports/kotlin.md +++ b/docs/ports/kotlin.md @@ -9,7 +9,7 @@ wiring) via KotlinPoet. Two modules: -- **`metaobjects-codegen-kotlin`** — 15 KotlinPoet-based generators. +- **`metaobjects-codegen-kotlin`** — 16 KotlinPoet-based generators. - **`metaobjects-metadata-ktx`** — thin Kotlin facade over the Java loader + render engine for idiomatic Kotlin runtime use. @@ -88,7 +88,7 @@ Two modules: ## Configure -The 15 generators registered in `codegen-kotlin` (`GeneratorRegistry.kt`): +The 16 generators registered in `codegen-kotlin` (`GeneratorRegistry.kt`): | Generator | Output | Per | |---|---|---| @@ -107,6 +107,7 @@ The 15 generators registered in `codegen-kotlin` (`GeneratorRegistry.kt`): | `KotlinStoredProcGenerator` | Stored-procedure call wrappers | entities with `source.rdb @kind="storedProc"` | | `KotlinSpringControllerGenerator` | `Controller.kt` — Spring `@RestController` (5 CRUD endpoints; cross-port API contract). **Select `KotlinRelationsGenerator` with it** when the model has a M:N relationship: the traversal routes call the `Query` helpers only that generator emits, and without it the controller does not compile | entities with `source.rdb @kind="table"` | | `KotlinRequirementTestsGenerator` | Per metamodel package that holds a tested requirement, two files in `testPackage`: `Requirements__Witnesses.kt` (an interface whose default members fail with `unimplemented requirement: ...`) and `Requirements__Test.kt` (one JUnit Jupiter `@Test` per requirement, calling a project-owned witness). See "Requirement tests" below | every tested `requirement.*` (functional, level 4 or above by default) | +| `KotlinApiDocsGenerator` | The Kotlin SDK api reference `mvn metaobjects:docs` writes under `/api/kotlin`: `.md` per entity, value object, projection, served report and template, a `README.md` index and `AGENT-API.md`. A `DocsGenerator`: `metaobjects:generate` and `metaobjects:verify` skip it, and `metaobjects:docs` runs your copy in place of the packaged one when `` names it. Ejectable, with the page model, builder, renderer and path math | the whole model, once | Maven wiring: @@ -177,11 +178,13 @@ A requirement that becomes live adds a failing member (a red test, no compile br **Own a generator.** `mvn metaobjects:eject -Dnames=` copies a reference generator into a `codegen/` Maven module under your own package, and prints the module, dependency and `` to wire. See [Own your codegen → Java and Kotlin](../features/own-your-codegen.md#java-and-kotlin-mvn-metaobjectseject). +**Ejecting hands over the emitters a generator writes through.** `entity` takes `KotlinEnumEmitter` with it, `output-parser` takes `KotlinExtractSchemaEmitter`, and `output-prompt` takes `KotlinOutputFormatSpecEmitter` and `KotlinExtractSchemaEmitter`, into the same package, with the imports the copies have of one another pointed at the copies. `Fr019SharedEnum` stays in the package: it writes nothing and the type mapper takes its `ProvidedEnumConfig` as an argument. See [Own your codegen → emitters](../features/own-your-codegen.md#ejecting-a-generator-hands-over-the-emitters-it-writes-through-java-and-kotlin). + **Nothing else to hand over.** Unlike the Java `routes`/`dto`/`repository` output, no Kotlin generator's output imports helper runtime. The generated controller declares its filter parser, constraint mapping and patch handling inline, per file, and the rest imports only core: render and extract, the loader, and `metadata-ktx`. So an ejected Kotlin generator's output depends on nothing you do not own beyond the core. `EjectRuntimeRoundTripTest` in the Maven plugin checks this against every Kotlin generator's real output. `com.metaobjects.generator.kotlin.runtime.M2mJoinResolver` is a helper you may call from hand-written traversal code. No generated file imports it, so eject does not copy it; copy it yourself if you use it and want to own it. ### Declarative template-codegen (`TemplateScopeGenerator`) -The 15 generators above are a starting point, not the ceiling. When you need a shape +The 16 generators above are a starting point, not the ceiling. When you need a shape none of them emits, Kotlin has **both** authoring paths — and which to reach for is a real decision (tradeoff table: [`codegen-concepts.md` §3](../features/codegen-concepts.md)). diff --git a/fixtures/generator-registry-conformance/registry.json b/fixtures/generator-registry-conformance/registry.json index d5e49a450..2e3e040a3 100644 --- a/fixtures/generator-registry-conformance/registry.json +++ b/fixtures/generator-registry-conformance/registry.json @@ -362,6 +362,8 @@ "java": { "codegenPackages": ["com.metaobjects.generator.**"], "engine": [ + "com.metaobjects.generator.CodeGenMetaDataProvider", + "com.metaobjects.generator.DocsGenerator", "com.metaobjects.generator.EmitsObjectExtractors", "com.metaobjects.generator.EmitsPhysicalNameConstants", "com.metaobjects.generator.FileEmittingGenerator", @@ -372,9 +374,15 @@ "com.metaobjects.generator.GeneratorIOWriter", "com.metaobjects.generator.MetaDataFilters", "com.metaobjects.generator.ProjectClassLoaderAware", + "com.metaobjects.generator.apidocs.GeneratorApplicability", "com.metaobjects.generator.direct.FileDirectWriter", "com.metaobjects.generator.direct.GenerationContext", "com.metaobjects.generator.direct.MultiFileDirectGeneratorBase", + "com.metaobjects.generator.direct.SingleFileDirectGeneratorBase", + "com.metaobjects.generator.direct.metadata.html.HtmlDirectWriter", + "com.metaobjects.generator.direct.metadata.html.SingleHtmlDirectGeneratorBase", + "com.metaobjects.generator.direct.metadata.json.JsonDirectWriter", + "com.metaobjects.generator.direct.metadata.json.SingleJsonDirectGeneratorBase", "com.metaobjects.generator.direct.object.BaseObjectCodeGenerator", "com.metaobjects.generator.requirement.RenderedTest", "com.metaobjects.generator.requirement.RequirementTestArgs", @@ -394,6 +402,7 @@ "kotlin": { "codegenPackages": ["com.metaobjects.generator.**"], "engine": [ + "com.metaobjects.generator.DocsGenerator", "com.metaobjects.generator.EmitsPhysicalNameConstants", "com.metaobjects.generator.FileEmittingGenerator", "com.metaobjects.generator.Generator", @@ -405,6 +414,7 @@ "com.metaobjects.generator.kotlin.ExposedApi", "com.metaobjects.generator.kotlin.ExposedImports", "com.metaobjects.generator.kotlin.FindInbound", + "com.metaobjects.generator.kotlin.Fr019SharedEnum", "com.metaobjects.generator.kotlin.KotlinGenUtil", "com.metaobjects.generator.kotlin.KotlinM2mSupport", "com.metaobjects.generator.kotlin.KotlinNaming", @@ -450,78 +460,6 @@ } ], "pending": [ - {"generator": "api-docs", "port": "java", "assertion": "census", "gap": 6}, - {"generator": "api-docs", "port": "kotlin", "assertion": "census", "gap": 6}, - {"generator": "ai-docs", "port": "java", "assertion": "completeness", "gap": 7}, - {"generator": "html-docs", "port": "java", "assertion": "completeness", "gap": 7}, - {"generator": "json-schema", "port": "java", "assertion": "completeness", "gap": 7}, - {"generator": "plantuml", "port": "java", "assertion": "completeness", "gap": 7}, - { - "generator": "dto", - "port": "java", - "assertion": "depth", - "gap": 5, - "outside": ["com.metaobjects.generator.spring.Fr019SharedEnum", "com.metaobjects.generator.spring.SpringRecordBuilder"] - }, - { - "generator": "entity", - "port": "java", - "assertion": "depth", - "gap": 13, - "outside": ["com.metaobjects.generator.direct.object.javacode.ExtractorCodeGenerator"] - }, - {"generator": "output-parser", "port": "java", "assertion": "depth", "gap": 5, "outside": ["com.metaobjects.generator.spring.Fr019SharedEnum"]}, - { - "generator": "output-prompt", - "port": "java", - "assertion": "depth", - "gap": 4, - "outside": ["com.metaobjects.generator.spring.OutputFormatSpecEmitter"] - }, - { - "generator": "value-object", - "port": "java", - "assertion": "depth", - "gap": 5, - "outside": ["com.metaobjects.generator.spring.Fr019SharedEnum", "com.metaobjects.generator.spring.SpringRecordBuilder"] - }, - { - "generator": "entity", - "port": "kotlin", - "assertion": "depth", - "gap": 5, - "outside": ["com.metaobjects.generator.kotlin.Fr019SharedEnum", "com.metaobjects.generator.kotlin.KotlinEnumEmitter"] - }, - { - "generator": "exposed-table", - "port": "kotlin", - "assertion": "depth", - "gap": 5, - "outside": ["com.metaobjects.generator.kotlin.Fr019SharedEnum"] - }, - { - "generator": "output-parser", - "port": "kotlin", - "assertion": "depth", - "gap": 4, - "outside": ["com.metaobjects.generator.kotlin.KotlinExtractSchemaEmitter"] - }, - { - "generator": "output-parser", - "port": "kotlin", - "assertion": "depth", - "gap": 5, - "outside": ["com.metaobjects.generator.kotlin.Fr019SharedEnum"] - }, - { - "generator": "output-prompt", - "port": "kotlin", - "assertion": "depth", - "gap": 4, - "outside": ["com.metaobjects.generator.kotlin.KotlinOutputFormatSpecEmitter"] - }, - {"generator": "relations", "port": "kotlin", "assertion": "depth", "gap": 5, "outside": ["com.metaobjects.generator.kotlin.Fr019SharedEnum"]}, - {"generator": "routes", "port": "kotlin", "assertion": "depth", "gap": 5, "outside": ["com.metaobjects.generator.kotlin.Fr019SharedEnum"]}, { "generator": "entity", "port": "typescript", diff --git a/server/java/README.md b/server/java/README.md index a9967dd45..ed4a7e36d 100644 --- a/server/java/README.md +++ b/server/java/README.md @@ -12,7 +12,7 @@ Only the loader and registry, runtime metadata access and the drift gates are a guarantee. The generators are reference helpers: copy one into your build with `mvn metaobjects:eject` and own it ([Own your codegen → Java and Kotlin](../../docs/features/own-your-codegen.md#java-and-kotlin-mvn-metaobjectseject)). -- **Codegen** *(reference generators)* — Spring REST + DTO + repository-interface emit (you supply the impl) (`codegen-spring`) and a Kotlin emit pipeline on KotlinPoet (`codegen-kotlin`), both ejectable; PlantUML diagrams (`codegen-plantuml`), registered and not ejectable yet; and the deprecated Mustache template engine (`codegen-mustache`), which is not ejectable and will not be. Output is hand-edit-preserving via three-way merge. +- **Codegen** *(reference generators)* — Spring REST + DTO + repository-interface emit (you supply the impl) (`codegen-spring`) and a Kotlin emit pipeline on KotlinPoet (`codegen-kotlin`), both ejectable; PlantUML diagrams (`codegen-plantuml`), ejectable; and the deprecated Mustache template engine (`codegen-mustache`), which is not ejectable and will not be. Output is hand-edit-preserving via three-way merge. - **Runtime metadata** — OMDB persistence layer over modernized JDBC with Spring-`@Transactional` integration. FR-003 fully shipped: binding registry, typed jsonb codec, source/origin metamodel, atomic mapping cache + JDBC codec registry + `inTransaction` template (Plan 4). Schema migrations are owned by the TypeScript toolchain (`@metaobjectsdev/cli migrate`); the `metaobjects:migrate` Maven goal was removed. Per the schema-authority consolidation the dev/test runtime auto-create path and `MetaClassDBValidatorService` were also removed — OMDB is now pure data-access (CRUD/query/codec/transactions only). - **Drift detection** — Template-drift: `Renderer.verify` checks `{{...}}` references against the payload VO at build time. The live-DB-schema mode of `metaobjects:verify` was removed; the goal now covers codegen drift (`mode=codegen`, the default) and template/prompt drift (`mode=templates`). - **Prompt construction** — `metaobjects-render` (Mustache + payload-VO + verify), FR-006 `template.output` parser-on-receipt codegen, render output byte-identical with the other four ports against the shared render-conformance corpus. diff --git a/server/java/codegen-base/pom.xml b/server/java/codegen-base/pom.xml index 7d585377d..2e300e314 100644 --- a/server/java/codegen-base/pom.xml +++ b/server/java/codegen-base/pom.xml @@ -99,8 +99,9 @@ Copied at BUILD TIME from the src/main/java files the compiler just compiled, so the resource IS the source: one file, one owner, nothing to drift. - These are "entity" (JavaObjectCodeGenerator) and "extractor" - (ExtractorCodeGenerator), and the rest of their emit, which eject copies + These are "entity" (JavaObjectCodeGenerator), "extractor" + (ExtractorCodeGenerator), "html-docs", "ai-docs" and "json-schema", and the rest + of their emit, which eject copies with them into the same owned package (GeneratorInfo.ejectWith(), ADR-0034 Amendment 6): the generator bases and the writers that emit the class body for "entity", and the writer "extractor" reads its names from. "template" @@ -137,6 +138,29 @@ BaseObjectCodeWriter.java + + + src/main/java/com/metaobjects/generator/direct/metadata/html + + MetaDataHtmlDocumentationGenerator.java + MetaDataHtmlDocumentationWriter.java + + + + src/main/java/com/metaobjects/generator/direct/metadata/ai + + MetaDataAIDocumentationGenerator.java + MetaDataAIDocumentationWriter.java + + + + src/main/java/com/metaobjects/generator/direct/metadata/file/json + + MetaDataFileJsonSchemaGenerator.java + MetaDataFileSchemaWriter.java + +
      diff --git a/server/java/codegen-base/src/main/java/com/metaobjects/generator/CodeGenMetaDataProvider.java b/server/java/codegen-base/src/main/java/com/metaobjects/generator/CodeGenMetaDataProvider.java index 33677a0d0..c8dd412a4 100644 --- a/server/java/codegen-base/src/main/java/com/metaobjects/generator/CodeGenMetaDataProvider.java +++ b/server/java/codegen-base/src/main/java/com/metaobjects/generator/CodeGenMetaDataProvider.java @@ -1,16 +1,16 @@ package com.metaobjects.generator; -import com.metaobjects.generator.direct.metadata.file.json.MetaDataFileJsonSchemaGenerator; -import com.metaobjects.generator.direct.metadata.ai.MetaDataAIDocumentationGenerator; import com.metaobjects.registry.MetaDataRegistry; import com.metaobjects.registry.MetaDataTypeProvider; /** * Code Generation MetaData provider that registers type extensions for code generation. * - *

      This provider delegates to existing generator classes that contain the actual extension logic - * and constants. It supports JSON Schema generation and AI documentation generation by extending - * existing MetaData types with generation-specific attributes.

      + *

      This provider holds the extension logic and constants itself, not the generators it serves, + * so a generator copied out with {@code mvn metaobjects:eject} owns emit code only: the attributes + * a loaded model may carry are metamodel vocabulary and stay with the loader. It supports JSON + * Schema generation and AI documentation generation by extending existing MetaData types with + * generation-specific attributes.

      * *

      Generators Supported:

      *
        @@ -28,13 +28,104 @@ public class CodeGenMetaDataProvider implements MetaDataTypeProvider { @Override public void registerTypes(MetaDataRegistry registry) { - // Delegate to existing generator classes that contain the extension logic and constants - // Register JSON Schema generation type extensions - MetaDataFileJsonSchemaGenerator.registerJsonSchemaAttributes(registry); + registerJsonSchemaAttributes(registry); // Register AI Documentation generation type extensions - MetaDataAIDocumentationGenerator.registerAIDocAttributes(registry); + registerAIDocAttributes(registry); + } + + // JSON Schema generation attribute constants + public static final String JSON_SCHEMA_VERSION = "jsonSchemaVersion"; + public static final String JSON_SCHEMA_ID = "jsonSchemaId"; + public static final String JSON_TITLE = "jsonTitle"; + public static final String JSON_DESCRIPTION = "jsonDescription"; + public static final String JSON_FORMAT = "jsonFormat"; + public static final String JSON_PATTERN = "jsonPattern"; + public static final String JSON_ENUM = "jsonEnum"; + public static final String JSON_MINIMUM = "jsonMinimum"; + public static final String JSON_MAXIMUM = "jsonMaximum"; + + // AI Documentation generation attribute constants + public static final String AI_VERSION = "aiVersion"; + public static final String AI_DESCRIPTION = "aiDescription"; + public static final String AI_BUSINESS_RULE = "aiBusinessRule"; + public static final String AI_USAGE_CONTEXT = "aiUsageContext"; + public static final String AI_VALIDATION_RULES = "aiValidationRules"; + public static final String AI_EXAMPLES = "aiExamples"; + public static final String AI_CONSTRAINTS = "aiConstraints"; + public static final String AI_EXTENSION_GUIDANCE = "aiExtensionGuidance"; + public static final String AI_CROSS_LANGUAGE_INFO = "aiCrossLanguageInfo"; + + /** + * Registers JSON Schema generation attributes for use by the service provider pattern. + * Called by {@link #registerTypes} to extend existing MetaData types with JSON Schema-specific attributes. + */ + public static void registerJsonSchemaAttributes(MetaDataRegistry registry) { + // Object-level JSON Schema attributes + registry.findType("object", "base") + .optionalAttribute(JSON_SCHEMA_VERSION, "string") + .optionalAttribute(JSON_SCHEMA_ID, "string") + .optionalAttribute(JSON_TITLE, "string") + .optionalAttribute(JSON_DESCRIPTION, "string") + // Codegen object-level flags (template conditionals). Formerly registered on + // object.pojo; moved to object.base so the entity/value subtypes inherit them + // after the pojo/proxy/map subtype registrations were retired (ADR-0005). + .optionalAttribute("hasAuditing", "boolean") + .optionalAttribute("hasJpa", "boolean") + .optionalAttribute("hasValidation", "boolean"); + + // Field-level JSON Schema attributes + registry.findType("field", "base") + .optionalAttribute(JSON_TITLE, "string") + .optionalAttribute(JSON_DESCRIPTION, "string") + .optionalAttribute(JSON_FORMAT, "string") + .optionalAttribute(JSON_PATTERN, "string") + .optionalAttribute(JSON_ENUM, "string"); + + registry.findType("field", "string") + .optionalAttribute(JSON_PATTERN, "string") + .optionalAttribute(JSON_FORMAT, "string"); + + registry.findType("field", "int") + .optionalAttribute(JSON_MINIMUM, "int") + .optionalAttribute(JSON_MAXIMUM, "int"); + + registry.findType("field", "long") + .optionalAttribute(JSON_MINIMUM, "long") + .optionalAttribute(JSON_MAXIMUM, "long"); + } + + /** + * Registers AI Documentation generation attributes for use by the service provider pattern. + * Called by {@link #registerTypes} to extend existing MetaData types with AI documentation-specific attributes. + */ + public static void registerAIDocAttributes(MetaDataRegistry registry) { + // Object-level AI Documentation attributes + registry.findType("object", "base") + .optionalAttribute(AI_VERSION, "string") + .optionalAttribute(AI_DESCRIPTION, "string") + .optionalAttribute(AI_BUSINESS_RULE, "string") + .optionalAttribute(AI_USAGE_CONTEXT, "string") + .optionalAttribute(AI_EXTENSION_GUIDANCE, "string") + .optionalAttribute(AI_CROSS_LANGUAGE_INFO, "string"); + + // Field-level AI Documentation attributes + registry.findType("field", "base") + .optionalAttribute(AI_DESCRIPTION, "string") + .optionalAttribute(AI_BUSINESS_RULE, "string") + .optionalAttribute(AI_USAGE_CONTEXT, "string") + .optionalAttribute(AI_VALIDATION_RULES, "string") + .optionalAttribute(AI_EXAMPLES, "string") + .optionalAttribute(AI_CONSTRAINTS, "string"); + + registry.findType("field", "string") + .optionalAttribute(AI_EXAMPLES, "string") + .optionalAttribute(AI_VALIDATION_RULES, "string"); + + registry.findType("field", "int") + .optionalAttribute(AI_CONSTRAINTS, "string") + .optionalAttribute(AI_EXAMPLES, "string"); } @Override diff --git a/server/java/codegen-base/src/main/java/com/metaobjects/generator/DocsGenerator.java b/server/java/codegen-base/src/main/java/com/metaobjects/generator/DocsGenerator.java new file mode 100644 index 000000000..4d5ae091f --- /dev/null +++ b/server/java/codegen-base/src/main/java/com/metaobjects/generator/DocsGenerator.java @@ -0,0 +1,39 @@ +package com.metaobjects.generator; + +/** + * A generator that writes documentation, not code: the api reference {@code + * mvn metaobjects:docs} produces. + * + *

        {@code metaobjects:generate} writes code under {@code outputDir}, and its drift check, + * {@code metaobjects:verify}, regenerates code and diffs it against the committed tree. A docs + * generator does neither: its pages land under the docs output directory, relative to it, and + * are not code the build compiles. A generator that takes part in the docs run says so by + * implementing this interface. {@code generate} and {@code verify} skip it and say so, and + * {@code docs} runs it.

        + * + *

        One {@code } list serves all three goals. An owned {@code api-docs} copy is + * listed beside the code generators, and each goal runs the half that is its own. When the list + * holds a docs generator for a surface, {@code docs} runs it in place of the packaged one.

        + * + *

        The goal sets these args on the generator: {@link #ARG_PROJECT_NAME}, {@link + * #ARG_API_SUBDIR}, {@link #ARG_LAYOUT} and {@link #ARG_MODEL_BASE_URL}, plus {@code outputDir}, + * the docs output directory.

        + */ +public interface DocsGenerator extends Generator { + + /** The label the agent page carries in its header. */ + String ARG_PROJECT_NAME = "projectName"; + + /** Where under {@code outputDir} the api reference lands, e.g. {@code api/java}. */ + String ARG_API_SUBDIR = "apiSubDir"; + + /** The page layout: {@code flat} (one file per unit, the default) or {@code package}. */ + String ARG_LAYOUT = "layout"; + + /** When set, the base URL the model pages' back-links federate to. */ + String ARG_MODEL_BASE_URL = "modelBaseUrl"; + + /** The surface this generator documents: {@code java} or {@code kotlin}. {@code docs} hands + * the surface to the docs generator in the project's list that names it. */ + String surface(); +} diff --git a/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/metadata/ai/MetaDataAIDocumentationGenerator.java b/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/metadata/ai/MetaDataAIDocumentationGenerator.java index 32c94d019..3d99d3d76 100644 --- a/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/metadata/ai/MetaDataAIDocumentationGenerator.java +++ b/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/metadata/ai/MetaDataAIDocumentationGenerator.java @@ -1,5 +1,6 @@ package com.metaobjects.generator.direct.metadata.ai; +import com.metaobjects.generator.CodeGenMetaDataProvider; import com.metaobjects.generator.direct.metadata.json.SingleJsonDirectGeneratorBase; import com.metaobjects.generator.direct.metadata.json.JsonDirectWriter; import com.metaobjects.loader.MetaDataLoader; @@ -144,47 +145,42 @@ public String getConfigurationSummary() { /////////////////////////////////////////////////// // Service Provider Pattern Registration - - // AI Documentation generation attribute constants - public static final String AI_VERSION = "aiVersion"; - public static final String AI_DESCRIPTION = "aiDescription"; - public static final String AI_BUSINESS_RULE = "aiBusinessRule"; - public static final String AI_USAGE_CONTEXT = "aiUsageContext"; - public static final String AI_VALIDATION_RULES = "aiValidationRules"; - public static final String AI_EXAMPLES = "aiExamples"; - public static final String AI_CONSTRAINTS = "aiConstraints"; - public static final String AI_EXTENSION_GUIDANCE = "aiExtensionGuidance"; - public static final String AI_CROSS_LANGUAGE_INFO = "aiCrossLanguageInfo"; - - /** - * Registers AI Documentation generation attributes for use by the service provider pattern. - * Called by CodeGenMetaDataProvider to extend existing MetaData types with AI documentation-specific attributes. - */ + // + // The attributes and their registration live in CodeGenMetaDataProvider, which the loader + // consults, so a copy of this generator made by `mvn metaobjects:eject` owns emit code only. + // These names stay here for existing callers. + + /** @deprecated use {@link CodeGenMetaDataProvider#AI_VERSION} */ + @Deprecated + public static final String AI_VERSION = CodeGenMetaDataProvider.AI_VERSION; + /** @deprecated use {@link CodeGenMetaDataProvider#AI_DESCRIPTION} */ + @Deprecated + public static final String AI_DESCRIPTION = CodeGenMetaDataProvider.AI_DESCRIPTION; + /** @deprecated use {@link CodeGenMetaDataProvider#AI_BUSINESS_RULE} */ + @Deprecated + public static final String AI_BUSINESS_RULE = CodeGenMetaDataProvider.AI_BUSINESS_RULE; + /** @deprecated use {@link CodeGenMetaDataProvider#AI_USAGE_CONTEXT} */ + @Deprecated + public static final String AI_USAGE_CONTEXT = CodeGenMetaDataProvider.AI_USAGE_CONTEXT; + /** @deprecated use {@link CodeGenMetaDataProvider#AI_VALIDATION_RULES} */ + @Deprecated + public static final String AI_VALIDATION_RULES = CodeGenMetaDataProvider.AI_VALIDATION_RULES; + /** @deprecated use {@link CodeGenMetaDataProvider#AI_EXAMPLES} */ + @Deprecated + public static final String AI_EXAMPLES = CodeGenMetaDataProvider.AI_EXAMPLES; + /** @deprecated use {@link CodeGenMetaDataProvider#AI_CONSTRAINTS} */ + @Deprecated + public static final String AI_CONSTRAINTS = CodeGenMetaDataProvider.AI_CONSTRAINTS; + /** @deprecated use {@link CodeGenMetaDataProvider#AI_EXTENSION_GUIDANCE} */ + @Deprecated + public static final String AI_EXTENSION_GUIDANCE = CodeGenMetaDataProvider.AI_EXTENSION_GUIDANCE; + /** @deprecated use {@link CodeGenMetaDataProvider#AI_CROSS_LANGUAGE_INFO} */ + @Deprecated + public static final String AI_CROSS_LANGUAGE_INFO = CodeGenMetaDataProvider.AI_CROSS_LANGUAGE_INFO; + + /** @deprecated use {@link CodeGenMetaDataProvider#registerAIDocAttributes} */ + @Deprecated public static void registerAIDocAttributes(com.metaobjects.registry.MetaDataRegistry registry) { - // Object-level AI Documentation attributes - registry.findType("object", "base") - .optionalAttribute(AI_VERSION, "string") - .optionalAttribute(AI_DESCRIPTION, "string") - .optionalAttribute(AI_BUSINESS_RULE, "string") - .optionalAttribute(AI_USAGE_CONTEXT, "string") - .optionalAttribute(AI_EXTENSION_GUIDANCE, "string") - .optionalAttribute(AI_CROSS_LANGUAGE_INFO, "string"); - - // Field-level AI Documentation attributes - registry.findType("field", "base") - .optionalAttribute(AI_DESCRIPTION, "string") - .optionalAttribute(AI_BUSINESS_RULE, "string") - .optionalAttribute(AI_USAGE_CONTEXT, "string") - .optionalAttribute(AI_VALIDATION_RULES, "string") - .optionalAttribute(AI_EXAMPLES, "string") - .optionalAttribute(AI_CONSTRAINTS, "string"); - - registry.findType("field", "string") - .optionalAttribute(AI_EXAMPLES, "string") - .optionalAttribute(AI_VALIDATION_RULES, "string"); - - registry.findType("field", "int") - .optionalAttribute(AI_CONSTRAINTS, "string") - .optionalAttribute(AI_EXAMPLES, "string"); + CodeGenMetaDataProvider.registerAIDocAttributes(registry); } } \ No newline at end of file diff --git a/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/metadata/file/json/MetaDataFileJsonSchemaGenerator.java b/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/metadata/file/json/MetaDataFileJsonSchemaGenerator.java index 1a0707ca1..c2ed31ec3 100644 --- a/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/metadata/file/json/MetaDataFileJsonSchemaGenerator.java +++ b/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/metadata/file/json/MetaDataFileJsonSchemaGenerator.java @@ -1,5 +1,6 @@ package com.metaobjects.generator.direct.metadata.file.json; +import com.metaobjects.generator.CodeGenMetaDataProvider; import com.metaobjects.generator.GeneratorIOException; import com.metaobjects.generator.direct.metadata.json.SingleJsonDirectGeneratorBase; import com.metaobjects.generator.direct.metadata.json.JsonDirectWriter; @@ -78,54 +79,42 @@ public String toString() { /////////////////////////////////////////////////// // Service Provider Pattern Registration - - // JSON Schema generation attribute constants - public static final String JSON_SCHEMA_VERSION = "jsonSchemaVersion"; - public static final String JSON_SCHEMA_ID = "jsonSchemaId"; - public static final String JSON_TITLE = "jsonTitle"; - public static final String JSON_DESCRIPTION = "jsonDescription"; - public static final String JSON_FORMAT = "jsonFormat"; - public static final String JSON_PATTERN = "jsonPattern"; - public static final String JSON_ENUM = "jsonEnum"; - public static final String JSON_MINIMUM = "jsonMinimum"; - public static final String JSON_MAXIMUM = "jsonMaximum"; - - /** - * Registers JSON Schema generation attributes for use by the service provider pattern. - * Called by CodeGenMetaDataProvider to extend existing MetaData types with JSON Schema-specific attributes. - */ + // + // The attributes and their registration live in CodeGenMetaDataProvider, which the loader + // consults, so a copy of this generator made by `mvn metaobjects:eject` owns emit code only. + // These names stay here for existing callers. + + /** @deprecated use {@link CodeGenMetaDataProvider#JSON_SCHEMA_VERSION} */ + @Deprecated + public static final String JSON_SCHEMA_VERSION = CodeGenMetaDataProvider.JSON_SCHEMA_VERSION; + /** @deprecated use {@link CodeGenMetaDataProvider#JSON_SCHEMA_ID} */ + @Deprecated + public static final String JSON_SCHEMA_ID = CodeGenMetaDataProvider.JSON_SCHEMA_ID; + /** @deprecated use {@link CodeGenMetaDataProvider#JSON_TITLE} */ + @Deprecated + public static final String JSON_TITLE = CodeGenMetaDataProvider.JSON_TITLE; + /** @deprecated use {@link CodeGenMetaDataProvider#JSON_DESCRIPTION} */ + @Deprecated + public static final String JSON_DESCRIPTION = CodeGenMetaDataProvider.JSON_DESCRIPTION; + /** @deprecated use {@link CodeGenMetaDataProvider#JSON_FORMAT} */ + @Deprecated + public static final String JSON_FORMAT = CodeGenMetaDataProvider.JSON_FORMAT; + /** @deprecated use {@link CodeGenMetaDataProvider#JSON_PATTERN} */ + @Deprecated + public static final String JSON_PATTERN = CodeGenMetaDataProvider.JSON_PATTERN; + /** @deprecated use {@link CodeGenMetaDataProvider#JSON_ENUM} */ + @Deprecated + public static final String JSON_ENUM = CodeGenMetaDataProvider.JSON_ENUM; + /** @deprecated use {@link CodeGenMetaDataProvider#JSON_MINIMUM} */ + @Deprecated + public static final String JSON_MINIMUM = CodeGenMetaDataProvider.JSON_MINIMUM; + /** @deprecated use {@link CodeGenMetaDataProvider#JSON_MAXIMUM} */ + @Deprecated + public static final String JSON_MAXIMUM = CodeGenMetaDataProvider.JSON_MAXIMUM; + + /** @deprecated use {@link CodeGenMetaDataProvider#registerJsonSchemaAttributes} */ + @Deprecated public static void registerJsonSchemaAttributes(com.metaobjects.registry.MetaDataRegistry registry) { - // Object-level JSON Schema attributes - registry.findType("object", "base") - .optionalAttribute(JSON_SCHEMA_VERSION, "string") - .optionalAttribute(JSON_SCHEMA_ID, "string") - .optionalAttribute(JSON_TITLE, "string") - .optionalAttribute(JSON_DESCRIPTION, "string") - // Codegen object-level flags (template conditionals). Formerly registered on - // object.pojo; moved to object.base so the entity/value subtypes inherit them - // after the pojo/proxy/map subtype registrations were retired (ADR-0005). - .optionalAttribute("hasAuditing", "boolean") - .optionalAttribute("hasJpa", "boolean") - .optionalAttribute("hasValidation", "boolean"); - - // Field-level JSON Schema attributes - registry.findType("field", "base") - .optionalAttribute(JSON_TITLE, "string") - .optionalAttribute(JSON_DESCRIPTION, "string") - .optionalAttribute(JSON_FORMAT, "string") - .optionalAttribute(JSON_PATTERN, "string") - .optionalAttribute(JSON_ENUM, "string"); - - registry.findType("field", "string") - .optionalAttribute(JSON_PATTERN, "string") - .optionalAttribute(JSON_FORMAT, "string"); - - registry.findType("field", "int") - .optionalAttribute(JSON_MINIMUM, "int") - .optionalAttribute(JSON_MAXIMUM, "int"); - - registry.findType("field", "long") - .optionalAttribute(JSON_MINIMUM, "long") - .optionalAttribute(JSON_MAXIMUM, "long"); + CodeGenMetaDataProvider.registerJsonSchemaAttributes(registry); } } \ No newline at end of file diff --git a/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/metadata/html/HtmlDirectWriter.java b/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/metadata/html/HtmlDirectWriter.java new file mode 100644 index 000000000..0db4cc8a4 --- /dev/null +++ b/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/metadata/html/HtmlDirectWriter.java @@ -0,0 +1,21 @@ +package com.metaobjects.generator.direct.metadata.html; + +import com.metaobjects.generator.GeneratorIOException; +import com.metaobjects.generator.GeneratorIOWriter; +import com.metaobjects.loader.MetaDataLoader; + +/** + * Base class for HTML direct writers: what {@link SingleHtmlDirectGeneratorBase} drives. This is + * the HTML equivalent of {@code JsonDirectWriter}. The base names this type and not a writer + * that emits a particular page, so a generator copied out with {@code mvn metaobjects:eject} + * pairs with the copy of its own writer. + */ +public abstract class HtmlDirectWriter extends GeneratorIOWriter { + + public HtmlDirectWriter(MetaDataLoader loader) throws GeneratorIOException { + super(loader); + } + + /** Write the whole page to the stream the writer was given. */ + public abstract void writeHtml() throws GeneratorIOException; +} diff --git a/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/metadata/html/MetaDataHtmlDocumentationGenerator.java b/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/metadata/html/MetaDataHtmlDocumentationGenerator.java index 795850c3f..f04415096 100644 --- a/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/metadata/html/MetaDataHtmlDocumentationGenerator.java +++ b/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/metadata/html/MetaDataHtmlDocumentationGenerator.java @@ -1,5 +1,9 @@ package com.metaobjects.generator.direct.metadata.html; +// Eject note (ADR-0034 Amendment 8): `mvn metaobjects:eject` copies this file with its writer. The +// base class stays in com.metaobjects.generator.direct.metadata.html as engine, so it is imported by +// name — a bare same-package reference would not resolve from the copy. +import com.metaobjects.generator.direct.metadata.html.SingleHtmlDirectGeneratorBase; import com.metaobjects.generator.GeneratorIOException; import com.metaobjects.loader.MetaDataLoader; import org.slf4j.Logger; diff --git a/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/metadata/html/MetaDataHtmlDocumentationWriter.java b/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/metadata/html/MetaDataHtmlDocumentationWriter.java index 63a18877c..e8c4a8011 100644 --- a/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/metadata/html/MetaDataHtmlDocumentationWriter.java +++ b/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/metadata/html/MetaDataHtmlDocumentationWriter.java @@ -1,6 +1,7 @@ package com.metaobjects.generator.direct.metadata.html; -import com.metaobjects.generator.GeneratorIOWriter; +// Imported by name: this file is copied out by `mvn metaobjects:eject`, and the base stays in the package. +import com.metaobjects.generator.direct.metadata.html.HtmlDirectWriter; import com.metaobjects.generator.GeneratorIOException; import com.metaobjects.loader.MetaDataLoader; import com.metaobjects.registry.MetaDataRegistry; @@ -38,7 +39,7 @@ * - Plugin and extension development * - Cross-team collaboration and knowledge sharing */ -public class MetaDataHtmlDocumentationWriter extends GeneratorIOWriter { +public class MetaDataHtmlDocumentationWriter extends HtmlDirectWriter { private static final Logger log = LoggerFactory.getLogger(MetaDataHtmlDocumentationWriter.class); @@ -91,6 +92,7 @@ public MetaDataHtmlDocumentationWriter withExtensionGuide(boolean includeExtensi return this; } + @Override public void writeHtml() throws GeneratorIOException { try { log.info("Generating professional HTML documentation for {} types", diff --git a/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/metadata/html/SingleHtmlDirectGeneratorBase.java b/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/metadata/html/SingleHtmlDirectGeneratorBase.java index 6b74cbc81..304f34190 100644 --- a/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/metadata/html/SingleHtmlDirectGeneratorBase.java +++ b/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/metadata/html/SingleHtmlDirectGeneratorBase.java @@ -19,7 +19,7 @@ public void execute(MetaDataLoader loader) { File outf = null; OutputStream fos = null; - MetaDataHtmlDocumentationWriter writer = null; + HtmlDirectWriter writer = null; parseArgs(); @@ -54,9 +54,9 @@ public void execute(MetaDataLoader loader) { /////////////////////////////////////////////////////////////////////////////////////// // Implementation Methods - protected abstract MetaDataHtmlDocumentationWriter getWriter(MetaDataLoader loader, OutputStream os) throws GeneratorIOException; + protected abstract HtmlDirectWriter getWriter(MetaDataLoader loader, OutputStream os) throws GeneratorIOException; - protected void writeHtml(MetaDataHtmlDocumentationWriter writer) throws GeneratorIOException { + protected void writeHtml(HtmlDirectWriter writer) throws GeneratorIOException { log.info("Writing HTML file: " + writer.getFilename()); writer.writeHtml(); } 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..f4f93ab25 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 { @@ -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..1a2c5ed6b --- /dev/null +++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/apidocs/KotlinApiDocsGenerator.kt @@ -0,0 +1,61 @@ +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.DocsGenerator +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`. + * + * Run by `mvn metaobjects:docs`, not by `mvn metaobjects:generate`: it implements [DocsGenerator], + * which `generate` and `verify` skip. Its files land under `outputDir`, the docs directory, in + * [DocsGenerator.ARG_API_SUBDIR]. The project label, the layout and the model back-link base come + * from the [DocsGenerator] args the goal sets, and where a `@provided` enum is referenced from + * comes from the same enum args every Kotlin generator takes ([Fr019SharedEnum.ProvidedEnumConfig]). + */ +class KotlinApiDocsGenerator : FileEmittingGenerator(), DocsGenerator { + + override fun surface(): String = "kotlin" + + override fun generate(loader: MetaDataLoader): List { + val project = getArg(DocsGenerator.ARG_PROJECT_NAME, "project") + val subDir = getArg(DocsGenerator.ARG_API_SUBDIR, DEFAULT_API_SUBDIR) + val modelBaseUrl = if (hasArg(DocsGenerator.ARG_MODEL_BASE_URL)) getArg(DocsGenerator.ARG_MODEL_BASE_URL) else null + val layout = if (getArg(DocsGenerator.ARG_LAYOUT, "flat").equals("package", ignoreCase = true)) { + DocsPaths.Layout.PACKAGE + } else { + DocsPaths.Layout.FLAT + } + + 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 { + /** The api-surface subdirectory of the cross-port docs contract. */ + const val DEFAULT_API_SUBDIR: String = "api/kotlin" + } +} 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..71f005255 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.factory() !is com.metaobjects.generator.DocsGenerator } + /** 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..a9ae91535 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 @@ -235,13 +277,38 @@ public String toString() { private static final List ENTITY_EMIT = List.of( JavaCodeGenerator.class.getName(), BaseObjectCodeGenerator.class.getName(), BaseObjectCodeWriter.class.getName(), JavaCodeWriter.class.getName(), - PojoAwareCodeWriter.class.getName(), ValueObjectCodeWriter.class.getName()); + PojoAwareCodeWriter.class.getName(), ValueObjectCodeWriter.class.getName(), + ExtractorCodeGenerator.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 +338,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,10 +376,10 @@ 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)); @@ -343,12 +418,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 +438,34 @@ 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(), + "api-docs: `mvn metaobjects:docs` runs this generator, not `metaobjects:generate`, which " + + "skips it. List it in with {owned} and the " + + "docs goal runs your copy for the java surface in place of the packaged one.", + 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 +494,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..b330cc178 --- /dev/null +++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/apidocs/JavaApiDocsGenerator.java @@ -0,0 +1,78 @@ +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.DocsGenerator; +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}. + * + *

        Run by {@code mvn metaobjects:docs}, not by {@code mvn metaobjects:generate}: it implements + * {@link DocsGenerator}, which {@code generate} and {@code verify} skip. Its files land under + * {@code outputDir}, the docs directory, in {@link #ARG_API_SUBDIR}. The project label, the layout + * and the model back-link base come from the {@link DocsGenerator} args the goal sets.

        + * + *

        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 implements DocsGenerator { + + /** The api-surface subdirectory of the cross-port docs contract. */ + public static final String DEFAULT_API_SUBDIR = "api/java"; + + 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 + public String surface() { + return "java"; + } + + @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 = LAYOUT_PACKAGE.equalsIgnoreCase(getArg(ARG_LAYOUT, "flat")) + ? DocsPaths.Layout.PACKAGE : DocsPaths.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; + } + + /** 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/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..f2052cd27 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()); } 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..d2d063c25 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 @@ -7,6 +7,7 @@ import com.metaobjects.agentcontext.AgentContextScaffold; import com.metaobjects.generator.Generator; import com.metaobjects.generator.GeneratorBase; +import com.metaobjects.generator.DocsGenerator; import com.metaobjects.generator.ProjectClassLoaderAware; import com.metaobjects.generator.direct.MultiFileDirectGeneratorBase; import com.metaobjects.generator.spring.SpringRenderHelperGenerator; @@ -295,6 +296,24 @@ protected void warnIfAgentContextStale() { protected abstract void executeGenerators(MetaDataLoader loader, List generatorImpls); + /** + * Whether {@code g} names a {@link DocsGenerator}: a generator that writes documentation, + * which {@code metaobjects:docs} runs and {@code generate} and {@code verify} leave alone. + */ + protected boolean isDocsGenerator(GeneratorParam g, ClassLoader projectClassLoader) { + try { + return DocsGenerator.class.isAssignableFrom(projectClassLoader.loadClass(g.getClassname())); + } catch (ClassNotFoundException e) { + throw new MetaDataException( "Error running generator ["+g.getClassname()+"]: "+e, e ); + } + } + + /** Say that {@code generate} / {@code verify} is skipping a docs generator, and which goal runs it. */ + protected void logSkippedDocsGenerator(String goal, String classname) { + getLog().info(goal + " > Skipping docs generator " + classname + + ": it writes documentation, and `mvn metaobjects:docs` runs it."); + } + protected MetaDataLoader createLoader(ClassLoader projectClassLoader) { LoaderConfigurable configurable = null; 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..9c0c0345e 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,6 +1,8 @@ package com.metaobjects.mojo; +import com.metaobjects.generator.DocsGenerator; import com.metaobjects.generator.Generator; +import com.metaobjects.generator.GeneratorBase; import com.metaobjects.loader.MetaDataLoader; import org.apache.maven.plugin.MojoExecutionException; import org.apache.maven.plugin.MojoFailureException; @@ -9,12 +11,7 @@ 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.HashMap; import java.util.List; import java.util.Locale; import java.util.Map; @@ -26,8 +23,9 @@ *

        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. + * hook. This goal needs only the loaded loader. It reads the configured {@code } + * for one thing: a {@link DocsGenerator} it names for a surface, which it runs in place of the + * packaged one. Every other configured generator is left alone. * *

        The {@link #language} parameter selects the native SDK surface: *

          @@ -38,9 +36,12 @@ * 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 + *

          The Mojo is wiring only: each surface is a {@link DocsGenerator} ({@code api-docs}) that + * writes its pages under {@link #outputDirectory}. When the project's {@code } list + * holds a docs generator for a surface, the goal runs that one, so a project that ejected + * {@code api-docs} runs its own copy. 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}. */ @Mojo(name = "docs", @@ -100,119 +101,64 @@ protected void executeGenerators(MetaDataLoader loader, List generato + "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); + runSurface(lang, subDir, loader, generatorImpls); } } - // ----- 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)); + /** + * Run the docs generator for one surface: the one the project's {@code } list + * names for it, when it names one (an owned copy of {@code api-docs}), else the packaged one. + * The goal is wiring only: every page, name and path comes from the generator. + */ + private void runSurface(String lang, String subDir, MetaDataLoader loader, List generatorImpls) { + DocsGenerator docs = null; + Map args = null; + for (int i = 0; i < generatorImpls.size(); i++) { + if (generatorImpls.get(i) instanceof DocsGenerator + && lang.equals(((DocsGenerator) generatorImpls.get(i)).surface())) { + docs = (DocsGenerator) generatorImpls.get(i); + args = mergeAndOverwriteArgs(getGenerators().get(i)); + break; + } } - 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)); + boolean owned = docs != null; + if (!owned) { + docs = packaged(lang); + args = new HashMap<>(); + if (getGlobals() != null) args.putAll(getGlobals()); + docs.setFilters(List.of()); } - 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); + args.put(GeneratorBase.ARG_OUTPUTDIR, outputDirectory.getAbsolutePath()); + args.put(DocsGenerator.ARG_PROJECT_NAME, project != null ? project.getArtifactId() : "project"); + args.put(DocsGenerator.ARG_API_SUBDIR, subDir); + args.put(DocsGenerator.ARG_LAYOUT, "package".equalsIgnoreCase(layout) ? "package" : "flat"); + if (modelBaseUrl != null && !modelBaseUrl.isEmpty()) { + args.put(DocsGenerator.ARG_MODEL_BASE_URL, modelBaseUrl); } - getLog().info("metaobjects:docs — wrote " + emitted.size() + " api pages into " + apiRoot); + docs.setArgs(args); + docs.execute(loader); + getLog().info("metaobjects:docs — ran " + (owned ? "your" : "the packaged") + " " + lang + + " api-docs generator " + docs.getClass().getName() + " into " + + outputDirectory.toPath().resolve(subDir)); } - /** 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); + /** The packaged {@code api-docs} generator for a surface. */ + private static DocsGenerator packaged(String lang) { + switch (lang) { + case LANG_JAVA: + return new com.metaobjects.generator.apidocs.JavaApiDocsGenerator(); + case LANG_KOTLIN: + return new com.metaobjects.generator.kotlin.apidocs.KotlinApiDocsGenerator(); + default: + throw new IllegalStateException( + "metaobjects:docs — unknown language '" + lang + "' (supported: java, kotlin)"); } - emitted.put(path, content); } } 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..07874159d 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,6 +83,13 @@ 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; @@ -102,8 +112,11 @@ 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 @@ -141,31 +154,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 +273,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 +294,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 +470,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 +479,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..9b3ff64f1 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 @@ -205,6 +205,9 @@ public void execute() throws MojoExecutionException, MojoFailureException { if (e.port == EjectSupport.Port.JAVA) { support.addAll(EjectSupport.supportFor(rewritten)); rewritten = EjectSupport.rewriteSupportImports(rewritten, pkg); + if (!e.resources.isEmpty()) { + rewritten = EjectSupport.rewriteTemplatePrefix(rewritten, pkg); + } } String what = generator ? "\"" + e.stableName + "\"" @@ -216,6 +219,7 @@ public void execute() throws MojoExecutionException, MojoFailureException { } 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); @@ -234,6 +238,49 @@ 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 owned package's path. 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<>(); + Path dir = resourceRoot.resolve(EjectSupport.ownedTemplatePrefix(pkg)); + for (EjectSupport.Resolution r : resolved) { + for (String ref : r.entry.resources) { + if (!written.add(ref)) 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); + } + Path target = dir.resolve(ref + EjectSupport.TEMPLATE_EXTENSION); + 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 // ------------------------------------------------------------------------------------ diff --git a/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/MetaDataGeneratorMojo.java b/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/MetaDataGeneratorMojo.java index a2e75ae49..6d1f7cf2b 100644 --- a/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/MetaDataGeneratorMojo.java +++ b/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/MetaDataGeneratorMojo.java @@ -1,5 +1,6 @@ package com.metaobjects.mojo; +import com.metaobjects.generator.DocsGenerator; import com.metaobjects.generator.Generator; import com.metaobjects.generator.GeneratorBase; import com.metaobjects.generator.util.GeneratedFileWriter; @@ -43,6 +44,10 @@ protected void executeGenerators(MetaDataLoader loader, List generato // a green build. Opening the run here is what makes the check reach a real build. try (GeneratedFileWriter.Run run = GeneratedFileWriter.beginRun()) { for( Generator gen : generatorImpls ) { + if ( gen instanceof DocsGenerator ) { + logSkippedDocsGenerator("MetaData Mojo", gen.getClass().getName()); + continue; + } getLog().info("MetaData Mojo > Executing Generator: " + gen.getClass().getName() ); run.attributeTo( gen.getClass().getSimpleName() ); gen.execute( loader ); 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..e6b3172df 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 @@ -1,5 +1,6 @@ package com.metaobjects.mojo; +import com.metaobjects.generator.DocsGenerator; import com.metaobjects.generator.Generator; import com.metaobjects.generator.GeneratorBase; import com.metaobjects.generator.verify.TemplateVerify; @@ -335,6 +336,8 @@ private void verifyCodegen() throws MojoExecutionException, MojoFailureException if (getGenerators() != null) { for (GeneratorParam g : getGenerators()) { + // A docs generator writes pages, not code: nothing here to drift-check. + if (isDocsGenerator(g, projectClassLoader)) continue; Map merged = mergeAndOverwriteArgs(g); String realDir = merged.get(ARG_OUTPUT_DIR); if (realDir == null) { @@ -360,6 +363,10 @@ private void verifyCodegen() throws MojoExecutionException, MojoFailureException List generatorImpls = buildGenerators(projectClassLoader, overrides); try { for (Generator gen : generatorImpls) { + if (gen instanceof DocsGenerator) { + logSkippedDocsGenerator("MetaData Verify Mojo", gen.getClass().getName()); + continue; + } getLog().info("MetaData Verify Mojo > Regenerating (temp): " + gen.getClass().getName()); gen.execute(loader); } diff --git a/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/DocsGeneratorGoalsTest.java b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/DocsGeneratorGoalsTest.java new file mode 100644 index 000000000..573c446a3 --- /dev/null +++ b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/DocsGeneratorGoalsTest.java @@ -0,0 +1,128 @@ +package com.metaobjects.mojo; + +import com.metaobjects.generator.kotlin.KotlinEntityGenerator; +import com.metaobjects.mojo.test.OwnedDocsGenerator; +import org.apache.maven.plugin.testing.MojoRule; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; + +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; + +/** + * A {@link com.metaobjects.generator.DocsGenerator} writes documentation, not code: the docs goal + * runs the one the project lists for a surface in place of the packaged one, and the generate and + * verify goals leave it alone. + */ +public class DocsGeneratorGoalsTest { + + private static final String OWNED = OwnedDocsGenerator.class.getName(); + private static final String ENTITY = KotlinEntityGenerator.class.getName(); + + @Rule + public MojoRule rule = new MojoRule(); + + private Path work; + + @Before + public void scratch() throws Exception { + work = Files.createTempDirectory("docs-goals"); + } + + private void configure(AbstractMetaDataMojo mojo, GeneratorParam... generators) { + LoaderParam loader = LoaderParam.builder("docs-goals-test") + .withClassname("com.metaobjects.loader.MetaDataLoader") + .withSourceDir("./src/test/resources") + .withSource("mojo/verify-test-metadata.json") + .build(); + mojo.setLoader(loader); + mojo.setGenerators(new ArrayList<>(List.of(generators))); + mojo.setGlobals(Collections.emptyMap()); + } + + private GeneratorParam generator(String classname, Path outputDir) { + GeneratorParam.Builder b = GeneratorParam.builder(classname); + if (outputDir != null) b.withArg("outputDir", outputDir.toString()); + return b.build(); + } + + private static List files(Path root) throws Exception { + if (!Files.isDirectory(root)) return List.of(); + try (Stream s = Files.walk(root)) { + return s.filter(Files::isRegularFile).map(p -> root.relativize(p).toString()).sorted() + .collect(Collectors.toList()); + } + } + + @Test + public void theDocsGoalRunsTheOwnedGeneratorForItsSurfaceInPlaceOfThePackagedOne() throws Exception { + DocsMojo docs = new DocsMojo(); + configure(docs, generator(OWNED, null)); + Path docsDir = work.resolve("docs"); + rule.setVariableValueToObject(docs, "outputDirectory", docsDir.toFile()); + rule.setVariableValueToObject(docs, "language", "java"); + docs.execute(); + + // The owned generator's page, in the surface subdirectory the goal named, and none of the + // packaged reference's. + assertEquals(List.of("api/java/OWNED.md"), files(docsDir)); + assertEquals("owned docs for project\n", Files.readString(docsDir.resolve("api/java/OWNED.md"))); + } + + @Test + public void aSurfaceNoGeneratorOwnsStillGetsThePackagedReference() throws Exception { + DocsMojo docs = new DocsMojo(); + configure(docs, generator(OWNED, null)); + Path docsDir = work.resolve("docs"); + rule.setVariableValueToObject(docs, "outputDirectory", docsDir.toFile()); + rule.setVariableValueToObject(docs, "language", "java,kotlin"); + docs.execute(); + + List written = files(docsDir); + assertTrue(written.toString(), written.contains("api/java/OWNED.md")); + assertTrue(written.toString(), written.contains("api/kotlin/README.md")); + assertTrue(written.toString(), written.contains("api/kotlin/AGENT-API.md")); + assertFalse(written.toString(), written.contains("api/java/README.md")); + } + + @Test + public void theGenerateGoalSkipsADocsGeneratorAndRunsTheRest() throws Exception { + Path out = work.resolve("generated"); + MetaDataGeneratorMojo gen = new MetaDataGeneratorMojo(); + configure(gen, generator(ENTITY, out), generator(OWNED, out)); + gen.execute(); + + List written = files(out); + assertTrue("the code generator ran: " + written, written.stream().anyMatch(f -> f.endsWith(".kt"))); + assertFalse("the docs generator was skipped: " + written, written.stream().anyMatch(f -> f.endsWith("OWNED.md"))); + } + + @Test + public void theVerifyGoalSkipsADocsGeneratorEvenWithNoOutputDirOrCommittedPages() throws Exception { + Path committed = work.resolve("committed"); + MetaDataGeneratorMojo gen = new MetaDataGeneratorMojo(); + configure(gen, generator(ENTITY, committed)); + gen.execute(); + + // A docs generator with no outputDir would fail verify as unverifiable, and one with an + // outputDir would read as a page the repository lacks, if verify ran it. + MetaDataVerifyMojo verify = new MetaDataVerifyMojo(); + configure(verify, generator(ENTITY, committed), generator(OWNED, null)); + verify.execute(); + + MetaDataVerifyMojo withDir = new MetaDataVerifyMojo(); + configure(withDir, generator(ENTITY, committed), generator(OWNED, work.resolve("docs-not-committed"))); + withDir.execute(); + } +} diff --git a/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/EjectContractTest.java b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/EjectContractTest.java index 9d843c221..74e5e6414 100644 --- a/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/EjectContractTest.java +++ b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/EjectContractTest.java @@ -7,6 +7,8 @@ import com.metaobjects.generator.Generator; import com.metaobjects.generator.GeneratorRegistry; import com.metaobjects.generator.ProjectClassLoaderAware; +import com.metaobjects.generator.direct.DirectGeneratorBase; +import com.metaobjects.generator.direct.MultiFileDirectGeneratorBase; import com.metaobjects.loader.DirectorySource; import com.metaobjects.loader.LoaderOptions; import com.metaobjects.loader.MetaDataLoader; @@ -314,6 +316,9 @@ public void completenessTheShippedReferenceSourcesAreExactlyTheOnesTheRegistryNa if (e.resourcePath == null) continue; for (String classname : e.sources()) named.add(root + EjectSupport.simpleNameOf(classname) + ".java"); for (String runtime : e.runtime) named.add(GeneratorRegistry.EJECT_RUNTIME_RESOURCE_ROOT + runtime + ".java"); + for (String template : e.resources) { + named.add(GeneratorRegistry.EJECT_TEMPLATE_RESOURCE_ROOT + template + EjectSupport.TEMPLATE_EXTENSION); + } } for (String support : GeneratorRegistry.EJECT_SUPPORT) { named.add(GeneratorRegistry.EJECT_SUPPORT_RESOURCE_ROOT + support + ".java"); @@ -506,6 +511,11 @@ private Map run(Generator generator, Corpus corpus, MetaDataLoad args.put("testPackage", "com.example.requirements"); args.put("witnessClass", "com.example.Witnesses"); args.put("warnUncovered", "false"); + // The file the single-file generators write: the docs, diagram and schema generators. The + // per-object ones read this arg as the name of a file to merge into, so they must not get it. + if (generator instanceof DirectGeneratorBase && !(generator instanceof MultiFileDirectGeneratorBase)) { + args.put("outputFilename", "contract.out"); + } // The one difference an owned copy's output is allowed: it imports the helper runtime // the adopter owns. The packaged generator is pointed at the same package to compare. if (packaged) args.put("runtimePackage", OWNED_RUNTIME_PACKAGE); @@ -526,9 +536,13 @@ private Map run(Generator generator, Corpus corpus, MetaDataLoad public void depthAnUnchangedOwnedCopyEmitsWhatThePackagedGeneratorEmits() throws Exception { List silent = new ArrayList<>(); for (EjectSupport.Entry e : ejectable()) { - Path classes = compileAlone(e.stableName, ejectAlone(e.stableName)); + Path projectDir = ejectAlone(e.stableName); + Path classes = compileAlone(e.stableName, projectDir); int emitted = 0; - try (URLClassLoader cl = new URLClassLoader(new URL[]{classes.toUri().toURL()}, getClass().getClassLoader())) { + // The owned classes, and the resources eject wrote beside them: a generator that reads + // its page text from the classpath reads the owned copy, whose path no package shares. + URL[] ownedUrls = {classes.toUri().toURL(), projectDir.resolve("codegen/src/main/resources/").toUri().toURL()}; + try (URLClassLoader cl = new URLClassLoader(ownedUrls, getClass().getClassLoader())) { for (Corpus corpus : CORPORA) { String where = e.stableName + " over " + corpus.label; Generator packaged = (Generator) Class.forName(e.classname).getDeclaredConstructor().newInstance(); diff --git a/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/EjectRuntimeRoundTripTest.java b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/EjectRuntimeRoundTripTest.java index 0a5123ce6..b77e111c0 100644 --- a/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/EjectRuntimeRoundTripTest.java +++ b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/EjectRuntimeRoundTripTest.java @@ -140,6 +140,11 @@ public void eachJavaGeneratorsOutputImportsExactlyTheRuntimeItsEntryDeclares() t Generator gen = (Generator) Class.forName(e.classname).getDeclaredConstructor().newInstance(); Map a = args(out); a.put("runtimePackage", probe); + // A single-file generator (the docs, diagram and schema ones) is told its file's name; + // a per-object one given one would merge every object into it. + if (!(gen instanceof com.metaobjects.generator.direct.MultiFileDirectGeneratorBase)) { + a.put("outputFilename", e.stableName + ".out"); + } gen.setArgs(a).execute(loader); Set all = imports(out); diff --git a/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/EjectSupportTest.java b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/EjectSupportTest.java index 280dbc26a..ecc73216a 100644 --- a/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/EjectSupportTest.java +++ b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/EjectSupportTest.java @@ -55,16 +55,16 @@ public void entityAndExtractorAreEjectableOnBothPortsAndSoNeedAPort() { } @Test - public void onlyTheJavaExtractorCarriesAWiringNoteAndItNamesTheOwnedClass() { + public void onlyTheJavaExtractorAndApiDocsCarryAWiringNoteAndItNamesTheOwnedClass() { for (EjectSupport.Entry e : EjectSupport.allEntries()) { boolean javaExtractor = e.port == EjectSupport.Port.JAVA && e.stableName.equals("extractor"); - if (!javaExtractor) { + boolean javaApiDocs = e.port == EjectSupport.Port.JAVA && e.stableName.equals("api-docs"); + if (!javaExtractor && !javaApiDocs) { assertNull(e.port.id + " " + e.stableName, e.wiringNote("com.acme.codegen")); continue; } String note = e.wiringNote("com.acme.codegen"); - assertTrue(note, note.contains( - "com.acme.codegen.ExtractorCodeGenerator")); + assertTrue(note, note.contains("com.acme.codegen." + e.simpleName() + "")); assertFalse(note, note.contains("{owned}")); } } @@ -163,8 +163,8 @@ public void rewritePackageRejectsSourceWithNoPackageLine() { /** * ADR-0034 Amendment 6: an owned generator extends and calls the classes copied with it, * never the packaged ones. So an import of a copied class follows the copy, and an - * import of anything else (here the {@code extractor} generator, which is not part of - * {@code entity}'s copy, and a class whose name merely starts like a copied one) stays. + * import of anything else (here the file writer, which is engine, and a class whose name + * merely starts like a copied one) stays. */ @Test public void anImportOfAClassCopiedAlongFollowsTheCopyAndNoOtherImportMoves() { @@ -173,14 +173,14 @@ public void anImportOfAClassCopiedAlongFollowsTheCopyAndNoOtherImportMoves() { String src = "package com.metaobjects.generator.direct.object.javacode;\n\n" + "import com.metaobjects.generator.direct.object.BaseObjectCodeWriter;\n" + "import com.metaobjects.generator.direct.object.BaseObjectCodeWriterFactory;\n" - + "import com.metaobjects.generator.direct.object.javacode.ExtractorCodeGenerator;\n" + + "import com.metaobjects.generator.util.GeneratedFileWriter;\n" + "import com.metaobjects.generator.direct.GenerationContext;\n\n" + "public class Foo extends BaseObjectCodeWriter {}\n"; assertEquals("package com.metaobjects.generator.direct.object.javacode;\n\n" + "import com.acme.codegen.BaseObjectCodeWriter;\n" + "import com.metaobjects.generator.direct.object.BaseObjectCodeWriterFactory;\n" - + "import com.metaobjects.generator.direct.object.javacode.ExtractorCodeGenerator;\n" + + "import com.metaobjects.generator.util.GeneratedFileWriter;\n" + "import com.metaobjects.generator.direct.GenerationContext;\n\n" + "public class Foo extends BaseObjectCodeWriter {}\n", EjectSupport.rewriteCopiedImports(src, entity.sources(), "com.acme.codegen")); @@ -282,4 +282,66 @@ public void onlyTheJavaWebTierCarriesRuntime() { assertEquals(e.port.id + " " + e.stableName, expected, !e.runtime.isEmpty()); } } + + // ------------------------------------------------------------------------------------ + // Kotlin imports, template prefix, and the ownership comparison across copies + // ------------------------------------------------------------------------------------ + + @Test + public void aKotlinImportOfAClassCopiedAlongFollowsTheCopyWithoutGainingASemicolon() { + String src = "package com.metaobjects.generator.kotlin\n\n" + + "import com.metaobjects.generator.kotlin.KotlinEnumEmitter\n" + + "import com.metaobjects.generator.kotlin.KotlinEnumEmitterFactory\n" + + "import com.metaobjects.generator.kotlin.KotlinNaming\n\n" + + "class Foo\n"; + assertEquals("package com.metaobjects.generator.kotlin\n\n" + + "import com.acme.codegen.KotlinEnumEmitter\n" + + "import com.metaobjects.generator.kotlin.KotlinEnumEmitterFactory\n" + + "import com.metaobjects.generator.kotlin.KotlinNaming\n\n" + + "class Foo\n", + EjectSupport.rewriteCopiedImports(src, + List.of("com.metaobjects.generator.kotlin.KotlinEnumEmitter"), "com.acme.codegen")); + } + + @Test + public void everyKotlinEntryResolvesItsCopiedClassesToKotlinSources() { + EjectSupport.Entry entity = EjectSupport.resolve( + "entity", EjectSupport.ejectableByStableName(), EjectSupport.Port.KOTLIN, null).entry; + assertEquals(List.of("com.metaobjects.generator.kotlin.KotlinEntityGenerator", + "com.metaobjects.generator.kotlin.KotlinEnumEmitter"), entity.sources()); + assertTrue(entity.resourceOf("com.metaobjects.generator.kotlin.KotlinEnumEmitter"), + entity.resourceOf("com.metaobjects.generator.kotlin.KotlinEnumEmitter") + .endsWith("/kotlin/KotlinEnumEmitter.kt")); + } + + @Test + public void aCopiedTemplateProviderIsPointedAtTheOwnedPackagesTemplates() { + String src = "package com.metaobjects.generator.apidocs;\n\npublic final class P {\n" + + " private static final String BASE_PREFIX = \"templates/\";\n}\n"; + assertEquals("com/acme/codegen/templates/", EjectSupport.ownedTemplatePrefix("com.acme.codegen")); + String owned = EjectSupport.rewriteTemplatePrefix(src, "com.acme.codegen"); + assertTrue(owned, owned.contains("BASE_PREFIX = \"com/acme/codegen/templates/\";")); + // A source with no such declaration is returned as it came. + assertEquals("class X {}\n", EjectSupport.rewriteTemplatePrefix("class X {}\n", "com.acme.codegen")); + // The rewritten line is eject's own edit, so the copy still compares identical. + assertEquals(EjectSupport.Verdict.IDENTICAL, EjectSupport.compare( + EjectSupport.rewritePackage(owned, "com.acme.codegen"), src).verdict); + } + + @Test + public void aFileTwoGeneratorsCopyComparesIdenticalWhicheverOneWroteIt() { + // The extractor is copied with entity, which points its import of the entity's base at the + // owned copy, and on its own, which leaves that import on the engine. Either way it is the + // reference: the import is eject's own edit. + String ref = "package com.metaobjects.generator.direct.object.javacode;\n\n" + + "import com.metaobjects.generator.direct.object.BaseObjectCodeGenerator;\n" + + "import com.metaobjects.generator.util.GeneratorUtil;\n\npublic class E {}\n"; + String viaEntity = ref.replace("com.metaobjects.generator.direct.object.BaseObjectCodeGenerator", + "com.acme.codegen.BaseObjectCodeGenerator") + .replace("package com.metaobjects.generator.direct.object.javacode;", "package com.acme.codegen;"); + assertEquals(EjectSupport.Verdict.IDENTICAL, EjectSupport.compare(viaEntity, ref).verdict); + // An import of anything else is still compared. + assertEquals(EjectSupport.Verdict.DIFFERS, + EjectSupport.compare(viaEntity.replace("GeneratorUtil", "OtherUtil"), ref).verdict); + } } diff --git a/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/EntityExtractorEjectRoundTripTest.java b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/EntityExtractorEjectRoundTripTest.java index 69b2e0f53..ab5ced60a 100644 --- a/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/EntityExtractorEjectRoundTripTest.java +++ b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/EntityExtractorEjectRoundTripTest.java @@ -255,8 +255,9 @@ public void anEditToEachOwnedCopyShowsInItsOutput() throws Exception { Map entity = generate(owned(classes, OWNED_ENTITY), POJO); assertTrue(entity.get("com/metaobjects/generated/GeneratedObjectClassBindingProvider.java") .contains("GENERATED (owned entity copy)")); - // The owned entity still calls the PACKAGED extractor: it imports that class by name. - assertFalse(entity.get("fitness/ProgramExtractor.java").contains("owned extractor copy")); + // The owned entity writes its extractors through the owned extractor copy beside it, so an + // edit there reaches the files entity writes too, with no extractor generator in the run. + assertTrue(entity.get("fitness/ProgramExtractor.java").contains("owned extractor copy")); Map extractor = generate(owned(classes, OWNED_EXTRACTOR), Map.of()); assertTrue(extractor.get("fitness/ProgramExtractor.java").contains("GENERATED (owned extractor copy)")); @@ -373,15 +374,18 @@ public void theListingReportsBothAsEjectableAndMarksTheirOwnedCopies() throws Ex String edited = list(projectDir); assertTrue(row(edited, "extractor"), row(edited, "extractor").endsWith("[owned — DIFFERS: 1 behind, 1 of your own]")); - assertTrue(row(edited, "entity"), row(edited, "entity").endsWith("[owned — identical]")); + // entity copies the extractor too, so an edit to it shows on entity's row as well. + assertTrue(row(edited, "entity"), + row(edited, "entity").endsWith("[owned — DIFFERS: 1 behind, 1 of your own]")); // A writer is part of the generator it was copied with: an edit to one only entity - // has shows on entity's row, and leaves the extractor's count where it was. + // has shows on entity's row, on top of the extractor edit it already carries, and leaves + // the extractor's count where it was. edit(ownedDir.resolve("PojoAwareCodeWriter.java"), "println(true, \"super(mo);\");", "println(true, \"super(mo); // mine\");"); String writerEdited = list(projectDir); assertTrue(row(writerEdited, "entity"), - row(writerEdited, "entity").endsWith("[owned — DIFFERS: 1 behind, 1 of your own]")); + row(writerEdited, "entity").endsWith("[owned — DIFFERS: 2 behind, 2 of your own]")); assertTrue(row(writerEdited, "extractor"), row(writerEdited, "extractor").endsWith("[owned — DIFFERS: 1 behind, 1 of your own]")); } diff --git a/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/MetaDataEjectMojoTest.java b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/MetaDataEjectMojoTest.java index 79bd685ff..a6e1ca923 100644 --- a/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/MetaDataEjectMojoTest.java +++ b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/MetaDataEjectMojoTest.java @@ -330,4 +330,30 @@ public void namesIsRequiredWhenNotListing() { fail("expected MojoFailureException, got " + e); } } + + // ------------------------------------------------------------------------------------ + // api-docs: the templates its page text reads come with it + // ------------------------------------------------------------------------------------ + + @Test + public void ejectingTheJavaApiDocsWritesTheTemplatesAndPointsTheCopiedProviderAtThem() throws Exception { + mojo("api-docs", false, "java", null).execute(); + + Path src = codegenRoot().resolve("src/main/java/com/acme/codegen"); + for (String file : List.of("JavaApiDocsGenerator", "JavaApiModelBuilder", "JavaApiDocsRenderer", + "DocsPaths", "ApiUnit", "ClasspathTemplateProvider")) { + assertTrue(file, Files.isRegularFile(src.resolve(file + ".java"))); + } + // The page text is the adopter's, under the owned package's path so no jar can shadow it. + Path templates = codegenRoot().resolve("src/main/resources/com/acme/codegen/templates/api"); + for (String name : List.of("entity-api", "index", "agent-api")) { + assertTrue(name, Files.isRegularFile(templates.resolve(name + ".md.mustache"))); + } + String provider = Files.readString(src.resolve("ClasspathTemplateProvider.java"), StandardCharsets.UTF_8); + assertTrue(provider, provider.contains("BASE_PREFIX = \"com/acme/codegen/templates/\";")); + // The generator names the engine's applicability, never a generator class. + String generator = Files.readString(src.resolve("JavaApiDocsGenerator.java"), StandardCharsets.UTF_8); + assertFalse(generator, generator.contains("SpringDtoGenerator")); + assertTrue(generator, generator.contains("package com.acme.codegen;")); + } } diff --git a/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/test/OwnedDocsGenerator.java b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/test/OwnedDocsGenerator.java new file mode 100644 index 000000000..f13a9e241 --- /dev/null +++ b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/test/OwnedDocsGenerator.java @@ -0,0 +1,23 @@ +package com.metaobjects.mojo.test; + +import com.metaobjects.generator.DocsGenerator; +import com.metaobjects.generator.FileEmittingGenerator; +import com.metaobjects.loader.MetaDataLoader; + +import java.util.List; + +/** A docs generator of the adopter's own, for the goals that run or skip one. */ +public class OwnedDocsGenerator extends FileEmittingGenerator implements DocsGenerator { + + @Override + public String surface() { + return "java"; + } + + @Override + protected List generate(MetaDataLoader loader) { + String project = getArg(ARG_PROJECT_NAME, "none"); + String subDir = getArg(ARG_API_SUBDIR, "api/unset"); + return List.of(new EmittedFile(subDir + "/OWNED.md", "owned docs for " + project + "\n")); + } +} diff --git a/spec/decisions/ADR-0034-codegen-scaffold-and-own.md b/spec/decisions/ADR-0034-codegen-scaffold-and-own.md index d0f4f1008..f357b42b0 100644 --- a/spec/decisions/ADR-0034-codegen-scaffold-and-own.md +++ b/spec/decisions/ADR-0034-codegen-scaffold-and-own.md @@ -729,8 +729,8 @@ checked further by the guard. |---|---| | TypeScript | `requirement-walk.ts`, `relation-resolver.ts`, `render-context.ts`, `templates/find-inbound.ts`, `templates/find-templates.ts`, `render-engine/framework-provider.ts` | | C# | `ReportRows`, `PackageBindingResolver`, `GeneratorApplicability` | -| Java | `BaseObjectCodeGenerator`, `SpringM2mSupport`, `AutoSetSupport`, `TphPlan`, `FindInbound` | -| Kotlin | `ExposedApi`, `ExposedImports`, `KotlinGenUtil`, `KotlinM2mSupport` | +| Java | `BaseObjectCodeGenerator`, `SpringM2mSupport`, `AutoSetSupport`, `TphPlan`, `FindInbound`, `GeneratorApplicability`, `DocsGenerator`, `CodeGenMetaDataProvider`, the single-file generator bases and the JSON and HTML writer bases (`SingleFileDirectGeneratorBase`, `SingleJsonDirectGeneratorBase`, `SingleHtmlDirectGeneratorBase`, `JsonDirectWriter`, `HtmlDirectWriter`) | +| Kotlin | `ExposedApi`, `ExposedImports`, `KotlinGenUtil`, `KotlinM2mSupport`, `Fr019SharedEnum`, `DocsGenerator` | | Python | `value_objects`, `find_inbound`, `tph_plan`, `requirement_walk` | **Exemptions.** Each is an entry in the shared manifest with its reason. @@ -828,16 +828,16 @@ refusal is then `scripts/finish-release.mjs`, which withholds the tag. | 1 | Java `routes`, `dto` and `value-object` did not compile when ejected alone. | Closed before this amendment | | 2 | TypeScript `requirementsFile` and `agentDocsFile` were in no registry. | Registered here as `requirements` and `agent-docs`; reference templates pending | | 3 | TypeScript reference templates that call packaged composers. | Pending | -| 4 | Prompt-tier emitters that stay packaged in C#, Python, Java and Kotlin. | Closed in C# and Python. Pending in Java and Kotlin | -| 5 | Fragment emitters behind otherwise whole generators (shared enums, M:N navigation, doc comments, record builders). | Closed in C# and Python. Pending in Java and Kotlin | -| 6 | The docs command's emitter in C#, Python, Java and Kotlin is not a registered `api-docs`. | Closed in C# and Python. Pending in Java and Kotlin | -| 7 | Java generators outside the registry. | Registered here as `plantuml`, `html-docs`, `ai-docs`, `json-schema` and `mustache`; eject source pending for the first four | +| 4 | Prompt-tier emitters that stay packaged in C#, Python, Java and Kotlin. | Closed in C#, Python, Java and Kotlin | +| 5 | Fragment emitters behind otherwise whole generators (shared enums, M:N navigation, doc comments, record builders). | Closed in C#, Python, Java and Kotlin. Kotlin `Fr019SharedEnum` is engine, not handed over: it writes nothing, and the type mapper takes its `ProvidedEnumConfig` as an argument | +| 6 | The docs command's emitter in C#, Python, Java and Kotlin is not a registered `api-docs`. | Closed in C#, Python, Java and Kotlin | +| 7 | Java generators outside the registry. | Registered here as `plantuml`, `html-docs`, `ai-docs`, `json-schema` and `mustache`. The first four ship eject source, each with its writer; `mustache` is exempt | | 8 | The docs-site builders. | Exempt (ruling 3) | | 9 | Python generators that called another generator's packaged module. | Closed before this amendment | | 10 | Angular has no reference templates. | Outside the census until the package is published | | 11 | Tests that asserted a gap, and hand-kept lists. | Closed here | | 12 | Documents that claimed more than the code delivered. | Corrected here, below | -| 13 | An owned Java `entity` constructs the packaged `extractor` generator when no extractor is in the run. Found by the guard on its first run. | Pending. It closes with the Java work on gaps 4, 5 and 7, which owns the `entity` and `extractor` copies | +| 13 | An owned Java `entity` constructs the packaged `extractor` generator when no extractor is in the run. Found by the guard on its first run. | Closed. `entity` copies `ExtractorCodeGenerator` with it, so the owned `entity` constructs the owned one | **What the guard found on its first run**, beyond the gaps the audit listed: @@ -871,9 +871,15 @@ is. - **Kotlin depth does not run the eject goal.** The goal lives in `maven-plugin`, which depends on `codegen-kotlin`, and the Kotlin compile test has to live in `codegen-kotlin`. - The test reads the reference source the jar ships and rewrites its package line, which - is the one edit eject makes to a Kotlin copy. If eject starts doing more than that, this - test will not see it. + The test reads the reference sources the jar ships (the generator and the classes eject + copies with it), rewrites each package line and the imports the copies have of one + another, and compiles them together. Those are the two edits eject makes to a Kotlin copy. + If eject starts doing more than that, this test will not see it. +- **The Java docs generators' page templates are not in the reference scan.** `api-docs` + reads its page text from the classpath. Eject copies the templates into the adopter's + resources and points the copied provider at them, and the depth test puts those resources + on the owned classes' path so the copy reads them, but the scan sees classes, not text. A + template that called a packaged helper would not be seen. - **C# compiles the generator and the emitters eject hands over with it**, which is every file eject writes under `codegen/generators`. The helper-runtime files eject writes elsewhere are compiled by `HelperRuntimeTests`, against the web framework they need. From 4194f469e4a19b75bfb15aac0c5b298fe477d7e9 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Mon, 12 Oct 2026 01:36:07 -0400 Subject: [PATCH 08/17] fix(eject): keep the 1.0 GeneratorInfo constructor and the HTML writeHtml hook; verify the emitter recipe - Kotlin GeneratorInfo keeps its six-argument constructor for JVM callers (@JvmOverloads), with a Java caller test. - SingleHtmlDirectGeneratorBase still calls a subclass's writeHtml(MetaDataHtmlDocumentationWriter) through a deprecated bridge, with a test. - The recipe for owning the emitters of an older owned copy is run against copies made by the last released artifacts, in Java and in Kotlin, and the docs say what it took: the copied Kotlin emitters import one another through the scratch package. --- CHANGELOG.md | 7 ++- docs/features/own-your-codegen.md | 37 +++++++++--- .../html/SingleHtmlDirectGeneratorBase.java | 17 ++++++ .../html/SingleHtmlBaseHookCompatTest.java | 60 +++++++++++++++++++ .../generator/kotlin/GeneratorRegistry.kt | 2 +- .../KotlinGeneratorInfoJavaCallerTest.java | 25 ++++++++ 6 files changed, 137 insertions(+), 11 deletions(-) create mode 100644 server/java/codegen-base/src/test/java/com/metaobjects/generator/direct/metadata/html/SingleHtmlBaseHookCompatTest.java create mode 100644 server/java/maven-plugin/src/test/java/com/metaobjects/mojo/KotlinGeneratorInfoJavaCallerTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index dcade21e7..da4886fd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -161,9 +161,10 @@ generator catalog and the Atlas evaluation are 1.2 work. - **The Java `plantuml`, `html-docs`, `ai-docs` and `json-schema` generators ship eject source**, each with the writer that emits its file. `SingleHtmlDirectGeneratorBase` now drives the new `HtmlDirectWriter`, as the JSON base drives `JsonDirectWriter`, so a copied - HTML generator pairs with the copy of its writer. A subclass of the base that overrode - `getWriter` or `writeHtml` with `MetaDataHtmlDocumentationWriter` in the signature needs - `HtmlDirectWriter` there. The attributes the JSON Schema and AI documentation generators + HTML generator pairs with the copy of its writer. A subclass that overrode + `writeHtml(MetaDataHtmlDocumentationWriter)` keeps being called, through a deprecated bridge the + base still calls; one that returns the stock writer from `getWriter` still compiles. A subclass + compiled against 1.0.x needs a recompile, because the method's return type changed. The attributes the JSON Schema and AI documentation generators add to the metamodel moved to `CodeGenMetaDataProvider`, where the loader registers them, so a copy owns emit code only; the old static methods and constants on the two generators stay as deprecated forwarders. diff --git a/docs/features/own-your-codegen.md b/docs/features/own-your-codegen.md index bcafc2b4f..132ce0346 100644 --- a/docs/features/own-your-codegen.md +++ b/docs/features/own-your-codegen.md @@ -1029,17 +1029,40 @@ class outside the engine surface in `fixtures/generator-registry-conformance/reg and compares what the copy emits with what the packaged generator emits, byte for byte. **A copy you ejected before the emitters were handed over** keeps compiling and running, with -the packaged emitters. To own them, raise your `codegen/` module's dependency to 1.1.0 or -later and re-eject the generator into a scratch project, then copy the emitter files next to -your generator: +the packaged emitters. A copy made by 1.0.13 compiled against the 1.1.0 package unchanged, +for the Java generators ejected together (`dto`, `repository`, `value-object`) and for Kotlin +`entity` and `output-prompt`. A Java `routes`, `dto` or `value-object` ejected alone never +compiled; that is the 1.0.x defect, and re-ejecting it with `-Dforce=true` is the fix. + +To own the emitters, raise your `codegen/` module's dependency on the codegen artifact to +1.1.0 or later, eject the same names into a scratch project under a package of its own, copy +the emitter files next to your generators and point the generators at them. Kotlin, for +`entity` and `output-prompt`: + +```bash +# in a scratch project (any pom), with the 1.1.0 plugin +mvn metaobjects:eject -Dnames=entity,output-prompt -Dport=kotlin -Dpackage=com.acme.scratch +# in yours +d=codegen/src/main/kotlin/com/acme/codegen +cp scratch/codegen/src/main/kotlin/com/acme/scratch/{KotlinEnumEmitter,KotlinOutputFormatSpecEmitter,KotlinExtractSchemaEmitter}.kt $d/ +sed -i 's/com\.acme\.scratch/com.acme.codegen/g' $d/{KotlinEnumEmitter,KotlinOutputFormatSpecEmitter,KotlinExtractSchemaEmitter}.kt +sed -i -E 's/^import com\.metaobjects\.generator\.kotlin\.(KotlinEnumEmitter|KotlinOutputFormatSpecEmitter|KotlinExtractSchemaEmitter)$/import com.acme.codegen.\1/' $d/KotlinEntityGenerator.kt $d/KotlinOutputPromptGenerator.kt +``` + +Java, for `dto` and `value-object` (Java imports end with `;`, and the copied files need only +their package line changed): ```bash -mvn metaobjects:eject -Dnames=dto,value-object -Dport=java -Dpackage=com.acme.scratch # in a scratch project -cp scratch/codegen/src/main/java/com/acme/scratch/{Fr019SharedEnum,SpringRecordBuilder}.java codegen/src/main/java/com/acme/codegen/ +mvn metaobjects:eject -Dnames=dto,value-object -Dport=java -Dpackage=com.acme.scratch # in the scratch project +d=codegen/src/main/java/com/acme/codegen +cp scratch/codegen/src/main/java/com/acme/scratch/{Fr019SharedEnum,SpringRecordBuilder}.java $d/ +sed -i 's/com\.acme\.scratch/com.acme.codegen/g' $d/{Fr019SharedEnum,SpringRecordBuilder}.java +sed -i -E 's/^import com\.metaobjects\.generator\.spring\.(Fr019SharedEnum|SpringRecordBuilder);/import com.acme.codegen.\1;/' $d/SpringDtoGenerator.java $d/SpringValueObjectGenerator.java ``` -Change the `package` line of each file you copy, and point your generator's imports of them at -your package. `mvn metaobjects:eject -Dlist` then marks the copy `identical` or `DIFFERS`. +Both were run against copies made by 1.0.13 and built against the 1.1.0 artifacts, and the owned module +compiled. `mvn metaobjects:eject -Dlist` keeps reporting such a copy as `DIFFERS`, because a copy +that old differs from the 1.1.0 reference in other lines too, not only in the emitters. ### `api-docs`: the api reference as an owned generator diff --git a/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/metadata/html/SingleHtmlDirectGeneratorBase.java b/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/metadata/html/SingleHtmlDirectGeneratorBase.java index 304f34190..e38dbcaaf 100644 --- a/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/metadata/html/SingleHtmlDirectGeneratorBase.java +++ b/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/metadata/html/SingleHtmlDirectGeneratorBase.java @@ -57,6 +57,23 @@ public void execute(MetaDataLoader loader) { protected abstract HtmlDirectWriter getWriter(MetaDataLoader loader, OutputStream os) throws GeneratorIOException; protected void writeHtml(HtmlDirectWriter writer) throws GeneratorIOException { + // A subclass written against the stock writer overrides the method below; keep calling it. + if (writer instanceof MetaDataHtmlDocumentationWriter) { + writeHtml((MetaDataHtmlDocumentationWriter) writer); + return; + } + log.info("Writing HTML file: " + writer.getFilename()); + writer.writeHtml(); + } + + /** + * The hook as it was before the base drove {@link HtmlDirectWriter}: a subclass that overrides + * this keeps being called whenever its {@link #getWriter} returns the stock writer. + * + * @deprecated override {@link #writeHtml(HtmlDirectWriter)} + */ + @Deprecated + protected void writeHtml(MetaDataHtmlDocumentationWriter writer) throws GeneratorIOException { log.info("Writing HTML file: " + writer.getFilename()); writer.writeHtml(); } diff --git a/server/java/codegen-base/src/test/java/com/metaobjects/generator/direct/metadata/html/SingleHtmlBaseHookCompatTest.java b/server/java/codegen-base/src/test/java/com/metaobjects/generator/direct/metadata/html/SingleHtmlBaseHookCompatTest.java new file mode 100644 index 000000000..774ac3500 --- /dev/null +++ b/server/java/codegen-base/src/test/java/com/metaobjects/generator/direct/metadata/html/SingleHtmlBaseHookCompatTest.java @@ -0,0 +1,60 @@ +package com.metaobjects.generator.direct.metadata.html; + +import com.metaobjects.generator.GeneratorIOException; +import com.metaobjects.generator.GeneratorTestBase; +import com.metaobjects.loader.MetaDataLoader; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.OutputStream; +import java.util.HashMap; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +/** + * A subclass written before the base drove {@link HtmlDirectWriter} overrode + * {@code writeHtml(MetaDataHtmlDocumentationWriter)}. It has to keep being called. + */ +public class SingleHtmlBaseHookCompatTest extends GeneratorTestBase { + + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + + /** The shape of a pre-1.1 subclass: stock writer in both signatures. */ + @SuppressWarnings("deprecation") + static final class OldStyleGenerator extends SingleHtmlDirectGeneratorBase { + int hookCalls; + + @Override + protected MetaDataHtmlDocumentationWriter getWriter(MetaDataLoader loader, OutputStream os) + throws GeneratorIOException { + return new MetaDataHtmlDocumentationWriter(loader, os); + } + + @Override + protected void writeHtml(MetaDataHtmlDocumentationWriter writer) throws GeneratorIOException { + hookCalls++; + writer.writeHtml(); + } + } + + @Test + public void anOverrideOfTheOldHookIsStillCalled() throws Exception { + OldStyleGenerator generator = new OldStyleGenerator(); + Map 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-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 f4f93ab25..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 @@ -77,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`. */ diff --git a/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/KotlinGeneratorInfoJavaCallerTest.java b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/KotlinGeneratorInfoJavaCallerTest.java new file mode 100644 index 000000000..9026dfa9e --- /dev/null +++ b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/KotlinGeneratorInfoJavaCallerTest.java @@ -0,0 +1,25 @@ +package com.metaobjects.mojo; + +import com.metaobjects.generator.kotlin.GeneratorInfo; +import com.metaobjects.generator.kotlin.GeneratorLayer; +import com.metaobjects.generator.kotlin.GeneratorTier; +import com.metaobjects.generator.kotlin.KotlinNamesGenerator; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +/** A Java caller of the six-argument {@code GeneratorInfo} constructor, as 1.0 shipped it, still links. */ +public class KotlinGeneratorInfoJavaCallerTest { + + @Test + public void theSixArgumentConstructorStillExistsAndLeavesNothingToCopyWithIt() { + GeneratorInfo info = new GeneratorInfo("names", "d", GeneratorTier.NATIVE, GeneratorLayer.MODEL, + KotlinNamesGenerator::new, "META-INF/metaobjects/reference/kotlin/KotlinNamesGenerator.kt"); + assertEquals("names", info.getName()); + assertEquals(java.util.List.of(), info.getEjectWith()); + // And the five-argument form it was a default-argument overload of. + assertNull(new GeneratorInfo("names", "d", GeneratorTier.NATIVE, GeneratorLayer.MODEL, + KotlinNamesGenerator::new).getEjectResourcePath()); + } +} From 89dcc40f86934e2087fe0ffe281d09a2f0fab74b Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Mon, 12 Oct 2026 04:34:04 -0400 Subject: [PATCH 09/17] no-mistakes(review): fix(eject): per-port api-docs subpackage, shared docs wiring note --- CHANGELOG.md | 14 +- docs/features/own-your-codegen.md | 21 ++- docs/ports/java.md | 2 +- .../javacode/JavaObjectCodeGenerator.java | 8 +- .../javacode/README-flavored-objects.md | 13 +- .../EjectedBaseGeneratorsCompileTest.java | 3 +- .../generator/GeneratorRegistry.java | 5 +- server/java/maven-plugin/pom.xml | 9 + .../com/metaobjects/mojo/EjectSupport.java | 50 ++++- .../metaobjects/mojo/MetaDataEjectMojo.java | 26 +-- .../mojo/ApiDocsEjectBothPortsTest.java | 173 ++++++++++++++++++ .../metaobjects/mojo/EjectContractTest.java | 2 +- .../metaobjects/mojo/EjectSupportTest.java | 29 ++- .../mojo/MetaDataEjectMojoTest.java | 13 +- 14 files changed, 315 insertions(+), 53 deletions(-) create mode 100644 server/java/maven-plugin/src/test/java/com/metaobjects/mojo/ApiDocsEjectBothPortsTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index da4886fd9..9239279d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -152,11 +152,15 @@ generator catalog and the Atlas evaluation are 1.2 work. through a new `DocsGenerator` marker (`metaobjects-codegen-base`). `metaobjects:generate` and `metaobjects:verify` skip a `DocsGenerator` and say so in the build log; `metaobjects:docs` runs the one your `` list names for a surface, else the packaged one. Eject - copies the generator, the page model, the builder, the renderer and the path math, and for - Java the page templates too, into `codegen/src/main/resources//templates/`, - with the copied template provider pointed there. What the Java reference documents comes - from `GeneratorApplicability.packaged()`, so it describes the packaged generators even in a - project that owns a copy of one. Page output and the goal's parameters + copies the generator, the page model, the builder, the renderer and the path math into a + subpackage per port, `.apidocs.java` or `.apidocs.kotlin`: the two ports + name those classes alike, and one `codegen/` module can own both. Eject prints the class + name to list. For Java the page templates come too, into + `codegen/src/main/resources//apidocs/java/templates/`, with the copied + template provider pointed there. What the Java reference documents is still what the + packaged generators emit for (`GeneratorApplicability.packaged()`): an owned `routes` whose + `appliesTo` you changed is documented as the packaged one behaves, unless you change the + line of your `JavaApiDocsGenerator` copy that builds it. Page output and the goal's parameters (`language`, `apiSubDir`, `layout`, `modelBaseUrl`, `outputDirectory`) are unchanged. - **The Java `plantuml`, `html-docs`, `ai-docs` and `json-schema` generators ship eject source**, each with the writer that emits its file. `SingleHtmlDirectGeneratorBase` now diff --git a/docs/features/own-your-codegen.md b/docs/features/own-your-codegen.md index 132ce0346..a1929c0e0 100644 --- a/docs/features/own-your-codegen.md +++ b/docs/features/own-your-codegen.md @@ -894,7 +894,9 @@ The JVM plugin loads generator classes from your project's classpath, but only f classes compiled **before** `generate-sources` runs, and the plugin's own copy of a class always wins over one with the same name. So eject puts each copy into a separate `codegen/` Maven module, under your own package (`-Dpackage`, default -`.codegen`). Every copy compiles on its own, and none calls another generator. The +`.codegen`). The `api-docs` copies alone go one level below it, in a subpackage per +port ([below](#api-docs-the-api-reference-as-an-owned-generator)). Every copy compiles on its +own, and none calls another generator. The package declaration is the only line it changes, except for a generator that is copied with other classes (the emitters it writes through, below, and Java `entity` and `extractor`) and for the support imports and the template prefix described below: there the imports those @@ -1071,12 +1073,23 @@ that old differs from the 1.1.0 reference in other lines too, not only in the em model (`ApiSymbol`, `ApiUnit`, `FieldShape`, `ApiSymbolKind`, `JavaApiModel`, `UnitExample`), the builder that decides what to document (`JavaApiModelBuilder`, `JavaFieldShapes`), the renderer (`JavaApiDocsRenderer`), the path math (`DocsPaths`) and the template provider, and writes the -page templates into `codegen/src/main/resources//templates/api/`. Eject -points the copied provider at that directory, so the page text is the one you own and no other -jar on the classpath can shadow it. `-Dport=kotlin` copies `KotlinApiDocsGenerator`, +page templates into `codegen/src/main/resources//apidocs/java/templates/api/`. +Eject points the copied provider at that directory, so the page text is the one you own and no +other jar on the classpath can shadow it. `-Dport=kotlin` copies `KotlinApiDocsGenerator`, `KotlinApiModel`, `KotlinApiModelBuilder`, `KotlinApiDocsRenderer` and `DocsPaths`; the Kotlin renderer writes its pages in code, so it has no templates. +**The copies take a subpackage per port.** The Java copies are written in +`.apidocs.java` and the Kotlin ones in `.apidocs.kotlin`, where `` is +`-Dpackage` (default `.codegen`). The two ports name their page model and path classes +alike (`ApiUnit`, `ApiSymbol`, `FieldShape`, `ApiSymbolKind`, `DocsPaths`), so a project that +documents both surfaces can own both generators in one `codegen/` module without declaring a +class twice. Eject prints each copy's class name, for example +`com.acme.codegen.apidocs.java.JavaApiDocsGenerator`. No other generator's package changes. Eject +never edits an existing pom, so the `codegen/pom.xml` the first eject wrote builds that port +only: when you eject the other port into the same module, add its `metaobjects-codegen-*` +dependency and its compiler to that pom yourself. + The generator is a `DocsGenerator`. List it in `` with `` set to your copy, and `metaobjects:docs` runs it for its surface in place of the packaged one. `metaobjects:generate` and `metaobjects:verify` skip it, because it writes pages under the docs directory and not code diff --git a/docs/ports/java.md b/docs/ports/java.md index f5ca073f1..7a9907be2 100644 --- a/docs/ports/java.md +++ b/docs/ports/java.md @@ -588,7 +588,7 @@ copy imports them. The model and the other ports' spelling are in **Own a generator.** `mvn metaobjects:eject -Dnames=` copies a reference generator into a `codegen/` Maven module under your own package, and prints the module, dependency and `` to wire. See [Own your codegen → Java and Kotlin](../features/own-your-codegen.md#java-and-kotlin-mvn-metaobjectseject). Every generator in the table above can be ejected except `template`. -**`entity` and `extractor` eject from `metaobjects-codegen-base`, each with the rest of its emit.** `mvn metaobjects:eject -Dnames=entity,extractor -Dport=java` (pass `-Dport`: both names are ejectable on both JVM ports). `entity` copies seven files into one package: `JavaObjectCodeGenerator`, the bases it extends (`JavaCodeGenerator`, `BaseObjectCodeGenerator`) and the four writers that emit each class body (`BaseObjectCodeWriter`, `JavaCodeWriter`, `PojoAwareCodeWriter`, `ValueObjectCodeWriter`). The copies use one another, never the packaged classes, so you change a field, a constructor or an accessor by editing the writer. `extractor` copies `ExtractorCodeGenerator` with `JavaCodeWriter` and `BaseObjectCodeWriter`, where it reads class names from; in the same package as `entity` the two share that one copy. The file-by-file table is in [Own your codegen → Java `entity` and `extractor`](../features/own-your-codegen.md#java-entity-and-extractor). +**`entity` and `extractor` eject from `metaobjects-codegen-base`, each with the rest of its emit.** `mvn metaobjects:eject -Dnames=entity,extractor -Dport=java` (pass `-Dport`: both names are ejectable on both JVM ports). `entity` copies eight files into one package: `JavaObjectCodeGenerator`, the bases it extends (`JavaCodeGenerator`, `BaseObjectCodeGenerator`), the four writers that emit each class body (`BaseObjectCodeWriter`, `JavaCodeWriter`, `PojoAwareCodeWriter`, `ValueObjectCodeWriter`) and `ExtractorCodeGenerator`, which writes the `Extractor` classes. The copies use one another, never the packaged classes, so you change a field, a constructor or an accessor by editing the writer. `extractor` copies `ExtractorCodeGenerator` with `JavaCodeWriter` and `BaseObjectCodeWriter`, where it reads class names from; in the same package as `entity` the two share that one copy. The file-by-file table is in [Own your codegen → Java `entity` and `extractor`](../features/own-your-codegen.md#java-entity-and-extractor). **Class headers changed once, and `mvn metaobjects:verify` no longer depends on the clock.** The header comment of every class `entity` writes used to carry an `ObjectCodeWriter:` line (the writer's class name) and a `Generated On:` line (the wall-clock second of the run). Both are gone, so the first regeneration after upgrading changes every generated class file in its header comment and nowhere else. Because of the timestamp, `mvn metaobjects:verify` reported `[content-differs]` for every one of those files unless it ran in the same second as the generate it was checking. Output now holds nothing that varies between runs: regenerating an unchanged model writes the same bytes, and verify passes whenever it runs. diff --git a/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/object/javacode/JavaObjectCodeGenerator.java b/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/object/javacode/JavaObjectCodeGenerator.java index 8dc07b625..7b8520b33 100644 --- a/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/object/javacode/JavaObjectCodeGenerator.java +++ b/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/object/javacode/JavaObjectCodeGenerator.java @@ -1,10 +1,10 @@ package com.metaobjects.generator.direct.object.javacode; // Eject note (ADR-0034 Amendment 6): `mvn metaobjects:eject -Dnames=entity` copies this file -// together with JavaCodeGenerator, BaseObjectCodeGenerator and the four writers, into one -// package. The classes named bare below are those copies. ExtractorCodeGenerator is the -// `extractor` generator and is NOT part of this copy, so it is imported by name: that is -// what keeps the ejected copy compiling, and calling the packaged one. +// together with JavaCodeGenerator, BaseObjectCodeGenerator, the four writers and +// ExtractorCodeGenerator, into one package. The classes named bare below are those copies. +// ExtractorCodeGenerator is the `extractor` generator too, and eject points the import below +// at its copy, so an owned `entity` writes its extractors through owned code. import com.metaobjects.generator.direct.object.javacode.ExtractorCodeGenerator; import com.metaobjects.generator.EmitsObjectExtractors; diff --git a/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/object/javacode/README-flavored-objects.md b/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/object/javacode/README-flavored-objects.md index 6124c8564..578839aa0 100644 --- a/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/object/javacode/README-flavored-objects.md +++ b/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/object/javacode/README-flavored-objects.md @@ -65,7 +65,7 @@ This module's pom ships the sources under `META-INF/metaobjects/reference/java/` | Generator | Copied with it, into the same owned package | |---|---| -| `entity` | `JavaCodeGenerator`, `BaseObjectCodeGenerator`, `BaseObjectCodeWriter`, `JavaCodeWriter`, `PojoAwareCodeWriter`, `ValueObjectCodeWriter` | +| `entity` | `JavaCodeGenerator`, `BaseObjectCodeGenerator`, `BaseObjectCodeWriter`, `JavaCodeWriter`, `PojoAwareCodeWriter`, `ValueObjectCodeWriter`, `ExtractorCodeGenerator` | | `extractor` | `JavaCodeWriter`, `BaseObjectCodeWriter` | Eject rewrites each file's package line and, where one copied class imports another, that @@ -74,14 +74,15 @@ class body by editing a writer, not by subclassing one. What a copy may still re this module, and so what has to keep working for it: - `MultiFileDirectGeneratorBase` and `FileDirectWriter`, which the copies extend; -- `GenerationContext`, `GeneratorUtil`, `JavaIdentifiers` and `GeneratedFileWriter`; -- `ExtractorCodeGenerator.emit(...)`, which an owned `entity` calls until an `extractor` - generator is wired beside it. +- `GenerationContext`, `GeneratorUtil`, `JavaIdentifiers` and `GeneratedFileWriter`. + +An owned `entity` writes its extractors through its own copy of `ExtractorCodeGenerator`, +until an `extractor` generator is wired beside it. Two rules keep a copy set ejectable, and `EjectedBaseGeneratorsCompileTest` fails when either is broken. A class in a copy set may name another class of this package by its bare -name only if that class is in the same set; anything else (the owned `entity` naming -`ExtractorCodeGenerator`) is imported explicitly. And a copied class calls nothing that is +name only if that class is in the same set; anything else (the owned `extractor` naming +`BaseObjectCodeGenerator`) is imported explicitly. And a copied class calls nothing that is not `public` on a class left behind. A new class the writers come to depend on is added to the set in three places: this module's pom, `GeneratorRegistry`, and that test. diff --git a/server/java/codegen-base/src/test/java/com/metaobjects/generator/direct/object/EjectedBaseGeneratorsCompileTest.java b/server/java/codegen-base/src/test/java/com/metaobjects/generator/direct/object/EjectedBaseGeneratorsCompileTest.java index cce3db6f2..3fe0f4d5c 100644 --- a/server/java/codegen-base/src/test/java/com/metaobjects/generator/direct/object/EjectedBaseGeneratorsCompileTest.java +++ b/server/java/codegen-base/src/test/java/com/metaobjects/generator/direct/object/EjectedBaseGeneratorsCompileTest.java @@ -45,7 +45,8 @@ public class EjectedBaseGeneratorsCompileTest { "BaseObjectCodeWriter", "javacode.JavaCodeWriter", "javacode.PojoAwareCodeWriter", - "javacode.ValueObjectCodeWriter"); + "javacode.ValueObjectCodeWriter", + "javacode.ExtractorCodeGenerator"); /** What eject copies for {@code extractor}. */ private static final List EXTRACTOR = List.of( 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 a9ae91535..2c50b7b0c 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 @@ -443,10 +443,7 @@ private static Map buildRegistry() { // 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(), - "api-docs: `mvn metaobjects:docs` runs this generator, not `metaobjects:generate`, which " - + "skips it. List it in with {owned} and the " - + "docs goal runs your copy for the java surface in place of the packaged one.", + 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, diff --git a/server/java/maven-plugin/pom.xml b/server/java/maven-plugin/pom.xml index dfe7ad280..f48583ebb 100644 --- a/server/java/maven-plugin/pom.xml +++ b/server/java/maven-plugin/pom.xml @@ -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/EjectSupport.java b/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/EjectSupport.java index 07874159d..b3bf052e9 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 @@ -1,5 +1,6 @@ package com.metaobjects.mojo; +import com.metaobjects.generator.DocsGenerator; import com.metaobjects.generator.GeneratorRegistry; import com.metaobjects.generator.kotlin.GeneratorInfo; import com.metaobjects.generator.kotlin.GeneratorRegistryKt; @@ -65,6 +66,9 @@ public static final class Entry { /** Wiring the generic eject report cannot know, with {@code {owned}} standing for the * owned copy's class name, or {@code null} (see {@link #wiringNote}). */ public final String note; + /** Whether this generator is a {@link DocsGenerator}: {@code metaobjects:docs} runs it + * and {@code metaobjects:generate} skips it. */ + public final boolean docs; /** 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; @@ -96,7 +100,8 @@ public Entry(Port port, String stableName, String classname, String description, this.description = description; this.resourcePath = resourcePath; this.runtime = List.copyOf(runtime); - this.note = note; + this.docs = isDocsGenerator(classname); + this.note = docs ? stableName + DOCS_NOTE.replace("{surface}", port.id) : note; this.with = List.copyOf(with); } @@ -121,8 +126,24 @@ public String resourceOf(String source) { /** {@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. A {@link DocsGenerator}'s 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 docs ? basePackage + "." + 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. */ @@ -131,6 +152,29 @@ public String simpleName() { } } + /** The subpackage of the owned package a {@link DocsGenerator}'s copies are written under, + * above the one named for its port (see {@link Entry#ownedPackage}). */ + public static final String DOCS_SUBPACKAGE = "apidocs"; + + /** What eject prints for a {@link DocsGenerator}, on either port, after its stable name: no pom + * lists one until its owner adds it. {@code {surface}} is the port it documents. */ + private static final String DOCS_NOTE = + ": `mvn metaobjects:docs` runs this generator, not `metaobjects:generate`, which " + + "skips it. List it in with {owned} and the " + + "docs goal runs your copy for the {surface} surface in place of the packaged one."; + + /** Whether the registered generator class {@code classname} is a {@link DocsGenerator}. + * Every registered class is on this plugin's classpath. */ + private static boolean isDocsGenerator(String classname) { + if (classname == null) return false; + try { + return DocsGenerator.class.isAssignableFrom( + Class.forName(classname, false, EjectSupport.class.getClassLoader())); + } catch (ClassNotFoundException e) { + throw new IllegalStateException("registered generator class is not on the classpath: " + classname, e); + } + } + /** The simple name of a fully-qualified class name. */ public static String simpleNameOf(String classname) { int dot = classname.lastIndexOf('.'); 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 9b3ff64f1..0357f8f7c 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 @@ -92,7 +92,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; @@ -181,7 +183,8 @@ public void execute() throws MojoExecutionException, MojoFailureException { 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,7 +201,7 @@ 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); } @@ -206,7 +209,7 @@ public void execute() throws MojoExecutionException, MojoFailureException { support.addAll(EjectSupport.supportFor(rewritten)); rewritten = EjectSupport.rewriteSupportImports(rewritten, pkg); if (!e.resources.isEmpty()) { - rewritten = EjectSupport.rewriteTemplatePrefix(rewritten, pkg); + rewritten = EjectSupport.rewriteTemplatePrefix(rewritten, ownedPkg); } } @@ -215,7 +218,7 @@ 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); @@ -244,16 +247,18 @@ public void execute() throws MojoExecutionException, MojoFailureException { /** * Write the templates the ejected generators read from the classpath into the owned module's - * resources, under the owned package's path. The copied template provider is pointed there - * ({@link EjectSupport#rewriteTemplatePrefix}), so the owned page text is what runs. + * 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<>(); - Path dir = resourceRoot.resolve(EjectSupport.ownedTemplatePrefix(pkg)); + 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) { - if (!written.add(ref)) continue; + 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 { @@ -262,7 +267,6 @@ private void ejectTemplates(List resolved, Path resourc throw new MojoExecutionException("Could not read reference template \"" + ref + "\" at classpath resource " + resource, ex); } - Path target = dir.resolve(ref + EjectSupport.TEMPLATE_EXTENSION); switch (write(target.getParent(), target, content, force)) { case CREATED: getLog().info("Copied template " + ref + " -> " + target 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..88923d6f0 --- /dev/null +++ b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/ApiDocsEjectBothPortsTest.java @@ -0,0 +1,173 @@ +package com.metaobjects.mojo; + +import com.metaobjects.generator.DocsGenerator; +import com.tschuchort.compiletesting.KotlinCompilation; +import com.tschuchort.compiletesting.SourceFile; +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.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.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.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.assertTrue; + +/** + * A project that documents both surfaces ({@code metaobjects:docs} with {@code language=java,kotlin}) + * can own both {@code api-docs} 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 the docs generator of that surface.

          + */ +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 owned classname eject printed for it. */ + private String ejectApiDocs(String port) throws Exception { + MetaDataEjectMojo mojo = new MetaDataEjectMojo(); + mojo.project = project; + mojo.names = "api-docs"; + mojo.port = port; + Log log = Mockito.mock(Log.class); + mojo.setLog(log); + mojo.execute(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(CharSequence.class); + Mockito.verify(log, 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; + } + + 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 void assertDocsGeneratorOf(String surface, String classname, ClassLoader loader) throws Exception { + Object generator = Class.forName(classname, true, loader).getDeclaredConstructor().newInstance(); + assertTrue(classname + " is not a DocsGenerator", generator instanceof DocsGenerator); + assertEquals(classname, surface, ((DocsGenerator) generator).surface()); + } + + @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())) { + assertDocsGeneratorOf("java", ownedJava, java); + } + assertDocsGeneratorOf("kotlin", ownedKotlin, kotlin.getClassLoader()); + } +} diff --git a/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/EjectContractTest.java b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/EjectContractTest.java index 74e5e6414..f7c99a10e 100644 --- a/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/EjectContractTest.java +++ b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/EjectContractTest.java @@ -546,7 +546,7 @@ public void depthAnUnchangedOwnedCopyEmitsWhatThePackagedGeneratorEmits() throws for (Corpus corpus : CORPORA) { String where = e.stableName + " over " + corpus.label; Generator packaged = (Generator) Class.forName(e.classname).getDeclaredConstructor().newInstance(); - Generator owned = (Generator) Class.forName(OWNED_PACKAGE + "." + e.simpleName(), true, cl) + Generator owned = (Generator) Class.forName(e.ownedClassname(OWNED_PACKAGE), true, cl) .getDeclaredConstructor().newInstance(); Map a = run(packaged, corpus, corpus.loader(), true); Map b = run(owned, corpus, corpus.loader(), false); diff --git a/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/EjectSupportTest.java b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/EjectSupportTest.java index ecc73216a..be6dada09 100644 --- a/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/EjectSupportTest.java +++ b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/EjectSupportTest.java @@ -55,17 +55,34 @@ public void entityAndExtractorAreEjectableOnBothPortsAndSoNeedAPort() { } @Test - public void onlyTheJavaExtractorAndApiDocsCarryAWiringNoteAndItNamesTheOwnedClass() { + public void theJavaExtractorAndApiDocsOnBothPortsCarryAWiringNoteAndItNamesTheOwnedClass() { + int noted = 0; for (EjectSupport.Entry e : EjectSupport.allEntries()) { boolean javaExtractor = e.port == EjectSupport.Port.JAVA && e.stableName.equals("extractor"); - boolean javaApiDocs = e.port == EjectSupport.Port.JAVA && e.stableName.equals("api-docs"); - if (!javaExtractor && !javaApiDocs) { - assertNull(e.port.id + " " + e.stableName, e.wiringNote("com.acme.codegen")); + boolean apiDocs = e.stableName.equals("api-docs"); + String note = e.wiringNote("com.acme.codegen"); + if (!javaExtractor && !apiDocs) { + assertNull(e.port.id + " " + e.stableName, note); continue; } - String note = e.wiringNote("com.acme.codegen"); - assertTrue(note, note.contains("com.acme.codegen." + e.simpleName() + "")); + noted++; + String ownedPackage = apiDocs ? "com.acme.codegen.apidocs." + e.port.id : "com.acme.codegen"; + assertTrue(note, note.contains("" + ownedPackage + "." + e.simpleName() + "")); assertFalse(note, note.contains("{owned}")); + // No pom lists a docs generator until its owner adds it, on either port. + if (apiDocs) assertTrue(note, note.contains("the " + e.port.id + " surface")); + } + assertEquals("the Java extractor, and api-docs on each port", 3, noted); + } + + @Test + public void onlyTheApiDocsCopiesTakeASubpackageAndEachPortHasItsOwn() { + // The two ports' api reference classes share simple names, and one module may own both. + // Every other generator keeps the package it ejected into before 1.1. + for (EjectSupport.Entry e : EjectSupport.allEntries()) { + String expected = e.stableName.equals("api-docs") + ? "com.acme.codegen.apidocs." + e.port.id : "com.acme.codegen"; + assertEquals(e.port.id + " " + e.stableName, expected, e.ownedPackage("com.acme.codegen")); } } diff --git a/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/MetaDataEjectMojoTest.java b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/MetaDataEjectMojoTest.java index a6e1ca923..8baf5a704 100644 --- a/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/MetaDataEjectMojoTest.java +++ b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/MetaDataEjectMojoTest.java @@ -339,21 +339,20 @@ public void namesIsRequiredWhenNotListing() { public void ejectingTheJavaApiDocsWritesTheTemplatesAndPointsTheCopiedProviderAtThem() throws Exception { mojo("api-docs", false, "java", null).execute(); - Path src = codegenRoot().resolve("src/main/java/com/acme/codegen"); + // The api-docs copies take the Java subpackage of the owned package. + Path src = codegenRoot().resolve("src/main/java/com/acme/codegen/apidocs/java"); for (String file : List.of("JavaApiDocsGenerator", "JavaApiModelBuilder", "JavaApiDocsRenderer", "DocsPaths", "ApiUnit", "ClasspathTemplateProvider")) { assertTrue(file, Files.isRegularFile(src.resolve(file + ".java"))); } - // The page text is the adopter's, under the owned package's path so no jar can shadow it. - Path templates = codegenRoot().resolve("src/main/resources/com/acme/codegen/templates/api"); + // The page text is the adopter's, under the copies' package path so no jar can shadow it. + Path templates = codegenRoot().resolve("src/main/resources/com/acme/codegen/apidocs/java/templates/api"); for (String name : List.of("entity-api", "index", "agent-api")) { assertTrue(name, Files.isRegularFile(templates.resolve(name + ".md.mustache"))); } String provider = Files.readString(src.resolve("ClasspathTemplateProvider.java"), StandardCharsets.UTF_8); - assertTrue(provider, provider.contains("BASE_PREFIX = \"com/acme/codegen/templates/\";")); - // The generator names the engine's applicability, never a generator class. + assertTrue(provider, provider.contains("BASE_PREFIX = \"com/acme/codegen/apidocs/java/templates/\";")); String generator = Files.readString(src.resolve("JavaApiDocsGenerator.java"), StandardCharsets.UTF_8); - assertFalse(generator, generator.contains("SpringDtoGenerator")); - assertTrue(generator, generator.contains("package com.acme.codegen;")); + assertTrue(generator, generator.contains("package com.acme.codegen.apidocs.java;")); } } From 4c23ee043d933f5c83aab26c76522a1231bbb712 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Mon, 12 Oct 2026 04:50:01 -0400 Subject: [PATCH 10/17] no-mistakes(review): fix(eject): print pom changes a second port needs --- CHANGELOG.md | 12 +- docs/features/own-your-codegen.md | 30 +++- docs/ports/java.md | 2 +- docs/ports/kotlin.md | 2 +- .../metaobjects/generator/DocsGenerator.java | 10 +- .../java/com/metaobjects/mojo/DocsMojo.java | 7 +- .../com/metaobjects/mojo/EjectSupport.java | 7 +- .../metaobjects/mojo/MetaDataEjectMojo.java | 130 ++++++++++++++---- .../mojo/ApiDocsEjectBothPortsTest.java | 67 ++++++++- 9 files changed, 214 insertions(+), 53 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9239279d0..159a2e2ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -151,11 +151,17 @@ generator catalog and the Atlas evaluation are 1.2 work. `mvn metaobjects:docs` writes is now `JavaApiDocsGenerator` / `KotlinApiDocsGenerator`, run through a new `DocsGenerator` marker (`metaobjects-codegen-base`). `metaobjects:generate` and `metaobjects:verify` skip a `DocsGenerator` and say so in the build log; `metaobjects:docs` - runs the one your `` list names for a surface, else the packaged one. Eject + runs the one the `` list it reads names for a surface, else the packaged one. + That list is the plugin-level ``'s, or the `docs` execution's own, never the + `generate` execution's. Eject copies the generator, the page model, the builder, the renderer and the path math into a subpackage per port, `.apidocs.java` or `.apidocs.kotlin`: the two ports - name those classes alike, and one `codegen/` module can own both. Eject prints the class - name to list. For Java the page templates come too, into + name those classes alike, so one `codegen/` module can hold both without a class declared + twice. Eject prints the class name to list. The `codegen/pom.xml` the first eject wrote + builds that port only, and eject never edits it: when it does not declare what a port in the + module needs (the `metaobjects-codegen-*` dependency, `kotlin-maven-plugin`, or + `src/main/java` as a source root after a Kotlin-first eject), eject now warns and prints + what to add. For Java the page templates come too, into `codegen/src/main/resources//apidocs/java/templates/`, with the copied template provider pointed there. What the Java reference documents is still what the packaged generators emit for (`GeneratorApplicability.packaged()`): an owned `routes` whose diff --git a/docs/features/own-your-codegen.md b/docs/features/own-your-codegen.md index a1929c0e0..2a30aa9ed 100644 --- a/docs/features/own-your-codegen.md +++ b/docs/features/own-your-codegen.md @@ -1085,13 +1085,29 @@ renderer writes its pages in code, so it has no templates. alike (`ApiUnit`, `ApiSymbol`, `FieldShape`, `ApiSymbolKind`, `DocsPaths`), so a project that documents both surfaces can own both generators in one `codegen/` module without declaring a class twice. Eject prints each copy's class name, for example -`com.acme.codegen.apidocs.java.JavaApiDocsGenerator`. No other generator's package changes. Eject -never edits an existing pom, so the `codegen/pom.xml` the first eject wrote builds that port -only: when you eject the other port into the same module, add its `metaobjects-codegen-*` -dependency and its compiler to that pom yourself. - -The generator is a `DocsGenerator`. List it in `` with `` set to your copy, -and `metaobjects:docs` runs it for its surface in place of the packaged one. `metaobjects:generate` +`com.acme.codegen.apidocs.java.JavaApiDocsGenerator`. No other generator's package changes. + +**The second port needs the pom changed, and eject prints the change.** Eject never edits an +existing pom, so the `codegen/pom.xml` the first eject wrote builds that port only, and the +module still builds green with the other port's copies left out. When the pom does not declare +what a port in the module needs, eject warns and prints what to add, in either order: + +- **Java first, then Kotlin.** Add the `metaobjects-codegen-kotlin` dependency, the + `kotlin.compiler.jvmTarget` property, and `kotlin-maven-plugin` with `src/main/kotlin` as its + ``. +- **Kotlin first, then Java.** Add the `metaobjects-codegen-spring` dependency, remove + `src/main/kotlin` so that Maven compiles `src/main/java` + again, and give `kotlin-maven-plugin` `src/main/kotlin` as its ``. + +Either way the pom ends up with both dependencies, Maven's default Java source root, and the +Kotlin plugin pointed at its own. + +The generator is a `DocsGenerator`. List it, with `` set to your copy, in the +`` the docs goal reads, and `metaobjects:docs` runs it for its surface in place of the +packaged one. That list is the one in the plugin-level ``, or in the `docs` +execution's own. A `` list inside the `generate` execution's `` is read +by that execution only, so an entry added there leaves `metaobjects:docs` on the packaged +generator, and its log line says which one it ran. `metaobjects:generate` and `metaobjects:verify` skip it, because it writes pages under the docs directory and not code under `outputDir`, and say so in the build log. The docs goal sets `outputDir`, `projectName`, `apiSubDir`, `layout` and `modelBaseUrl` on it, and the pages land under diff --git a/docs/ports/java.md b/docs/ports/java.md index 7a9907be2..785d2b571 100644 --- a/docs/ports/java.md +++ b/docs/ports/java.md @@ -288,7 +288,7 @@ name in every port. | `extractor` | `ExtractorCodeGenerator` | `metaobjects-codegen-base` | One `Extractor` per concrete object, wrapping the runtime tolerant extract and turning dirty LLM text into a fully-typed flavored object graph (nested objects + arrays-of-objects populated) in one call. Args: `outputDir`, and the `pkgPrefix` / `pkgSuffix` / `namePrefix` / `nameSuffix` your `entity` generator uses; give it the same `` too. Needs `entity` with a concrete `flavor` in the same run, since an extractor casts to the flavored class. It names `MetaObjectExtractor` (in `metaobjects-om`) by FQN string only, so `codegen-base` keeps no compile dependency on `om` — the reference resolves on the consumer's classpath. | | `trace-helper` | `LlmTraceHelperGenerator` | `metaobjects-codegen-spring` | One `TraceHelper` per concrete entity that transitively `extends` `metaobjects::ai::LlmCallBase` **and** nests a `template.prompt` carrying `@responseRef` — a static `record(...)` that extracts the typed response, builds the `LlmCallBase` trace row, and persists it. Emits nothing for any other entity. | | `template` | `TemplateScopeGenerator` | `metaobjects-codegen-base` | The generic Mustache primitive: walk the model by a named `scope`, render one shared template per walk result, write each file. The declarative alternative to writing a generator class — see [Declarative template-codegen](#declarative-template-codegen-templatescopegenerator) below. It renders through `render.templategen.TemplateGenerator`, the byte-pinned cross-port factory; that factory takes a walk callback and is not itself a `Generator`, so `TemplateScopeGenerator` is the class a pom names. | -| `api-docs` | `JavaApiDocsGenerator` | `metaobjects-codegen-spring` | The Java SDK api reference `mvn metaobjects:docs` writes: one page per entity, value object, projection, served report and template, an index and `AGENT-API.md`, under `/api/java`. A `DocsGenerator`: `metaobjects:generate` and `metaobjects:verify` skip it. List an owned copy in `` and the docs goal runs it in place of the packaged one. Ejectable: the copy takes the page IR, the builder, the renderer, the page paths and the page templates with it. | +| `api-docs` | `JavaApiDocsGenerator` | `metaobjects-codegen-spring` | The Java SDK api reference `mvn metaobjects:docs` writes: one page per entity, value object, projection, served report and template, an index and `AGENT-API.md`, under `/api/java`. A `DocsGenerator`: `metaobjects:generate` and `metaobjects:verify` skip it. List an owned copy in the `` the docs goal reads (the plugin-level ``, or the `docs` execution's own; a list inside the `generate` execution's ``, as in [Configure](#configure), is not read by `metaobjects:docs`) and the docs goal runs it in place of the packaged one. Ejectable: the copy takes the page IR, the builder, the renderer, the page paths and the page templates with it. | | `plantuml` | `PlantUMLGenerator` | `metaobjects-codegen-plantuml` | One PlantUML class diagram of the object model (`outputDir`, `outputFilename`; `showAttrs`, `showFields`, `showAbstracts`, `drawKeys`, `drawRefs` choose what is drawn). Ejectable: the copy takes `PlantUMLWriter` with it. | | `html-docs` | `MetaDataHtmlDocumentationGenerator` | `metaobjects-codegen-base` | One HTML page describing the registered metamodel types (`outputDir`, `outputFilename`). It reads the type registry, not your model, so it documents the vocabulary your build loads, providers included. Ejectable: the copy takes `MetaDataHtmlDocumentationWriter` with it. | | `ai-docs` | `MetaDataAIDocumentationGenerator` | `metaobjects-codegen-base` | The same type reference as JSON, laid out for an AI reader (`outputDir`, `outputFilename`). Ejectable: the copy takes `MetaDataAIDocumentationWriter` with it. | diff --git a/docs/ports/kotlin.md b/docs/ports/kotlin.md index 72a748dd0..fb0b837a3 100644 --- a/docs/ports/kotlin.md +++ b/docs/ports/kotlin.md @@ -107,7 +107,7 @@ The 16 generators registered in `codegen-kotlin` (`GeneratorRegistry.kt`): | `KotlinStoredProcGenerator` | Stored-procedure call wrappers | entities with `source.rdb @kind="storedProc"` | | `KotlinSpringControllerGenerator` | `Controller.kt` — Spring `@RestController` (5 CRUD endpoints; cross-port API contract). **Select `KotlinRelationsGenerator` with it** when the model has a M:N relationship: the traversal routes call the `Query` helpers only that generator emits, and without it the controller does not compile | entities with `source.rdb @kind="table"` | | `KotlinRequirementTestsGenerator` | Per metamodel package that holds a tested requirement, two files in `testPackage`: `Requirements__Witnesses.kt` (an interface whose default members fail with `unimplemented requirement: ...`) and `Requirements__Test.kt` (one JUnit Jupiter `@Test` per requirement, calling a project-owned witness). See "Requirement tests" below | every tested `requirement.*` (functional, level 4 or above by default) | -| `KotlinApiDocsGenerator` | The Kotlin SDK api reference `mvn metaobjects:docs` writes under `/api/kotlin`: `.md` per entity, value object, projection, served report and template, a `README.md` index and `AGENT-API.md`. A `DocsGenerator`: `metaobjects:generate` and `metaobjects:verify` skip it, and `metaobjects:docs` runs your copy in place of the packaged one when `` names it. Ejectable, with the page model, builder, renderer and path math | the whole model, once | +| `KotlinApiDocsGenerator` | The Kotlin SDK api reference `mvn metaobjects:docs` writes under `/api/kotlin`: `.md` per entity, value object, projection, served report and template, a `README.md` index and `AGENT-API.md`. A `DocsGenerator`: `metaobjects:generate` and `metaobjects:verify` skip it, and `metaobjects:docs` runs your copy in place of the packaged one when the `` it reads names it (the plugin-level ``, or the `docs` execution's own, not the `generate` execution's). Ejectable, with the page model, builder, renderer and path math | the whole model, once | Maven wiring: diff --git a/server/java/codegen-base/src/main/java/com/metaobjects/generator/DocsGenerator.java b/server/java/codegen-base/src/main/java/com/metaobjects/generator/DocsGenerator.java index 4d5ae091f..140c501c3 100644 --- a/server/java/codegen-base/src/main/java/com/metaobjects/generator/DocsGenerator.java +++ b/server/java/codegen-base/src/main/java/com/metaobjects/generator/DocsGenerator.java @@ -11,9 +11,13 @@ * implementing this interface. {@code generate} and {@code verify} skip it and say so, and * {@code docs} runs it.

          * - *

          One {@code } list serves all three goals. An owned {@code api-docs} copy is - * listed beside the code generators, and each goal runs the half that is its own. When the list - * holds a docs generator for a surface, {@code docs} runs it in place of the packaged one.

          + *

          One {@code } list serves all three goals when it sits in the plugin-level + * {@code }: an owned {@code api-docs} copy is listed beside the code generators, + * and each goal runs the half that is its own. A list inside one execution's {@code + * } is read by that execution only, so {@code docs} sees an entry in the {@code + * generate} execution's list only if the {@code docs} execution's own list repeats it. When the + * list {@code docs} reads holds a docs generator for a surface, it runs that one in place of the + * packaged one.

          * *

          The goal sets these args on the generator: {@link #ARG_PROJECT_NAME}, {@link * #ARG_API_SUBDIR}, {@link #ARG_LAYOUT} and {@link #ARG_MODEL_BASE_URL}, plus {@code outputDir}, 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 9c0c0345e..35927e969 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 @@ -37,9 +37,10 @@ * {@link #apiSubDir} explicitly only when documenting a single language into a custom dir. * *

          The Mojo is wiring only: each surface is a {@link DocsGenerator} ({@code api-docs}) that - * writes its pages under {@link #outputDirectory}. When the project's {@code } list - * holds a docs generator for a surface, the goal runs that one, so a project that ejected - * {@code api-docs} runs its own copy. Every name comes from the per-language IR and every path + * writes its pages under {@link #outputDirectory}. When the {@code } list this goal + * reads (the plugin-level {@code }, or the docs execution's own; never another + * execution's) holds a docs generator for a surface, the goal runs that one, so a project that + * ejected {@code api-docs} runs its own copy. 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}. 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 b3bf052e9..0676ce341 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 @@ -160,8 +160,11 @@ public String simpleName() { * lists one until its owner adds it. {@code {surface}} is the port it documents. */ private static final String DOCS_NOTE = ": `mvn metaobjects:docs` runs this generator, not `metaobjects:generate`, which " - + "skips it. List it in with {owned} and the " - + "docs goal runs your copy for the {surface} surface in place of the packaged one."; + + "skips it. List it with {owned} in the the docs " + + "goal reads, which is the plugin-level or the docs execution's own, " + + "and the docs goal runs your copy for the {surface} surface in place of the packaged " + + "one. A list inside another execution's , the generate " + + "execution's for one, is not read by the docs goal."; /** Whether the registered generator class {@code classname} is a {@link DocsGenerator}. * Every registered class is on this plugin's classpath. */ 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 0357f8f7c..f58c937a4 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. */ @@ -225,6 +228,7 @@ public void execute() throws MojoExecutionException, MojoFailureException { ejectTemplates(resolved, codegenRoot.resolve("src/main/resources"), pkg); boolean pomWritten = writeCodegenPomIfNeeded(codegenRoot, pkg, portsTouched); + reportWhatThePomLacks(codegenRoot); if (!runtime.isEmpty()) { ejectRuntime(runtime, runtimeRoot, runtimePkg); @@ -508,39 +512,59 @@ 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"; + } + 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 : EjectSupport.Port.values()) { + if (ports.contains(port)) deps.append(dependency(port)); } String kotlinPlugin = ports.contains(EjectSupport.Port.KOTLIN) ? " \n" + " 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("") + " \n" + " \n" : ""; @@ -559,9 +583,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 @@ -570,6 +592,54 @@ 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 of one call and never edits it, so a port + * ejected into the module in another 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 : EjectSupport.Port.values()) { + String sourceRoot = "src/main/" + port.id; + if (!Files.isDirectory(codegenRoot.resolve(sourceRoot))) continue; + 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/test/java/com/metaobjects/mojo/ApiDocsEjectBothPortsTest.java b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/ApiDocsEjectBothPortsTest.java index 88923d6f0..2dc78af3c 100644 --- 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 @@ -72,8 +72,8 @@ public void setUp() throws IOException { 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 owned classname eject printed for it. */ - private String ejectApiDocs(String port) throws Exception { + /** Eject {@code api-docs} for one port, and return the log eject wrote to. */ + private Log eject(String port) throws Exception { MetaDataEjectMojo mojo = new MetaDataEjectMojo(); mojo.project = project; mojo.names = "api-docs"; @@ -81,9 +81,13 @@ private String ejectApiDocs(String port) throws Exception { Log log = Mockito.mock(Log.class); mojo.setLog(log); mojo.execute(); + return log; + } + /** 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(log, Mockito.atLeastOnce()).info(captor.capture()); + Mockito.verify(eject(port), Mockito.atLeastOnce()).info(captor.capture()); List printed = new ArrayList<>(); for (CharSequence line : captor.getAllValues()) { Matcher m = CLASSNAME_CHANGE.matcher(line); @@ -97,6 +101,13 @@ private String ejectApiDocs(String port) throws Exception { 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()); @@ -150,6 +161,56 @@ private static void assertDocsGeneratorOf(String surface, String classname, Clas assertEquals(classname, surface, ((DocsGenerator) generator).surface()); } + // The pom eject writes builds the ports of that call. 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 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"); From 4176e97dec12604c7e53810df3d9145db46bdb8d Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Mon, 12 Oct 2026 05:00:41 -0400 Subject: [PATCH 11/17] no-mistakes(review): fix(eject): two-port pom builds both source trees --- CHANGELOG.md | 11 +-- docs/features/own-your-codegen.md | 34 +++++---- .../metaobjects/mojo/MetaDataEjectMojo.java | 31 +++++---- .../mojo/ApiDocsEjectBothPortsTest.java | 69 ++++++++++++++++++- 4 files changed, 110 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 159a2e2ea..203c40066 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -157,11 +157,12 @@ generator catalog and the Atlas evaluation are 1.2 work. copies the generator, the page model, the builder, the renderer and the path math into a subpackage per port, `.apidocs.java` or `.apidocs.kotlin`: the two ports name those classes alike, so one `codegen/` module can hold both without a class declared - twice. Eject prints the class name to list. The `codegen/pom.xml` the first eject wrote - builds that port only, and eject never edits it: when it does not declare what a port in the - module needs (the `metaobjects-codegen-*` dependency, `kotlin-maven-plugin`, or - `src/main/java` as a source root after a Kotlin-first eject), eject now warns and prints - what to add. For Java the page templates come too, into + twice. Eject prints the class name to list. Eject writes `codegen/pom.xml` for the ports + the module holds at that moment and never edits it afterwards. One call that ejects for + both ports now gets a pom that builds both source trees; it used to leave `src/main/java` + out. When a later call adds a port, the pom does not declare what that port needs (the + `metaobjects-codegen-*` dependency, `kotlin-maven-plugin`, or `src/main/java` as a source + root after a Kotlin-first eject), and eject now warns and prints what to add. For Java the page templates come too, into `codegen/src/main/resources//apidocs/java/templates/`, with the copied template provider pointed there. What the Java reference documents is still what the packaged generators emit for (`GeneratorApplicability.packaged()`): an owned `routes` whose diff --git a/docs/features/own-your-codegen.md b/docs/features/own-your-codegen.md index 2a30aa9ed..9bc1a9ee2 100644 --- a/docs/features/own-your-codegen.md +++ b/docs/features/own-your-codegen.md @@ -902,7 +902,8 @@ other classes (the emitters it writes through, below, and Java `entity` and `ext for the support imports and the template prefix described below: there the imports those copies have of one another change too. On first use it writes `codegen/pom.xml`, which depends on `metaobjects-codegen-spring` or -`metaobjects-codegen-kotlin` at the plugin's version. +`metaobjects-codegen-kotlin` at the plugin's version, or on both when that call ejects for both +ports. Eject changes a copy's package declaration, and for a Java generator up to three more lines, each described below: its import of `SpringGeneratorSupport`, its @@ -1087,20 +1088,23 @@ documents both surfaces can own both generators in one `codegen/` module without class twice. Eject prints each copy's class name, for example `com.acme.codegen.apidocs.java.JavaApiDocsGenerator`. No other generator's package changes. -**The second port needs the pom changed, and eject prints the change.** Eject never edits an -existing pom, so the `codegen/pom.xml` the first eject wrote builds that port only, and the -module still builds green with the other port's copies left out. When the pom does not declare -what a port in the module needs, eject warns and prints what to add, in either order: - -- **Java first, then Kotlin.** Add the `metaobjects-codegen-kotlin` dependency, the - `kotlin.compiler.jvmTarget` property, and `kotlin-maven-plugin` with `src/main/kotlin` as its - ``. -- **Kotlin first, then Java.** Add the `metaobjects-codegen-spring` dependency, remove - `src/main/kotlin` so that Maven compiles `src/main/java` - again, and give `kotlin-maven-plugin` `src/main/kotlin` as its ``. - -Either way the pom ends up with both dependencies, Maven's default Java source root, and the -Kotlin plugin pointed at its own. +**The pom builds the ports the module held when eject wrote it.** Eject writes +`codegen/pom.xml` once and never edits an existing one, so how a module comes to hold both ports +decides what is left for you to do: + +- **Both ports in one call** (a Java-only name beside a Kotlin-only one, for example + `-Dnames=html-docs,exposed-table`). The pom eject writes builds both. Nothing to change. +- **Java first, then Kotlin in a later call.** Add the `metaobjects-codegen-kotlin` dependency, + the `kotlin.compiler.jvmTarget` property, and `kotlin-maven-plugin` with `src/main/kotlin` as + its ``. +- **Kotlin first, then Java in a later call.** Add the `metaobjects-codegen-spring` dependency, + remove `src/main/kotlin` so that Maven compiles + `src/main/java` again, and give `kotlin-maven-plugin` `src/main/kotlin` as its ``. + +In the two later-call orders the module still builds green with the new port's copies left out, +so eject warns and prints what to add. All three orders end with the same pom: both +dependencies, Maven's default Java source root, and the Kotlin plugin pointed at its own. +`-Dforce` rewrites the pom for every port the module holds. The generator is a `DocsGenerator`. List it, with `` set to your copy, in the `` the docs goal reads, and `metaobjects:docs` runs it for its surface in place of the 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 f58c937a4..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 @@ -165,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<>(); @@ -183,7 +182,6 @@ 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"; String ownedPkg = e.ownedPackage(pkg); @@ -227,7 +225,7 @@ public void execute() throws MojoExecutionException, MojoFailureException { 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()) { @@ -555,16 +553,26 @@ private static String kotlinPlugin(String configuration) { + " \n"; } - private String codegenPom(String artifactId, Set ports) { - StringBuilder deps = new StringBuilder(); + /** 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 (ports.contains(port)) deps.append(dependency(port)); + if (Files.isDirectory(codegenRoot.resolve("src/main/" + port.id))) out.add(port); } + return out; + } + + private String codegenPom(String artifactId, Set ports) { + StringBuilder deps = new StringBuilder(); + 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" - + kotlinPlugin("") + + kotlinPlugin(java ? KOTLIN_SOURCE_DIRS : "") + " \n" + " \n" : ""; @@ -601,8 +609,8 @@ private static boolean declares(String pom, String 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 of one call and never edits it, so a port - * ejected into the module in another call is left out of the build, and the build passes. + * 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"); @@ -612,9 +620,8 @@ private void reportWhatThePomLacks(Path codegenRoot) throws MojoExecutionExcepti } catch (IOException ex) { throw new MojoExecutionException("Could not read " + pom, ex); } - for (EjectSupport.Port port : EjectSupport.Port.values()) { + for (EjectSupport.Port port : portsIn(codegenRoot)) { String sourceRoot = "src/main/" + port.id; - if (!Files.isDirectory(codegenRoot.resolve(sourceRoot))) continue; List changes = new ArrayList<>(); if (!declares(content, codegenArtifact(port))) { changes.add(" Add to :\n" + dependency(port)); 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 index 2dc78af3c..bf2e757bd 100644 --- 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 @@ -3,11 +3,16 @@ import com.metaobjects.generator.DocsGenerator; 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; @@ -22,6 +27,7 @@ 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; @@ -38,6 +44,7 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; /** @@ -74,16 +81,50 @@ public void setUp() throws IOException { /** 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 = "api-docs"; + 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); @@ -161,8 +202,8 @@ private static void assertDocsGeneratorOf(String surface, String classname, Clas assertEquals(classname, surface, ((DocsGenerator) generator).surface()); } - // The pom eject writes builds the ports of that call. 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 + // 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. @@ -172,6 +213,28 @@ public void aPomWrittenForThePortBeingEjectedDrawsNoWarning() throws Exception { 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"); From 2b571fc144355027241e42535a747596ddc3a614 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Mon, 12 Oct 2026 06:58:12 -0400 Subject: [PATCH 12/17] no-mistakes(document): Fix Java ejectable-exemption sentence to exclude deprecated mustache --- docs/ports/java.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ports/java.md b/docs/ports/java.md index 785d2b571..7b174fc45 100644 --- a/docs/ports/java.md +++ b/docs/ports/java.md @@ -586,7 +586,7 @@ copy imports them. The model and the other ports' spelling are in ### Authoring your own — two paths -**Own a generator.** `mvn metaobjects:eject -Dnames=` copies a reference generator into a `codegen/` Maven module under your own package, and prints the module, dependency and `` to wire. See [Own your codegen → Java and Kotlin](../features/own-your-codegen.md#java-and-kotlin-mvn-metaobjectseject). Every generator in the table above can be ejected except `template`. +**Own a generator.** `mvn metaobjects:eject -Dnames=` copies a reference generator into a `codegen/` Maven module under your own package, and prints the module, dependency and `` to wire. See [Own your codegen → Java and Kotlin](../features/own-your-codegen.md#java-and-kotlin-mvn-metaobjectseject). Every generator in the table above can be ejected except `template` and the deprecated `mustache` (see [the primitive](../features/own-your-codegen.md#java-and-kotlin-mvn-metaobjectseject)). **`entity` and `extractor` eject from `metaobjects-codegen-base`, each with the rest of its emit.** `mvn metaobjects:eject -Dnames=entity,extractor -Dport=java` (pass `-Dport`: both names are ejectable on both JVM ports). `entity` copies eight files into one package: `JavaObjectCodeGenerator`, the bases it extends (`JavaCodeGenerator`, `BaseObjectCodeGenerator`), the four writers that emit each class body (`BaseObjectCodeWriter`, `JavaCodeWriter`, `PojoAwareCodeWriter`, `ValueObjectCodeWriter`) and `ExtractorCodeGenerator`, which writes the `Extractor` classes. The copies use one another, never the packaged classes, so you change a field, a constructor or an accessor by editing the writer. `extractor` copies `ExtractorCodeGenerator` with `JavaCodeWriter` and `BaseObjectCodeWriter`, where it reads class names from; in the same package as `entity` the two share that one copy. The file-by-file table is in [Own your codegen → Java `entity` and `extractor`](../features/own-your-codegen.md#java-entity-and-extractor). From 7dc1f9a2754b37c8c0661f58e2483f29d452289c Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Wed, 14 Oct 2026 08:06:53 -0400 Subject: [PATCH 13/17] feat(maven-plugin)!: api-docs is an ordinary generator entry; remove the docs goal Every generator, documentation included, is a entry in the list metaobjects:generate runs, and nothing is written that does not declare (ADR-0034 Amendment 9). - JavaApiDocsGenerator and KotlinApiDocsGenerator read outputDir, apiSubDir, layout, projectName and modelBaseUrl from . The DocsGenerator marker and the skips in metaobjects:generate and metaobjects:verify are deleted. - metaobjects:docs only fails, with a line naming metaobjects:generate and the migration section. generate and verify refuse a pom that still sets the old goal's , , or , so a build never passes while writing no documentation. - The Java entity generator writes no Extractor; the extractor generator is the one entry that does, and entity no longer copies it on eject. EmitsObjectExtractors and the emitExtractors arg are removed (both were new in this unreleased version). - Tests: api-docs through generate and verify, the removed goal and the refused 8.0 configuration, and a pom that declares nothing writes zero files (Java and Kotlin). --- .../registry.json | 5 +- .../metaobjects/generator/DocsGenerator.java | 43 ----- .../generator/EmitsObjectExtractors.java | 52 ------ .../javacode/ExtractorCodeGenerator.java | 16 +- .../javacode/JavaObjectCodeGenerator.java | 66 +++---- .../javacode/README-flavored-objects.md | 32 ++-- .../generator/util/GeneratedFileWriter.java | 2 +- .../EjectedBaseGeneratorsCompileTest.java | 3 +- .../object/JavaCodegenWriteGuardTest.java | 2 +- .../StandaloneExtractorGeneratorTest.java | 143 ++++++--------- .../kotlin/apidocs/KotlinApiDocsGenerator.kt | 46 +++-- .../ApiDocsCrossPortConformanceKtTest.kt | 2 +- .../generator/kotlin/ReportingInertTest.kt | 2 +- .../generator/GeneratorRegistry.java | 26 ++- .../apidocs/JavaApiDocsGenerator.java | 43 +++-- .../ApiDocsCrossPortConformanceTest.java | 4 +- .../spring/CodegenCompileConformanceTest.java | 14 +- .../GeneratedExtractorCompileRunTest.java | 8 +- .../spring/NoMagicPhysicalNamesTest.java | 7 +- .../generator/spring/ReportingInertTest.java | 2 +- server/java/maven-plugin/pom.xml | 6 +- .../mojo/AbstractMetaDataMojo.java | 64 ++++--- .../java/com/metaobjects/mojo/DocsMojo.java | 170 ++---------------- .../com/metaobjects/mojo/EjectSupport.java | 50 ++---- .../mojo/MetaDataGeneratorMojo.java | 5 - .../metaobjects/mojo/MetaDataVerifyMojo.java | 8 +- .../mojo/ApiDocsEjectBothPortsTest.java | 45 +++-- .../mojo/ApiDocsGeneratorEntryTest.java | 162 +++++++++++++++++ .../DeclaresNothingWritesNothingTest.java | 108 +++++++++++ .../mojo/DocsGeneratorGoalsTest.java | 128 ------------- .../com/metaobjects/mojo/DocsMojoTest.java | 106 ----------- .../metaobjects/mojo/EjectSupportTest.java | 4 +- .../mojo/EmitExtractorsDerivationTest.java | 103 ----------- .../EntityExtractorEjectRoundTripTest.java | 37 ++-- .../metaobjects/mojo/RemovedDocsGoalTest.java | 76 ++++++++ .../mojo/test/OwnedDocsGenerator.java | 23 --- .../{pom-docs-kotlin.xml => pom-api-docs.xml} | 30 +++- .../mojo/{pom-docs.xml => pom-docs-8-0.xml} | 19 +- 38 files changed, 710 insertions(+), 952 deletions(-) delete mode 100644 server/java/codegen-base/src/main/java/com/metaobjects/generator/DocsGenerator.java delete mode 100644 server/java/codegen-base/src/main/java/com/metaobjects/generator/EmitsObjectExtractors.java create mode 100644 server/java/maven-plugin/src/test/java/com/metaobjects/mojo/ApiDocsGeneratorEntryTest.java create mode 100644 server/java/maven-plugin/src/test/java/com/metaobjects/mojo/DeclaresNothingWritesNothingTest.java delete mode 100644 server/java/maven-plugin/src/test/java/com/metaobjects/mojo/DocsGeneratorGoalsTest.java delete mode 100644 server/java/maven-plugin/src/test/java/com/metaobjects/mojo/DocsMojoTest.java delete mode 100644 server/java/maven-plugin/src/test/java/com/metaobjects/mojo/EmitExtractorsDerivationTest.java create mode 100644 server/java/maven-plugin/src/test/java/com/metaobjects/mojo/RemovedDocsGoalTest.java delete mode 100644 server/java/maven-plugin/src/test/java/com/metaobjects/mojo/test/OwnedDocsGenerator.java rename server/java/maven-plugin/src/test/resources/mojo/{pom-docs-kotlin.xml => pom-api-docs.xml} (52%) rename server/java/maven-plugin/src/test/resources/mojo/{pom-docs.xml => pom-docs-8-0.xml} (65%) diff --git a/fixtures/generator-registry-conformance/registry.json b/fixtures/generator-registry-conformance/registry.json index 2e3e040a3..5f5a58876 100644 --- a/fixtures/generator-registry-conformance/registry.json +++ b/fixtures/generator-registry-conformance/registry.json @@ -107,7 +107,7 @@ "concept": "Per-entity/template SDK API reference (generated-code API; human + agent forms).", "tier": "native", "layer": "docs", - "note": "Run by the port's docs command (`meta docs`, `dotnet meta docs`, `mvn metaobjects:docs`, `metaobjects docs`) as its api surface (ADR-0025), not by the gen command: a TypeScript `meta gen` config that lists it is warned about and skipped, and `dotnet meta gen` skips it, warning only when `--generators` names it.", + "note": "An ordinary generator, listed in the project's generator list and run by the gen command (ADR-0034 Amendment 9). Java and Kotlin do this today: a `` entry that `mvn metaobjects:generate` runs. TypeScript, C# and Python still run it from the port's docs command (`meta docs`, `dotnet meta docs`, `metaobjects docs`), and their gen commands skip it, until each port's Amendment 9 change lands and removes that command.", "eject": "full", "ports": ["typescript", "csharp", "java", "kotlin", "python"] }, @@ -363,8 +363,6 @@ "codegenPackages": ["com.metaobjects.generator.**"], "engine": [ "com.metaobjects.generator.CodeGenMetaDataProvider", - "com.metaobjects.generator.DocsGenerator", - "com.metaobjects.generator.EmitsObjectExtractors", "com.metaobjects.generator.EmitsPhysicalNameConstants", "com.metaobjects.generator.FileEmittingGenerator", "com.metaobjects.generator.Generator", @@ -402,7 +400,6 @@ "kotlin": { "codegenPackages": ["com.metaobjects.generator.**"], "engine": [ - "com.metaobjects.generator.DocsGenerator", "com.metaobjects.generator.EmitsPhysicalNameConstants", "com.metaobjects.generator.FileEmittingGenerator", "com.metaobjects.generator.Generator", diff --git a/server/java/codegen-base/src/main/java/com/metaobjects/generator/DocsGenerator.java b/server/java/codegen-base/src/main/java/com/metaobjects/generator/DocsGenerator.java deleted file mode 100644 index 140c501c3..000000000 --- a/server/java/codegen-base/src/main/java/com/metaobjects/generator/DocsGenerator.java +++ /dev/null @@ -1,43 +0,0 @@ -package com.metaobjects.generator; - -/** - * A generator that writes documentation, not code: the api reference {@code - * mvn metaobjects:docs} produces. - * - *

          {@code metaobjects:generate} writes code under {@code outputDir}, and its drift check, - * {@code metaobjects:verify}, regenerates code and diffs it against the committed tree. A docs - * generator does neither: its pages land under the docs output directory, relative to it, and - * are not code the build compiles. A generator that takes part in the docs run says so by - * implementing this interface. {@code generate} and {@code verify} skip it and say so, and - * {@code docs} runs it.

          - * - *

          One {@code } list serves all three goals when it sits in the plugin-level - * {@code }: an owned {@code api-docs} copy is listed beside the code generators, - * and each goal runs the half that is its own. A list inside one execution's {@code - * } is read by that execution only, so {@code docs} sees an entry in the {@code - * generate} execution's list only if the {@code docs} execution's own list repeats it. When the - * list {@code docs} reads holds a docs generator for a surface, it runs that one in place of the - * packaged one.

          - * - *

          The goal sets these args on the generator: {@link #ARG_PROJECT_NAME}, {@link - * #ARG_API_SUBDIR}, {@link #ARG_LAYOUT} and {@link #ARG_MODEL_BASE_URL}, plus {@code outputDir}, - * the docs output directory.

          - */ -public interface DocsGenerator extends Generator { - - /** The label the agent page carries in its header. */ - String ARG_PROJECT_NAME = "projectName"; - - /** Where under {@code outputDir} the api reference lands, e.g. {@code api/java}. */ - String ARG_API_SUBDIR = "apiSubDir"; - - /** The page layout: {@code flat} (one file per unit, the default) or {@code package}. */ - String ARG_LAYOUT = "layout"; - - /** When set, the base URL the model pages' back-links federate to. */ - String ARG_MODEL_BASE_URL = "modelBaseUrl"; - - /** The surface this generator documents: {@code java} or {@code kotlin}. {@code docs} hands - * the surface to the docs generator in the project's list that names it. */ - String surface(); -} diff --git a/server/java/codegen-base/src/main/java/com/metaobjects/generator/EmitsObjectExtractors.java b/server/java/codegen-base/src/main/java/com/metaobjects/generator/EmitsObjectExtractors.java deleted file mode 100644 index fabd25c7c..000000000 --- a/server/java/codegen-base/src/main/java/com/metaobjects/generator/EmitsObjectExtractors.java +++ /dev/null @@ -1,52 +0,0 @@ -package com.metaobjects.generator; - -import java.util.Collection; -import java.util.HashMap; -import java.util.Map; - -/** - * Marker: this generator EMITS the per-object {@code Extractor} classes, as a - * generator of its own in the run. - * - *

          The Java {@code entity} generator has always written those classes itself, beside each - * concrete flavored class, and still does when nothing else in the run will. The marker - * exists so a RUN can answer the question {@code entity} cannot answer alone: is a - * separate extractor generator writing these files? When one is, {@code entity} leaves - * them to it. Without that, wiring an owned extractor next to {@code entity} put two - * emitters on one path: identical output hid the duplicate, and the first edit to the owned - * copy failed the build as an output path collision.

          - * - *

          Same shape as {@link EmitsPhysicalNameConstants}: the Maven mojo builds the whole - * {@link Generator} list before it runs any of it, so that is where the answer is derived. - * Unlike {@code useNames} it is not a preference a pom can pin the other way: with a separate - * extractor generator in the run, {@code entity} writing them too is the duplicate.

          - */ -public interface EmitsObjectExtractors { - - /** - * The {@code entity} generator arg that says whether it writes the - * {@code Extractor} classes itself. Defaults to {@code true}, which is what it - * did before the extractor could be wired separately. - */ - String ARG_EMIT_EXTRACTORS = "emitExtractors"; - - /** True iff some generator in {@code generators} emits the extractors on its own. */ - static boolean includesExtractors(Collection generators) { - return generators.stream().anyMatch(g -> g instanceof EmitsObjectExtractors); - } - - /** - * {@code args} with {@link #ARG_EMIT_EXTRACTORS} derived from the run's generator set. - * - *

          It is {@code "false"} whenever a separate extractor generator is in the run, - * whatever {@code args} said, so each file has one emitter. Otherwise {@code args} is - * returned as it came, so a run without one behaves as it always has.

          - */ - static Map deriveEmitExtractors(Map args, - Collection generators) { - if (!includesExtractors(generators)) return args; - Map derived = new HashMap<>(args); - derived.put(ARG_EMIT_EXTRACTORS, "false"); - return derived; - } -} diff --git a/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/object/javacode/ExtractorCodeGenerator.java b/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/object/javacode/ExtractorCodeGenerator.java index ae3e244c5..9feb1f34f 100644 --- a/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/object/javacode/ExtractorCodeGenerator.java +++ b/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/object/javacode/ExtractorCodeGenerator.java @@ -3,7 +3,6 @@ // Eject note (ADR-0034 Amendment 6): `mvn metaobjects:eject -Dnames=extractor` copies this // file together with JavaCodeWriter and BaseObjectCodeWriter, into one package. JavaCodeWriter, // named bare below, is that copy: the names an owned extractor writes come from owned code. -import com.metaobjects.generator.EmitsObjectExtractors; import com.metaobjects.generator.GeneratorBase; import com.metaobjects.generator.GeneratorException; import com.metaobjects.generator.direct.object.BaseObjectCodeGenerator; @@ -46,13 +45,12 @@ * flavored type. The {@code extractLenient(...)} overload widens through {@code ExtractionResult} * to expose the typed {@code ExtractionResult<>}.

          * - *

          Two ways to run it. Wired on its own as a {@code }, {@link #execute} - * writes one Extractor per concrete object, named by the same {@code pkgPrefix} / - * {@code pkgSuffix} / {@code namePrefix} / {@code nameSuffix} args the {@code entity} - * generator takes, so give both the same values. Left out of the run, the {@code entity} - * generator ({@link JavaObjectCodeGenerator}) calls {@link #emit} itself for each concrete - * flavored class, which is what it has always done. A run that holds both writes each file - * once: {@link EmitsObjectExtractors} is how {@code entity} knows to leave them to this.

          + *

          One way to run it. Wired as a {@code }, {@link #execute} writes one + * Extractor per concrete object, named by the same {@code pkgPrefix} / {@code pkgSuffix} / + * {@code namePrefix} / {@code nameSuffix} args the {@code entity} generator takes, so give + * both the same values. Nothing else writes them: through 8.0 the {@code entity} generator + * ({@link JavaObjectCodeGenerator}) wrote one beside each concrete class for a pom that did + * not list this generator, and ADR-0034 Amendment 9 removed that.

          * *

          An Extractor casts to the generated flavored class, so it needs the {@code entity} * generator with a concrete {@code flavor} in the same run, selecting the same objects: give @@ -63,7 +61,7 @@ * {@code protected} so a subclass can override one (e.g. {@link #writeExtractMethod}) * without reimplementing the whole class.

          */ -public class ExtractorCodeGenerator extends GeneratorBase implements EmitsObjectExtractors { +public class ExtractorCodeGenerator extends GeneratorBase { /** Fully-qualified name of the runtime extract entry point (emitted as a source FQN string). */ public static final String META_OBJECT_EXTRACT_FQN = diff --git a/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/object/javacode/JavaObjectCodeGenerator.java b/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/object/javacode/JavaObjectCodeGenerator.java index 7b8520b33..cf7aa81d3 100644 --- a/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/object/javacode/JavaObjectCodeGenerator.java +++ b/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/object/javacode/JavaObjectCodeGenerator.java @@ -1,13 +1,8 @@ package com.metaobjects.generator.direct.object.javacode; // Eject note (ADR-0034 Amendment 6): `mvn metaobjects:eject -Dnames=entity` copies this file -// together with JavaCodeGenerator, BaseObjectCodeGenerator, the four writers and -// ExtractorCodeGenerator, into one package. The classes named bare below are those copies. -// ExtractorCodeGenerator is the `extractor` generator too, and eject points the import below -// at its copy, so an owned `entity` writes its extractors through owned code. -import com.metaobjects.generator.direct.object.javacode.ExtractorCodeGenerator; - -import com.metaobjects.generator.EmitsObjectExtractors; +// together with JavaCodeGenerator, BaseObjectCodeGenerator and the four writers, into one +// package. The classes named bare below are those copies. import com.metaobjects.generator.GeneratorException; import com.metaobjects.generator.direct.GenerationContext; import com.metaobjects.generator.direct.object.BaseObjectCodeWriter; @@ -50,8 +45,13 @@ * {@link JavaCodeWriter}, {@link PojoAwareCodeWriter}, {@link ValueObjectCodeWriter}). This * file holds which flavors exist and which writer each one gets ({@link #createWriter}), * the {@code ObjectClassBindingProvider} source and its {@code META-INF/services} - * registration, and whether the Extractors are written here. To change a field, a - * constructor or an accessor, edit the writer.

          + * registration. To change a field, a constructor or an accessor, edit the writer.

          + * + *

          It writes no {@code Extractor}. Through 8.0 it wrote one beside every + * concrete class, for a pom that listed only this generator. A file no {@code } + * entry declares is what ADR-0034 Amendment 9 rules out, so those are written by the + * {@code extractor} generator ({@link ExtractorCodeGenerator}) and by nothing else: list it, + * with this entry's {@code outputDir}, naming args and {@code }, to get them.

          */ public class JavaObjectCodeGenerator extends JavaCodeGenerator { @@ -77,16 +77,6 @@ private boolean isConcreteFlavor() { return FLAVOR_POJO_AWARE.equals(getFlavor()) || FLAVOR_VALUE_OBJECT.equals(getFlavor()); } - /** - * Whether this generator writes the Extractors itself: the - * {@link EmitsObjectExtractors#ARG_EMIT_EXTRACTORS} arg, default {@code true}. The Maven - * plugin sets it to {@code false} when a separate {@code extractor} generator is in the - * same {@code } list, so each file has one emitter. - */ - public boolean emitsExtractors() { - return Boolean.parseBoolean(getArg(EmitsObjectExtractors.ARG_EMIT_EXTRACTORS, "true")); - } - // FQN ("pkg::Name") of the generated provider class self-registered via ServiceLoader. static final String PROVIDER_PACKAGE = "com.metaobjects.generated"; static final String PROVIDER_SIMPLE_NAME = "GeneratedObjectClassBindingProvider"; @@ -128,9 +118,7 @@ protected void parseArgs() { * self-registering {@link ObjectClassBindingProvider} covering every generated object * (keyed on the package-folded metadata resolution key {@code pkg::Name}, which is what * {@link MetaObject#newInstance()} looks up), plus its {@code META-INF/services} - * registration so the provider is discoverable via {@code ServiceLoader}. It also writes - * a {@code Extractor} beside each concrete class, unless - * {@link EmitsObjectExtractors#ARG_EMIT_EXTRACTORS} says a separate extractor generator does. + * registration so the provider is discoverable via {@code ServiceLoader}. */ @Override public void execute(MetaDataLoader loader) { @@ -146,31 +134,21 @@ public void execute(MetaDataLoader loader) { JavaCodeWriter namer = (JavaCodeWriter) createWriter(loader, null, new PrintWriter(new StringWriter()), globalContext); Map fqnToClass = new LinkedHashMap<>(); - ExtractorCodeGenerator extractorGen = emitsExtractors() ? new ExtractorCodeGenerator() : null; Collection metadata = GeneratorUtil.getFilteredMetaData(loader, getFilterClass(), getMetaDataFilters()); - try { - for (MetaObject mo : metadata) { - // Abstract objects cannot be instantiated via newInstance(), so binding their - // resolution key to a generated class would make newInstance() try to construct - // a non-instantiable type — and an extractor for one could never produce a typed - // graph. Skip BOTH the binding entry and the extractor for abstract metadata - // (honors the abstract-codegen invariant: no instance artifacts for abstracts). - if (IOUtil.isAbstract(mo)) { - continue; - } - String javaPkg = namer.getLanguagePackage(mo); - String javaClass = namer.getClassName(mo); - String generatedFqn = isNotBlank(javaPkg) ? javaPkg + "." + javaClass : javaClass; - // Key on the package-folded resolution key — NOT the dotted Java FQN. - fqnToClass.put(mo.getName(), generatedFqn); - // Emit a Extractor alongside each CONCRETE flavored class. - if (extractorGen != null) { - extractorGen.emit(getOutputDir(), javaPkg, javaClass, mo.getName()); - } + for (MetaObject mo : metadata) { + // Abstract objects cannot be instantiated via newInstance(), so binding their + // resolution key to a generated class would make newInstance() try to construct + // a non-instantiable type. Skip the binding entry for abstract metadata (honors + // the abstract-codegen invariant: no instance artifacts for abstracts). + if (IOUtil.isAbstract(mo)) { + continue; } - } catch (IOException e) { - throw new GeneratorException("Unable to emit generated Extractor: " + e, e); + String javaPkg = namer.getLanguagePackage(mo); + String javaClass = namer.getClassName(mo); + String generatedFqn = isNotBlank(javaPkg) ? javaPkg + "." + javaClass : javaClass; + // Key on the package-folded resolution key — NOT the dotted Java FQN. + fqnToClass.put(mo.getName(), generatedFqn); } if (fqnToClass.isEmpty()) return; diff --git a/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/object/javacode/README-flavored-objects.md b/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/object/javacode/README-flavored-objects.md index 578839aa0..269699408 100644 --- a/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/object/javacode/README-flavored-objects.md +++ b/server/java/codegen-base/src/main/java/com/metaobjects/generator/direct/object/javacode/README-flavored-objects.md @@ -18,18 +18,17 @@ same metadata emits any flavor. | `valueObject` | `com.metaobjects.object.value.ValueObject` | Map-backed + extensible; **perf-tuned** — each field's value-holder is bound once and accessors read/write it directly (no per-call keyed lookup). | | _(absent)_ | _(interface)_ | Legacy `JavaCodeWriter` interface emission — unchanged. | -For each concrete flavor, the generator also emits, per object: -- a self-registering `ObjectClassBindingProvider` (one per run, FQN→class) + its - `META-INF/services` registration, so `MetaObject.newInstance()` yields the generated - type for that object's FQN; -- a `Extractor` with `static extract(MetaDataLoader, String)` (typed, - `orThrow`) and `static ExtractionResult<> extractLenient(MetaDataLoader, String)` - (never-throws), both wrapping the runtime `MetaObjectExtractor.extract`. - -The Extractors have a generator of their own as well, `ExtractorCodeGenerator`, wirable as -a separate `` with the same `outputDir` and naming args. When one is in the run -`JavaObjectCodeGenerator` leaves the files to it (`emitExtractors=false`, which the Maven -plugin sets from the generator list; `EmitsObjectExtractors` is the marker it reads). +For each concrete flavor, the generator also emits a self-registering +`ObjectClassBindingProvider` (one per run, FQN→class) + its `META-INF/services` +registration, so `MetaObject.newInstance()` yields the generated type for that object's FQN. + +The `Extractor` classes are written by a generator of their own, +`ExtractorCodeGenerator`, listed as a `` with the same `outputDir`, naming args +and ``. Each has `static extract(MetaDataLoader, String)` (typed, `orThrow`) +and `static ExtractionResult<> extractLenient(MetaDataLoader, String)` (never-throws), +both wrapping the runtime `MetaObjectExtractor.extract`. `JavaObjectCodeGenerator` writes +none: through 8.0 it wrote them for a pom that listed no extractor, and a file no +`` entry declares is what ADR-0034 Amendment 9 rules out. Abstract objects are skipped for both the binding entry and the Extractor (they cannot be `newInstance()`-d — honors the abstract-codegen invariant: no instance artifacts for abstracts). @@ -41,8 +40,8 @@ The legacy direct object-codegen framework — `BaseObjectCodeGenerator`, (byte-unchanged). All new behavior is added by **subclassing + overriding hooks**: - `JavaObjectCodeGenerator extends JavaCodeGenerator` — overrides `createWriter` (selects - the writer by `flavor`) and `execute` (emits the provider + Extractor after the standard - multi-file loop). + the writer by `flavor`) and `execute` (emits the provider after the standard multi-file + loop). - `PojoAwareCodeWriter` / `ValueObjectCodeWriter` `extends JavaCodeWriter` — override `writeObjectHeader` (the `extends ` clause), `writeObjectMethods` (fields + ctor), `writeGetter` / `writeSetter` (concrete bodies); reuse the inherited naming/type hooks @@ -65,7 +64,7 @@ This module's pom ships the sources under `META-INF/metaobjects/reference/java/` | Generator | Copied with it, into the same owned package | |---|---| -| `entity` | `JavaCodeGenerator`, `BaseObjectCodeGenerator`, `BaseObjectCodeWriter`, `JavaCodeWriter`, `PojoAwareCodeWriter`, `ValueObjectCodeWriter`, `ExtractorCodeGenerator` | +| `entity` | `JavaCodeGenerator`, `BaseObjectCodeGenerator`, `BaseObjectCodeWriter`, `JavaCodeWriter`, `PojoAwareCodeWriter`, `ValueObjectCodeWriter` | | `extractor` | `JavaCodeWriter`, `BaseObjectCodeWriter` | Eject rewrites each file's package line and, where one copied class imports another, that @@ -76,9 +75,6 @@ this module, and so what has to keep working for it: - `MultiFileDirectGeneratorBase` and `FileDirectWriter`, which the copies extend; - `GenerationContext`, `GeneratorUtil`, `JavaIdentifiers` and `GeneratedFileWriter`. -An owned `entity` writes its extractors through its own copy of `ExtractorCodeGenerator`, -until an `extractor` generator is wired beside it. - Two rules keep a copy set ejectable, and `EjectedBaseGeneratorsCompileTest` fails when either is broken. A class in a copy set may name another class of this package by its bare name only if that class is in the same set; anything else (the owned `extractor` naming diff --git a/server/java/codegen-base/src/main/java/com/metaobjects/generator/util/GeneratedFileWriter.java b/server/java/codegen-base/src/main/java/com/metaobjects/generator/util/GeneratedFileWriter.java index efef2cd16..8d77cc454 100644 --- a/server/java/codegen-base/src/main/java/com/metaobjects/generator/util/GeneratedFileWriter.java +++ b/server/java/codegen-base/src/main/java/com/metaobjects/generator/util/GeneratedFileWriter.java @@ -46,7 +46,7 @@ * break them silently rather than loudly: * *
            - *
          • {@code DocsMojo}'s API pages — rendered from {@code templates/api/*.mustache}, + *
          • The {@code api-docs} generators' pages — rendered from {@code templates/api/*.mustache}, * which emit no marker and are under no obligation to.
          • *
          • {@code TemplateScopeGenerator} — emits whatever a user's {@code --template-spec} * renders (SQL, markdown, CSV); the content is not ours to require a marker of.
          • diff --git a/server/java/codegen-base/src/test/java/com/metaobjects/generator/direct/object/EjectedBaseGeneratorsCompileTest.java b/server/java/codegen-base/src/test/java/com/metaobjects/generator/direct/object/EjectedBaseGeneratorsCompileTest.java index 3fe0f4d5c..cce3db6f2 100644 --- a/server/java/codegen-base/src/test/java/com/metaobjects/generator/direct/object/EjectedBaseGeneratorsCompileTest.java +++ b/server/java/codegen-base/src/test/java/com/metaobjects/generator/direct/object/EjectedBaseGeneratorsCompileTest.java @@ -45,8 +45,7 @@ public class EjectedBaseGeneratorsCompileTest { "BaseObjectCodeWriter", "javacode.JavaCodeWriter", "javacode.PojoAwareCodeWriter", - "javacode.ValueObjectCodeWriter", - "javacode.ExtractorCodeGenerator"); + "javacode.ValueObjectCodeWriter"); /** What eject copies for {@code extractor}. */ private static final List EXTRACTOR = List.of( 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/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 index 1a2c5ed6b..5e47f4b4e 100644 --- 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 @@ -3,7 +3,6 @@ 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.DocsGenerator import com.metaobjects.generator.FileEmittingGenerator import com.metaobjects.generator.FileEmittingGenerator.EmittedFile import com.metaobjects.generator.GeneratorException @@ -15,24 +14,26 @@ import com.metaobjects.loader.MetaDataLoader * contract): one page per entity, value object, projection, served report and template, an index, * and the agent-facing `AGENT-API.md`. * - * Run by `mvn metaobjects:docs`, not by `mvn metaobjects:generate`: it implements [DocsGenerator], - * which `generate` and `verify` skip. Its files land under `outputDir`, the docs directory, in - * [DocsGenerator.ARG_API_SUBDIR]. The project label, the layout and the model back-link base come - * from the [DocsGenerator] args the goal sets, and where a `@provided` enum is referenced from + * 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(), DocsGenerator { - - override fun surface(): String = "kotlin" +class KotlinApiDocsGenerator : FileEmittingGenerator() { override fun generate(loader: MetaDataLoader): List { - val project = getArg(DocsGenerator.ARG_PROJECT_NAME, "project") - val subDir = getArg(DocsGenerator.ARG_API_SUBDIR, DEFAULT_API_SUBDIR) - val modelBaseUrl = if (hasArg(DocsGenerator.ARG_MODEL_BASE_URL)) getArg(DocsGenerator.ARG_MODEL_BASE_URL) else null - val layout = if (getArg(DocsGenerator.ARG_LAYOUT, "flat").equals("package", ignoreCase = true)) { - DocsPaths.Layout.PACKAGE - } else { - DocsPaths.Layout.FLAT + 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) @@ -55,7 +56,22 @@ class KotlinApiDocsGenerator : FileEmittingGenerator(), DocsGenerator { } 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/ReportingInertTest.kt b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/ReportingInertTest.kt index 71f005255..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 @@ -116,7 +116,7 @@ class ReportingInertTest { * 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.factory() !is com.metaobjects.generator.DocsGenerator } + 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]) 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 2c50b7b0c..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 @@ -277,8 +277,7 @@ public String toString() { private static final List ENTITY_EMIT = List.of( JavaCodeGenerator.class.getName(), BaseObjectCodeGenerator.class.getName(), BaseObjectCodeWriter.class.getName(), JavaCodeWriter.class.getName(), - PojoAwareCodeWriter.class.getName(), ValueObjectCodeWriter.class.getName(), - ExtractorCodeGenerator.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. */ @@ -383,25 +382,22 @@ private static Map buildRegistry() { 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 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 index b330cc178..ef7f2764c 100644 --- 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 @@ -3,7 +3,6 @@ // 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.DocsGenerator; import com.metaobjects.generator.FileEmittingGenerator; import com.metaobjects.generator.GeneratorException; import com.metaobjects.generator.apidocs.GeneratorApplicability; @@ -19,38 +18,47 @@ * SDK-docs contract): one page per entity, value object, projection, served report and template, * an index, and the agent-facing {@code AGENT-API.md}. * - *

            Run by {@code mvn metaobjects:docs}, not by {@code mvn metaobjects:generate}: it implements - * {@link DocsGenerator}, which {@code generate} and {@code verify} skip. Its files land under - * {@code outputDir}, the docs directory, in {@link #ARG_API_SUBDIR}. The project label, the layout - * and the model back-link base come from the {@link DocsGenerator} args the goal sets.

            + *

            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 implements DocsGenerator { +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 - public String surface() { - return "java"; - } - @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 = LAYOUT_PACKAGE.equalsIgnoreCase(getArg(ARG_LAYOUT, "flat")) - ? DocsPaths.Layout.PACKAGE : DocsPaths.Layout.FLAT; + DocsPaths.Layout layout = layoutOf(getArg(ARG_LAYOUT, LAYOUT_FLAT)); JavaApiModel model = new JavaApiModelBuilder(applicability).build(loader, project); JavaApiDocsRenderer renderer = new JavaApiDocsRenderer(); @@ -67,6 +75,15 @@ protected List generate(MetaDataLoader loader) { 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) { 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/ReportingInertTest.java b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/ReportingInertTest.java index f2052cd27..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 @@ -257,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 f48583ebb..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 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 d2d063c25..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 @@ -7,7 +7,6 @@ import com.metaobjects.agentcontext.AgentContextScaffold; import com.metaobjects.generator.Generator; import com.metaobjects.generator.GeneratorBase; -import com.metaobjects.generator.DocsGenerator; import com.metaobjects.generator.ProjectClassLoaderAware; import com.metaobjects.generator.direct.MultiFileDirectGeneratorBase; import com.metaobjects.generator.spring.SpringRenderHelperGenerator; @@ -15,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; @@ -110,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"); @@ -201,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; @@ -296,24 +322,6 @@ protected void warnIfAgentContextStale() { protected abstract void executeGenerators(MetaDataLoader loader, List generatorImpls); - /** - * Whether {@code g} names a {@link DocsGenerator}: a generator that writes documentation, - * which {@code metaobjects:docs} runs and {@code generate} and {@code verify} leave alone. - */ - protected boolean isDocsGenerator(GeneratorParam g, ClassLoader projectClassLoader) { - try { - return DocsGenerator.class.isAssignableFrom(projectClassLoader.loadClass(g.getClassname())); - } catch (ClassNotFoundException e) { - throw new MetaDataException( "Error running generator ["+g.getClassname()+"]: "+e, e ); - } - } - - /** Say that {@code generate} / {@code verify} is skipping a docs generator, and which goal runs it. */ - protected void logSkippedDocsGenerator(String goal, String classname) { - getLog().info(goal + " > Skipping docs generator " + classname - + ": it writes documentation, and `mvn metaobjects:docs` runs it."); - } - protected MetaDataLoader createLoader(ClassLoader projectClassLoader) { LoaderConfigurable configurable = null; 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 35927e969..806a14406 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,165 +1,33 @@ package com.metaobjects.mojo; -import com.metaobjects.generator.DocsGenerator; -import com.metaobjects.generator.Generator; -import com.metaobjects.generator.GeneratorBase; -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.util.HashMap; -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. It reads the configured {@code } - * for one thing: a {@link DocsGenerator} it names for a surface, which it runs in place of the - * packaged one. Every other configured generator is left alone. + * {@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. * - *

              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: each surface is a {@link DocsGenerator} ({@code api-docs}) that - * writes its pages under {@link #outputDirectory}. When the {@code } list this goal - * reads (the plugin-level {@code }, or the docs execution's own; never another - * execution's) holds a docs generator for a surface, the goal runs that one, so a project that - * ejected {@code api-docs} runs its own copy. 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; - - /** Doc-page layout: {@code flat} (one file per unit) or {@code package} (foldered). */ - @Parameter(property = "metaobjects.docs.layout", defaultValue = "flat") - private String layout; +@Mojo(name = "docs", threadSafe = true) +public class DocsMojo extends AbstractMojo { - /** Optional base URL for federated model docs; when unset, model links are relative. */ - @Parameter(property = "metaobjects.docs.modelBaseUrl") - private String modelBaseUrl; + /** 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\""; - @Override - public void execute() throws MojoExecutionException, MojoFailureException { - // Inherit loader construction + the executeGenerators hook from the base. - super.execute(); - } + /** 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 - 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/"); - } - - for (String raw : langs) { - String lang = raw.trim().toLowerCase(Locale.ROOT); - if (lang.isEmpty()) { - continue; - } - String subDir = (apiSubDir != null && !apiSubDir.isEmpty()) ? apiSubDir : ("api/" + lang); - runSurface(lang, subDir, loader, generatorImpls); - } - } - - /** - * Run the docs generator for one surface: the one the project's {@code } list - * names for it, when it names one (an owned copy of {@code api-docs}), else the packaged one. - * The goal is wiring only: every page, name and path comes from the generator. - */ - private void runSurface(String lang, String subDir, MetaDataLoader loader, List generatorImpls) { - DocsGenerator docs = null; - Map args = null; - for (int i = 0; i < generatorImpls.size(); i++) { - if (generatorImpls.get(i) instanceof DocsGenerator - && lang.equals(((DocsGenerator) generatorImpls.get(i)).surface())) { - docs = (DocsGenerator) generatorImpls.get(i); - args = mergeAndOverwriteArgs(getGenerators().get(i)); - break; - } - } - boolean owned = docs != null; - if (!owned) { - docs = packaged(lang); - args = new HashMap<>(); - if (getGlobals() != null) args.putAll(getGlobals()); - docs.setFilters(List.of()); - } - - args.put(GeneratorBase.ARG_OUTPUTDIR, outputDirectory.getAbsolutePath()); - args.put(DocsGenerator.ARG_PROJECT_NAME, project != null ? project.getArtifactId() : "project"); - args.put(DocsGenerator.ARG_API_SUBDIR, subDir); - args.put(DocsGenerator.ARG_LAYOUT, "package".equalsIgnoreCase(layout) ? "package" : "flat"); - if (modelBaseUrl != null && !modelBaseUrl.isEmpty()) { - args.put(DocsGenerator.ARG_MODEL_BASE_URL, modelBaseUrl); - } - docs.setArgs(args); - docs.execute(loader); - getLog().info("metaobjects:docs — ran " + (owned ? "your" : "the packaged") + " " + lang - + " api-docs generator " + docs.getClass().getName() + " into " - + outputDirectory.toPath().resolve(subDir)); - } - - /** The packaged {@code api-docs} generator for a surface. */ - private static DocsGenerator packaged(String lang) { - switch (lang) { - case LANG_JAVA: - return new com.metaobjects.generator.apidocs.JavaApiDocsGenerator(); - case LANG_KOTLIN: - return new com.metaobjects.generator.kotlin.apidocs.KotlinApiDocsGenerator(); - default: - throw new IllegalStateException( - "metaobjects:docs — unknown language '" + lang + "' (supported: java, kotlin)"); - } + 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 0676ce341..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 @@ -1,6 +1,5 @@ package com.metaobjects.mojo; -import com.metaobjects.generator.DocsGenerator; import com.metaobjects.generator.GeneratorRegistry; import com.metaobjects.generator.kotlin.GeneratorInfo; import com.metaobjects.generator.kotlin.GeneratorRegistryKt; @@ -66,9 +65,6 @@ public static final class Entry { /** Wiring the generic eject report cannot know, with {@code {owned}} standing for the * owned copy's class name, or {@code null} (see {@link #wiringNote}). */ public final String note; - /** Whether this generator is a {@link DocsGenerator}: {@code metaobjects:docs} runs it - * and {@code metaobjects:generate} skips it. */ - public final boolean docs; /** 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; @@ -100,8 +96,7 @@ public Entry(Port port, String stableName, String classname, String description, this.description = description; this.resourcePath = resourcePath; this.runtime = List.copyOf(runtime); - this.docs = isDocsGenerator(classname); - this.note = docs ? stableName + DOCS_NOTE.replace("{surface}", port.id) : note; + this.note = API_DOCS.equals(stableName) ? API_DOCS_NOTE : note; this.with = List.copyOf(with); } @@ -132,13 +127,14 @@ public String wiringNote(String basePackage) { /** * The package this generator's copies are written in, given the package eject was asked - * for. A {@link DocsGenerator}'s copies take a subpackage of their port: the Java and the + * 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 docs ? basePackage + "." + DOCS_SUBPACKAGE + "." + port.id : basePackage; + return API_DOCS.equals(stableName) + ? basePackage + "." + API_DOCS_SUBPACKAGE + "." + port.id : basePackage; } /** The owned copy's class name: the {@code } value to wire. */ @@ -152,31 +148,19 @@ public String simpleName() { } } - /** The subpackage of the owned package a {@link DocsGenerator}'s copies are written under, - * above the one named for its port (see {@link Entry#ownedPackage}). */ - public static final String DOCS_SUBPACKAGE = "apidocs"; - - /** What eject prints for a {@link DocsGenerator}, on either port, after its stable name: no pom - * lists one until its owner adds it. {@code {surface}} is the port it documents. */ - private static final String DOCS_NOTE = - ": `mvn metaobjects:docs` runs this generator, not `metaobjects:generate`, which " - + "skips it. List it with {owned} in the the docs " - + "goal reads, which is the plugin-level or the docs execution's own, " - + "and the docs goal runs your copy for the {surface} surface in place of the packaged " - + "one. A list inside another execution's , the generate " - + "execution's for one, is not read by the docs goal."; - - /** Whether the registered generator class {@code classname} is a {@link DocsGenerator}. - * Every registered class is on this plugin's classpath. */ - private static boolean isDocsGenerator(String classname) { - if (classname == null) return false; - try { - return DocsGenerator.class.isAssignableFrom( - Class.forName(classname, false, EjectSupport.class.getClassLoader())); - } catch (ClassNotFoundException e) { - throw new IllegalStateException("registered generator class is not on the classpath: " + classname, e); - } - } + /** 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) { diff --git a/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/MetaDataGeneratorMojo.java b/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/MetaDataGeneratorMojo.java index 6d1f7cf2b..a2e75ae49 100644 --- a/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/MetaDataGeneratorMojo.java +++ b/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/MetaDataGeneratorMojo.java @@ -1,6 +1,5 @@ package com.metaobjects.mojo; -import com.metaobjects.generator.DocsGenerator; import com.metaobjects.generator.Generator; import com.metaobjects.generator.GeneratorBase; import com.metaobjects.generator.util.GeneratedFileWriter; @@ -44,10 +43,6 @@ protected void executeGenerators(MetaDataLoader loader, List generato // a green build. Opening the run here is what makes the check reach a real build. try (GeneratedFileWriter.Run run = GeneratedFileWriter.beginRun()) { for( Generator gen : generatorImpls ) { - if ( gen instanceof DocsGenerator ) { - logSkippedDocsGenerator("MetaData Mojo", gen.getClass().getName()); - continue; - } getLog().info("MetaData Mojo > Executing Generator: " + gen.getClass().getName() ); run.attributeTo( gen.getClass().getSimpleName() ); gen.execute( loader ); 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 e6b3172df..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 @@ -1,6 +1,5 @@ package com.metaobjects.mojo; -import com.metaobjects.generator.DocsGenerator; import com.metaobjects.generator.Generator; import com.metaobjects.generator.GeneratorBase; import com.metaobjects.generator.verify.TemplateVerify; @@ -144,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"); @@ -336,8 +336,6 @@ private void verifyCodegen() throws MojoExecutionException, MojoFailureException if (getGenerators() != null) { for (GeneratorParam g : getGenerators()) { - // A docs generator writes pages, not code: nothing here to drift-check. - if (isDocsGenerator(g, projectClassLoader)) continue; Map merged = mergeAndOverwriteArgs(g); String realDir = merged.get(ARG_OUTPUT_DIR); if (realDir == null) { @@ -363,10 +361,6 @@ private void verifyCodegen() throws MojoExecutionException, MojoFailureException List generatorImpls = buildGenerators(projectClassLoader, overrides); try { for (Generator gen : generatorImpls) { - if (gen instanceof DocsGenerator) { - logSkippedDocsGenerator("MetaData Verify Mojo", gen.getClass().getName()); - continue; - } getLog().info("MetaData Verify Mojo > Regenerating (temp): " + gen.getClass().getName()); gen.execute(loader); } 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 index bf2e757bd..e92d009d8 100644 --- 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 @@ -1,6 +1,9 @@ package com.metaobjects.mojo; -import com.metaobjects.generator.DocsGenerator; +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; @@ -35,6 +38,7 @@ 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; @@ -48,15 +52,15 @@ import static org.junit.Assert.assertTrue; /** - * A project that documents both surfaces ({@code metaobjects:docs} with {@code language=java,kotlin}) - * can own both {@code api-docs} generators in ONE {@code codegen/} module. The two ports name + * 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 the docs generator of that surface.

              + * compile, as a generator that writes that language's pages.

              */ public class ApiDocsEjectBothPortsTest { @@ -196,10 +200,31 @@ private KotlinCompilation.Result compileKotlin(Path sourceRoot) throws IOExcepti return result; } - private static void assertDocsGeneratorOf(String surface, String classname, ClassLoader loader) throws Exception { - Object generator = Class.forName(classname, true, loader).getDeclaredConstructor().newInstance(); - assertTrue(classname + " is not a DocsGenerator", generator instanceof DocsGenerator); - assertEquals(classname, surface, ((DocsGenerator) generator).surface()); + 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, @@ -290,8 +315,8 @@ public void bothPortsEjectedIntoOneModuleCompileAndDeclareNoClassTwice() throws URL[] javaUrls = {javaClasses.toUri().toURL(), codegen.resolve("src/main/resources/").toUri().toURL()}; try (URLClassLoader java = new URLClassLoader(javaUrls, getClass().getClassLoader())) { - assertDocsGeneratorOf("java", ownedJava, java); + assertWritesTheApiReferenceOf("java", ownedJava, java); } - assertDocsGeneratorOf("kotlin", ownedKotlin, kotlin.getClassLoader()); + 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). + * + *

              The rule has two halves, and each port is held to both. A pom whose {@code } + * is empty or absent writes zero files: no default generator set, no documentation. And a + * pom that lists one generator gets that generator's files and no other's: the Java + * {@code entity} wrote a {@code Extractor} beside each class through 8.0, and the api + * reference was written by a goal of its own.

              + */ +public class DeclaresNothingWritesNothingTest { + + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + + private Path module; + + @Before + public void aModuleOfItsOwn() throws Exception { + module = tmp.newFolder("module").toPath(); + } + + /** A generate goal in a module of its own, so every file the run writes is under it. */ + private MetaDataGeneratorMojo generateGoal(List generators) { + MetaDataGeneratorMojo mojo = new MetaDataGeneratorMojo(); + MavenProject project = Mockito.mock(MavenProject.class); + Mockito.when(project.getBasedir()).thenReturn(module.toFile()); + mojo.project = project; + mojo.setLoader(LoaderParam.builder("declares-nothing-test") + .withClassname("com.metaobjects.loader.MetaDataLoader") + .withSourceDir(new File("src/test/resources").getAbsolutePath()) + .withSource("mojo/verify-test-metadata.json") + .build()); + mojo.setGenerators(generators); + mojo.setGlobals(Collections.emptyMap()); + return mojo; + } + + private List written() throws Exception { + try (Stream s = Files.walk(module)) { + return s.filter(Files::isRegularFile) + .map(p -> module.relativize(p).toString().replace(File.separatorChar, '/')) + .sorted().collect(Collectors.toList()); + } + } + + @Test + public void aPomWithNoGeneratorsElementWritesZeroFiles() throws Exception { + generateGoal(null).execute(); + assertEquals(List.of(), written()); + } + + @Test + public void aPomWithAnEmptyGeneratorsListWritesZeroFiles() throws Exception { + generateGoal(new ArrayList<>()).execute(); + assertEquals(List.of(), written()); + } + + @Test + public void java_aPomThatListsOnlyEntityGetsItsClassesAndNoExtractorOrApiReference() throws Exception { + generateGoal(new ArrayList<>(List.of(GeneratorParam.builder(JavaObjectCodeGenerator.class.getName()) + .withArg("outputDir", "generated") + .withArg("type", "class") + .withArg("flavor", "pojoAware") + .build()))).execute(); + + assertEquals(List.of( + "generated/META-INF/services/com.metaobjects.registry.ObjectClassBindingProvider", + "generated/com/metaobjects/generated/GeneratedObjectClassBindingProvider.java", + "generated/verify/demo/Gadget.java", + "generated/verify/demo/Widget.java"), written()); + } + + @Test + public void kotlin_aPomThatListsOnlyEntityGetsItsFilesAndNoApiReference() throws Exception { + generateGoal(new ArrayList<>(List.of(GeneratorParam.builder(KotlinEntityGenerator.class.getName()) + .withArg("outputDir", "generated") + .build()))).execute(); + + List written = written(); + assertFalse("the listed generator wrote nothing", written.isEmpty()); + assertTrue("a file the entry did not declare: " + written, + written.stream().allMatch(f -> f.startsWith("generated/verify/demo/") && f.endsWith(".kt"))); + } +} diff --git a/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/DocsGeneratorGoalsTest.java b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/DocsGeneratorGoalsTest.java deleted file mode 100644 index 573c446a3..000000000 --- a/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/DocsGeneratorGoalsTest.java +++ /dev/null @@ -1,128 +0,0 @@ -package com.metaobjects.mojo; - -import com.metaobjects.generator.kotlin.KotlinEntityGenerator; -import com.metaobjects.mojo.test.OwnedDocsGenerator; -import org.apache.maven.plugin.testing.MojoRule; -import org.junit.Before; -import org.junit.Rule; -import org.junit.Test; - -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; - -/** - * A {@link com.metaobjects.generator.DocsGenerator} writes documentation, not code: the docs goal - * runs the one the project lists for a surface in place of the packaged one, and the generate and - * verify goals leave it alone. - */ -public class DocsGeneratorGoalsTest { - - private static final String OWNED = OwnedDocsGenerator.class.getName(); - private static final String ENTITY = KotlinEntityGenerator.class.getName(); - - @Rule - public MojoRule rule = new MojoRule(); - - private Path work; - - @Before - public void scratch() throws Exception { - work = Files.createTempDirectory("docs-goals"); - } - - private void configure(AbstractMetaDataMojo mojo, GeneratorParam... generators) { - LoaderParam loader = LoaderParam.builder("docs-goals-test") - .withClassname("com.metaobjects.loader.MetaDataLoader") - .withSourceDir("./src/test/resources") - .withSource("mojo/verify-test-metadata.json") - .build(); - mojo.setLoader(loader); - mojo.setGenerators(new ArrayList<>(List.of(generators))); - mojo.setGlobals(Collections.emptyMap()); - } - - private GeneratorParam generator(String classname, Path outputDir) { - GeneratorParam.Builder b = GeneratorParam.builder(classname); - if (outputDir != null) b.withArg("outputDir", outputDir.toString()); - return b.build(); - } - - private static List files(Path root) throws Exception { - if (!Files.isDirectory(root)) return List.of(); - try (Stream s = Files.walk(root)) { - return s.filter(Files::isRegularFile).map(p -> root.relativize(p).toString()).sorted() - .collect(Collectors.toList()); - } - } - - @Test - public void theDocsGoalRunsTheOwnedGeneratorForItsSurfaceInPlaceOfThePackagedOne() throws Exception { - DocsMojo docs = new DocsMojo(); - configure(docs, generator(OWNED, null)); - Path docsDir = work.resolve("docs"); - rule.setVariableValueToObject(docs, "outputDirectory", docsDir.toFile()); - rule.setVariableValueToObject(docs, "language", "java"); - docs.execute(); - - // The owned generator's page, in the surface subdirectory the goal named, and none of the - // packaged reference's. - assertEquals(List.of("api/java/OWNED.md"), files(docsDir)); - assertEquals("owned docs for project\n", Files.readString(docsDir.resolve("api/java/OWNED.md"))); - } - - @Test - public void aSurfaceNoGeneratorOwnsStillGetsThePackagedReference() throws Exception { - DocsMojo docs = new DocsMojo(); - configure(docs, generator(OWNED, null)); - Path docsDir = work.resolve("docs"); - rule.setVariableValueToObject(docs, "outputDirectory", docsDir.toFile()); - rule.setVariableValueToObject(docs, "language", "java,kotlin"); - docs.execute(); - - List written = files(docsDir); - assertTrue(written.toString(), written.contains("api/java/OWNED.md")); - assertTrue(written.toString(), written.contains("api/kotlin/README.md")); - assertTrue(written.toString(), written.contains("api/kotlin/AGENT-API.md")); - assertFalse(written.toString(), written.contains("api/java/README.md")); - } - - @Test - public void theGenerateGoalSkipsADocsGeneratorAndRunsTheRest() throws Exception { - Path out = work.resolve("generated"); - MetaDataGeneratorMojo gen = new MetaDataGeneratorMojo(); - configure(gen, generator(ENTITY, out), generator(OWNED, out)); - gen.execute(); - - List written = files(out); - assertTrue("the code generator ran: " + written, written.stream().anyMatch(f -> f.endsWith(".kt"))); - assertFalse("the docs generator was skipped: " + written, written.stream().anyMatch(f -> f.endsWith("OWNED.md"))); - } - - @Test - public void theVerifyGoalSkipsADocsGeneratorEvenWithNoOutputDirOrCommittedPages() throws Exception { - Path committed = work.resolve("committed"); - MetaDataGeneratorMojo gen = new MetaDataGeneratorMojo(); - configure(gen, generator(ENTITY, committed)); - gen.execute(); - - // A docs generator with no outputDir would fail verify as unverifiable, and one with an - // outputDir would read as a page the repository lacks, if verify ran it. - MetaDataVerifyMojo verify = new MetaDataVerifyMojo(); - configure(verify, generator(ENTITY, committed), generator(OWNED, null)); - verify.execute(); - - MetaDataVerifyMojo withDir = new MetaDataVerifyMojo(); - configure(withDir, generator(ENTITY, committed), generator(OWNED, work.resolve("docs-not-committed"))); - withDir.execute(); - } -} diff --git a/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/DocsMojoTest.java b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/DocsMojoTest.java deleted file mode 100644 index 889d35b82..000000000 --- a/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/DocsMojoTest.java +++ /dev/null @@ -1,106 +0,0 @@ -package com.metaobjects.mojo; - -import org.apache.maven.plugin.testing.MojoRule; -import org.codehaus.plexus.PlexusTestCase; -import org.junit.Rule; -import org.junit.Test; - -import java.io.File; -import java.nio.file.Files; -import java.nio.file.Path; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertTrue; - -/** - * Tests the {@code metaobjects:docs} goal — that it loads metadata from the - * {@code } config, builds the {@code JavaApiModel}, renders it via - * {@code JavaApiDocsRenderer}, and writes the Java api surface into the configured - * {@code } with a model cross-link on each unit page (the SP-1 - * {@code apiSurfaces} {@code api/java} surface). - */ -public class DocsMojoTest { - - @Rule - public MojoRule rule = new MojoRule(); - - @Test - public void emitsTheJavaApiSurface() throws Exception { - File pom = new File(PlexusTestCase.getBasedir(), "src/test/resources/mojo/pom-docs.xml"); - - DocsMojo mojo = (DocsMojo) rule.lookupMojo("docs", pom); - assertNotNull(mojo); - mojo.execute(); - - // (target/docs-mojo-test/docs) / (api/java). - Path apiBase = new File(PlexusTestCase.getBasedir(), - "target/docs-mojo-test/docs/api/java").toPath(); - - // The concrete Basket entity (package simple::fruitbasket → simple/fruitbasket) - // produces an api page under its package folder for the package layout. - Path basketPage = apiBase.resolve("simple/fruitbasket/Basket.md"); - assertTrue("expected entity api page at " + basketPage, Files.exists(basketPage)); - assertTrue("expected index README.md", Files.exists(apiBase.resolve("README.md"))); - assertTrue("expected condensed AGENT-API.md", Files.exists(apiBase.resolve("AGENT-API.md"))); - - String page = Files.readString(basketPage); - assertTrue("unit page emits the model/metadata cross-link", - page.contains("**Model / metadata:**")); - assertTrue("unit page emits a java code fence", page.contains("```java")); - } - - @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. - // - // The existing tests could not see it: each runs the mojo exactly ONCE. Only a - // second run distinguishes "writes" from "keeps writing". - File pom = new File(PlexusTestCase.getBasedir(), "src/test/resources/mojo/pom-docs.xml"); - Path apiBase = new File(PlexusTestCase.getBasedir(), - "target/docs-mojo-test/docs/api/java").toPath(); - Path basketPage = apiBase.resolve("simple/fruitbasket/Basket.md"); - - ((DocsMojo) rule.lookupMojo("docs", pom)).execute(); - assertTrue("first run should emit the page", Files.exists(basketPage)); - - // Stand in for "the model changed": clobber the page, then regenerate. A second - // run that refuses leaves the sentinel in place. - Files.writeString(basketPage, "STALE SENTINEL\n"); - ((DocsMojo) rule.lookupMojo("docs", pom)).execute(); - - String after = Files.readString(basketPage); - 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:**")); - } - - @Test - public void emitsTheKotlinApiSurface() throws Exception { - File pom = new File(PlexusTestCase.getBasedir(), "src/test/resources/mojo/pom-docs-kotlin.xml"); - - DocsMojo mojo = (DocsMojo) rule.lookupMojo("docs", pom); - assertNotNull(mojo); - mojo.execute(); - - // kotlin with no explicit → api/kotlin. - Path apiBase = new File(PlexusTestCase.getBasedir(), - "target/docs-mojo-test-kotlin/docs/api/kotlin").toPath(); - - Path basketPage = apiBase.resolve("simple/fruitbasket/Basket.md"); - assertTrue("expected entity api page at " + basketPage, Files.exists(basketPage)); - assertTrue("expected index README.md", Files.exists(apiBase.resolve("README.md"))); - assertTrue("expected condensed AGENT-API.md", Files.exists(apiBase.resolve("AGENT-API.md"))); - - String page = Files.readString(basketPage); - assertTrue("unit page emits the model/metadata cross-link", - page.contains("**Model / metadata:**")); - assertTrue("unit page emits a kotlin code fence", page.contains("```kotlin")); - } -} diff --git a/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/EjectSupportTest.java b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/EjectSupportTest.java index be6dada09..6e52b741f 100644 --- a/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/EjectSupportTest.java +++ b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/EjectSupportTest.java @@ -69,8 +69,8 @@ public void theJavaExtractorAndApiDocsOnBothPortsCarryAWiringNoteAndItNamesTheOw String ownedPackage = apiDocs ? "com.acme.codegen.apidocs." + e.port.id : "com.acme.codegen"; assertTrue(note, note.contains("" + ownedPackage + "." + e.simpleName() + "")); assertFalse(note, note.contains("{owned}")); - // No pom lists a docs generator until its owner adds it, on either port. - if (apiDocs) assertTrue(note, note.contains("the " + e.port.id + " surface")); + // The goal that runs it is the one that runs every generator, on either port. + if (apiDocs) assertTrue(note, note.contains("`mvn metaobjects:generate` runs this generator")); } assertEquals("the Java extractor, and api-docs on each port", 3, noted); } diff --git a/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/EmitExtractorsDerivationTest.java b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/EmitExtractorsDerivationTest.java deleted file mode 100644 index da81488c8..000000000 --- a/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/EmitExtractorsDerivationTest.java +++ /dev/null @@ -1,103 +0,0 @@ -package com.metaobjects.mojo; - -import com.metaobjects.generator.EmitsObjectExtractors; -import org.junit.Before; -import org.junit.Test; - -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; - -/** - * The mojo tells the Java {@code entity} generator when a separate {@code extractor} - * generator is in the run, so each {@code Extractor} has one emitter. - * - *

              {@link UseNamesDerivationTest} holds the same seam for the names artifact, and for the - * same reason: each generator's own tests construct it directly and never reach - * {@link AbstractMetaDataMojo#buildGenerators}, so the derivation could be dropped there - * with nothing failing. What would fail is an adopter's build, on the first edit to an owned - * extractor, as an output path collision with the copy {@code entity} still wrote.

              - */ -public class EmitExtractorsDerivationTest { - - /** - * Declares that it writes the extractors as a generator of its own. The arg recorder is - * the sibling names test's shared {@code RecordingGenerator} — same package, same seam, - * one recorder rather than a copy that can drift from the other. - */ - public static class RecordingExtractorGenerator extends UseNamesDerivationTest.RecordingGenerator - implements EmitsObjectExtractors { } - - private MetaDataGeneratorMojo mojo; - - @Before - public void setUp() { - UseNamesDerivationTest.RecordingGenerator.SEEN.clear(); - mojo = new MetaDataGeneratorMojo(); - mojo.setLoader(new LoaderParam()); - } - - private static GeneratorParam param(Class impl, Map args) { - GeneratorParam p = new GeneratorParam(); - p.setClassname(impl.getName()); - if (args != null) p.setArgs(args); - return p; - } - - private List> run(GeneratorParam... params) { - mojo.setGenerators(List.of(params)); - mojo.buildGenerators(getClass().getClassLoader(), null); - return UseNamesDerivationTest.RecordingGenerator.SEEN; - } - - @Test - public void a_separate_extractor_in_the_run_tells_every_generator_not_to_write_them() { - List> seen = run( - param(UseNamesDerivationTest.RecordingGenerator.class, null), - param(RecordingExtractorGenerator.class, null)); - - assertEquals(2, seen.size()); - for (Map args : seen) { - assertEquals("false", args.get(EmitsObjectExtractors.ARG_EMIT_EXTRACTORS)); - } - } - - @Test - public void a_run_without_one_leaves_the_arg_unset_so_entity_writes_them_as_before() { - List> seen = run(param(UseNamesDerivationTest.RecordingGenerator.class, null)); - - assertEquals(1, seen.size()); - assertFalse(seen.get(0).containsKey(EmitsObjectExtractors.ARG_EMIT_EXTRACTORS)); - } - - /** - * One emitter per file is not a preference a pom can pin the other way. A {@code true} - * here used to win, which put {@code entity} back on the paths the separate extractor - * writes: the same bytes until the owned extractor is edited, then an output path - * collision. - */ - @Test - public void an_explicit_true_in_the_pom_does_not_bring_back_a_second_emitter() { - Map pinned = new HashMap<>(); - pinned.put(EmitsObjectExtractors.ARG_EMIT_EXTRACTORS, "true"); - - List> seen = run( - param(UseNamesDerivationTest.RecordingGenerator.class, pinned), - param(RecordingExtractorGenerator.class, null)); - - assertEquals("false", seen.get(0).get(EmitsObjectExtractors.ARG_EMIT_EXTRACTORS)); - } - - @Test - public void an_explicit_false_with_no_separate_extractor_is_passed_through() { - Map pinned = new HashMap<>(); - pinned.put(EmitsObjectExtractors.ARG_EMIT_EXTRACTORS, "false"); - - List> seen = run(param(UseNamesDerivationTest.RecordingGenerator.class, pinned)); - - assertEquals("false", seen.get(0).get(EmitsObjectExtractors.ARG_EMIT_EXTRACTORS)); - } -} diff --git a/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/EntityExtractorEjectRoundTripTest.java b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/EntityExtractorEjectRoundTripTest.java index ab5ced60a..d54408942 100644 --- a/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/EntityExtractorEjectRoundTripTest.java +++ b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/EntityExtractorEjectRoundTripTest.java @@ -1,6 +1,5 @@ package com.metaobjects.mojo; -import com.metaobjects.generator.EmitsObjectExtractors; import com.metaobjects.generator.Generator; import com.metaobjects.generator.direct.object.javacode.ExtractorCodeGenerator; import com.metaobjects.generator.direct.object.javacode.JavaObjectCodeGenerator; @@ -38,6 +37,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.TreeMap; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -225,7 +225,7 @@ public void anUnchangedEjectedEntityWritesWhatThePackagedOneWritesInEveryFlavor( } @Test - public void anUnchangedEjectedExtractorWritesWhatThePackagedOneAndEntityWrite() throws Exception { + public void anUnchangedEjectedExtractorWritesWhatThePackagedOneWritesAndEntityWritesNone() throws Exception { ClassLoader classes = compileOwned(ejectBoth(tempFolder.newFolder("adopter").toPath())); Map packaged = generate(new ExtractorCodeGenerator(), Map.of()); @@ -233,10 +233,13 @@ public void anUnchangedEjectedExtractorWritesWhatThePackagedOneAndEntityWrite() assertTrue("the corpus generated only " + packaged.size() + " extractors", packaged.size() >= 17); assertSameTree("extractor", packaged, copy); - // And they are the files entity writes when no separate extractor is in the run. - Map fromEntity = new TreeMap<>(generate(new JavaObjectCodeGenerator(), POJO)); - fromEntity.keySet().removeIf(path -> !path.endsWith("Extractor.java")); - assertSameTree("extractor, against entity's own", fromEntity, copy); + // Only the extractor generator writes them: entity, packaged or owned, writes none. + for (Generator entity : List.of(new JavaObjectCodeGenerator(), owned(classes, OWNED_ENTITY))) { + Map fromEntity = new TreeMap<>(generate(entity, POJO)); + fromEntity.keySet().removeIf(path -> !path.endsWith("Extractor.java")); + assertEquals(entity.getClass().getName() + " wrote extractors no entry declared", + Set.of(), fromEntity.keySet()); + } } // ------------------------------------------------------------------------------------ @@ -255,9 +258,7 @@ public void anEditToEachOwnedCopyShowsInItsOutput() throws Exception { Map entity = generate(owned(classes, OWNED_ENTITY), POJO); assertTrue(entity.get("com/metaobjects/generated/GeneratedObjectClassBindingProvider.java") .contains("GENERATED (owned entity copy)")); - // The owned entity writes its extractors through the owned extractor copy beside it, so an - // edit there reaches the files entity writes too, with no extractor generator in the run. - assertTrue(entity.get("fitness/ProgramExtractor.java").contains("owned extractor copy")); + assertFalse("entity writes no extractor", entity.containsKey("fitness/ProgramExtractor.java")); Map extractor = generate(owned(classes, OWNED_EXTRACTOR), Map.of()); assertTrue(extractor.get("fitness/ProgramExtractor.java").contains("GENERATED (owned extractor copy)")); @@ -311,9 +312,7 @@ public void theOwnedExtractorNamesItsClassesThroughTheOwnedWriter() throws Excep "return GeneratorUtil.toCamelCase(name, true) + \"Row\";\n }\n\n @Override\n protected void writeGetter"); ClassLoader classes = compileOwned(ownedDir); - Map entity = new HashMap<>(POJO); - entity.put(EmitsObjectExtractors.ARG_EMIT_EXTRACTORS, "false"); - String program = generate(owned(classes, OWNED_ENTITY), entity).get("fitness/Program.java"); + String program = generate(owned(classes, OWNED_ENTITY), POJO).get("fitness/Program.java"); assertTrue(program, program.contains("public class ProgramRow extends ")); Map extractors = generate(owned(classes, OWNED_EXTRACTOR), Map.of()); @@ -374,18 +373,16 @@ public void theListingReportsBothAsEjectableAndMarksTheirOwnedCopies() throws Ex String edited = list(projectDir); assertTrue(row(edited, "extractor"), row(edited, "extractor").endsWith("[owned — DIFFERS: 1 behind, 1 of your own]")); - // entity copies the extractor too, so an edit to it shows on entity's row as well. - assertTrue(row(edited, "entity"), - row(edited, "entity").endsWith("[owned — DIFFERS: 1 behind, 1 of your own]")); + // entity copies no extractor, so its row does not move. + assertTrue(row(edited, "entity"), row(edited, "entity").endsWith("[owned — identical]")); // A writer is part of the generator it was copied with: an edit to one only entity - // has shows on entity's row, on top of the extractor edit it already carries, and leaves - // the extractor's count where it was. + // has shows on entity's row and leaves the extractor's count where it was. edit(ownedDir.resolve("PojoAwareCodeWriter.java"), "println(true, \"super(mo);\");", "println(true, \"super(mo); // mine\");"); String writerEdited = list(projectDir); assertTrue(row(writerEdited, "entity"), - row(writerEdited, "entity").endsWith("[owned — DIFFERS: 2 behind, 2 of your own]")); + row(writerEdited, "entity").endsWith("[owned — DIFFERS: 1 behind, 1 of your own]")); assertTrue(row(writerEdited, "extractor"), row(writerEdited, "extractor").endsWith("[owned — DIFFERS: 1 behind, 1 of your own]")); } @@ -401,7 +398,7 @@ public void ejectingTheExtractorSaysHowToWireAGeneratorThePomMayNotNameYet() thr String out = logged(log); assertTrue(out, out.contains("" + OWNED_EXTRACTOR + "")); - assertTrue(out, out.contains("`entity` then leaves those files to it")); + assertTrue(out, out.contains("only this generator writes the Extractor classes")); } @Test @@ -467,7 +464,7 @@ public void theDriftCheckRunsTheOwnedCopiesAndReportsWhatTheyNoLongerProduce() t Path committed = generatedInSync(classes); - // One emitter per file: with the extractor in , entity left them to it. + // The extractors come from the extractor entry, the provider from the entity entry. assertTrue(Files.readString(committed.resolve("fitness/ProgramExtractor.java")) .contains("GENERATED (owned extractor copy)")); assertTrue(Files.readString(committed.resolve( diff --git a/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/RemovedDocsGoalTest.java b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/RemovedDocsGoalTest.java new file mode 100644 index 000000000..8f2cbbd9e --- /dev/null +++ b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/RemovedDocsGoalTest.java @@ -0,0 +1,76 @@ +package com.metaobjects.mojo; + +import org.apache.maven.plugin.Mojo; +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 java.io.File; +import java.nio.file.Files; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.fail; + +/** + * An 8.0 pom is never run quietly into a build with no documentation (ADR-0034 Amendment 9). + * The removed {@code docs} goal says what replaced it, and the goals that remain refuse the + * {@code docs} goal's configuration by name, each with the sentence the amendment fixes. + */ +public class RemovedDocsGoalTest { + + private static final String MIGRATION = + "See docs/features/migrations/docs-are-generators.md, section \"Java and Kotlin\"."; + + @Rule + public MojoRule rule = new MojoRule(); + + private static File pom() { + return new File(PlexusTestCase.getBasedir(), "src/test/resources/mojo/pom-docs-8-0.xml"); + } + + private static String refusalOf(Mojo mojo) throws Exception { + try { + mojo.execute(); + } catch (MojoFailureException e) { + return e.getMessage(); + } + fail(mojo.getClass().getSimpleName() + " ran an 8.0 pom"); + return null; + } + + @Test + public void theDocsGoalSaysItWasRemovedAndWhatWritesDocumentationNow() throws Exception { + assertEquals( + "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 " + + ". " + MIGRATION, + // Built directly: Maven hands a goal only the elements it declares, + // and this one declares none. The test harness's configurator refuses the rest. + refusalOf(new DocsMojo())); + } + + @Test + public void theGenerateGoalRefusesTheDocsGoalsConfigurationAndWritesNothing() throws Exception { + assertEquals( + "metaobjects:generate: found 1.0 docs configuration (, 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 " + + ". " + MIGRATION, + refusalOf(rule.lookupMojo("generate", pom()))); + // Refused before the list ran: the pom's one code generator wrote nothing either. + assertFalse(Files.exists(new File(PlexusTestCase.getBasedir(), "target/docs-8-0-test").toPath())); + } + + @Test + public void theVerifyGoalRefusesItToo() throws Exception { + assertEquals( + "metaobjects:verify: found 1.0 docs configuration (, 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 " + + ". " + MIGRATION, + refusalOf(rule.lookupMojo("verify", pom()))); + } +} diff --git a/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/test/OwnedDocsGenerator.java b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/test/OwnedDocsGenerator.java deleted file mode 100644 index f13a9e241..000000000 --- a/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/test/OwnedDocsGenerator.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.metaobjects.mojo.test; - -import com.metaobjects.generator.DocsGenerator; -import com.metaobjects.generator.FileEmittingGenerator; -import com.metaobjects.loader.MetaDataLoader; - -import java.util.List; - -/** A docs generator of the adopter's own, for the goals that run or skip one. */ -public class OwnedDocsGenerator extends FileEmittingGenerator implements DocsGenerator { - - @Override - public String surface() { - return "java"; - } - - @Override - protected List generate(MetaDataLoader loader) { - String project = getArg(ARG_PROJECT_NAME, "none"); - String subDir = getArg(ARG_API_SUBDIR, "api/unset"); - return List.of(new EmittedFile(subDir + "/OWNED.md", "owned docs for " + project + "\n")); - } -} diff --git a/server/java/maven-plugin/src/test/resources/mojo/pom-docs-kotlin.xml b/server/java/maven-plugin/src/test/resources/mojo/pom-api-docs.xml similarity index 52% rename from server/java/maven-plugin/src/test/resources/mojo/pom-docs-kotlin.xml rename to server/java/maven-plugin/src/test/resources/mojo/pom-api-docs.xml index 37f2381a1..c192c87f9 100644 --- a/server/java/maven-plugin/src/test/resources/mojo/pom-docs-kotlin.xml +++ b/server/java/maven-plugin/src/test/resources/mojo/pom-api-docs.xml @@ -12,7 +12,7 @@ - target/docs-mojo-test-kotlin + target/api-docs-test com.metaobjects @@ -24,20 +24,38 @@ true com.metaobjects.loader.MetaDataLoader - docs-mojo-test-kotlin-pom + api-docs-test-pom ./src/test/resources mojo/mojo-test-metadata.json - target/docs-mojo-test-kotlin/docs - kotlin - package + + + + com.metaobjects.generator.apidocs.JavaApiDocsGenerator + + target/api-docs-test/docs + reference/java + package + fruit-shop + https://docs.example.test/model + + + + com.metaobjects.generator.kotlin.apidocs.KotlinApiDocsGenerator + + target/api-docs-test/docs + + + - docs + generate diff --git a/server/java/maven-plugin/src/test/resources/mojo/pom-docs.xml b/server/java/maven-plugin/src/test/resources/mojo/pom-docs-8-0.xml similarity index 65% rename from server/java/maven-plugin/src/test/resources/mojo/pom-docs.xml rename to server/java/maven-plugin/src/test/resources/mojo/pom-docs-8-0.xml index f172d1ce4..192e0ead6 100644 --- a/server/java/maven-plugin/src/test/resources/mojo/pom-docs.xml +++ b/server/java/maven-plugin/src/test/resources/mojo/pom-docs-8-0.xml @@ -12,11 +12,13 @@ - target/docs-mojo-test + target/docs-8-0-test com.metaobjects metaobjects-maven-plugin +