diff --git a/.github/workflows/publish-csharp.yml b/.github/workflows/publish-csharp.yml index acbdff5ad..9d7eee6eb 100644 --- a/.github/workflows/publish-csharp.yml +++ b/.github/workflows/publish-csharp.yml @@ -41,6 +41,16 @@ jobs: steps: - uses: actions/checkout@v4 + # A final version is not published while the eject pending list has entries + # (ADR-0034 Amendment 8). A release candidate passes, which is why the `version` + # input is read first. + - name: Eject release gate + env: + VERSION_INPUT: ${{ github.event.inputs.version }} + run: | + COMMITTED="$(sed -n 's:.*\(.*\).*:\1:p' server/csharp/Directory.Build.props | head -n 1)" + node scripts/eject-release-gate.mjs "${VERSION_INPUT:-$COMMITTED}" + - name: Set up .NET uses: actions/setup-dotnet@v4 with: diff --git a/.github/workflows/publish-java.yml b/.github/workflows/publish-java.yml index 6bc7458de..d52469fd6 100644 --- a/.github/workflows/publish-java.yml +++ b/.github/workflows/publish-java.yml @@ -33,6 +33,11 @@ jobs: steps: - uses: actions/checkout@v4 + # A final version is not published while the eject pending list has entries + # (ADR-0034 Amendment 8). A release candidate passes. + - name: Eject release gate + run: node scripts/eject-release-gate.mjs "$(sed -n 's:.*\(.*\).*:\1:p' server/java/pom.xml | head -n 1)" + - name: Set up JDK 21 + Maven settings (Central server + GPG import) uses: actions/setup-java@v4 with: diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml index 0eb888130..f0ebef191 100644 --- a/.github/workflows/publish-npm.yml +++ b/.github/workflows/publish-npm.yml @@ -39,6 +39,11 @@ jobs: steps: - uses: actions/checkout@v4 + # A final version is not published while the eject pending list has entries + # (ADR-0034 Amendment 8). A release candidate passes. + - name: Eject release gate + run: node scripts/eject-release-gate.mjs "$(node -p "require('./server/typescript/packages/cli/package.json').version")" + - name: Set up Bun uses: oven-sh/setup-bun@v2 diff --git a/.github/workflows/publish-python.yml b/.github/workflows/publish-python.yml index 2773eec5f..87c702f82 100644 --- a/.github/workflows/publish-python.yml +++ b/.github/workflows/publish-python.yml @@ -28,6 +28,11 @@ jobs: steps: - uses: actions/checkout@v4 + # A final version is not published while the eject pending list has entries + # (ADR-0034 Amendment 8). A release candidate passes. + - name: Eject release gate + run: node scripts/eject-release-gate.mjs "$(sed -n 's/^version *= *"\(.*\)".*/\1/p' server/python/pyproject.toml | head -n 1)" + - name: Set up uv uses: astral-sh/setup-uv@v5 diff --git a/AGENTS.md b/AGENTS.md index a263806a7..f5d5cf47e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,7 @@ MetaObjects is a **cross-language metadata standard** for declaring typed entity The metamodel is the **durable spine**; generated code is the **disposable artifact**. Substrate is local-first: typed metadata lives in your repo, generated code is idiomatic per-language output with **no proprietary runtime**: the generated entity and model code imports nothing from MetaObjects at runtime, and the REST, prompt, client and runtime tiers import ordinary Apache-2.0 packages you can vendor, fork or replace. If `@metaobjectsdev/*` disappears tomorrow, you keep working code. -**Core vs helpers (ADR-0034 Amendment 3, 2026-09-22).** Only the core is a product promise: the metamodel, loader, canonical format and registry; runtime metadata access (the `ObjectManager` and its drivers; the HTTP adapters that mount it on a web framework are helpers, 2026-09-24 ruling); schema migrations (`meta migrate`); the drift gates (`meta verify`); prompt render and the reply parser. Every generator that writes application code into an adopter's repo — routes, controllers, ORM wiring, DTOs, forms, grids, hooks, filter allowlists — is a **reference helper**: it must compile and pass its reference fixtures, and the adopter copies it with `meta eject` and owns the copy. Mechanical test: what the tool guarantees is core; what it writes into your repo is a helper. Every port can eject: `meta eject` (TypeScript), `metaobjects eject` (Python), `mvn metaobjects:eject` (Java/Kotlin), `dotnet meta eject` (C#). Not every generator ships a reference copy: `meta gen --list` marks the ones that do not `package-only` (in TypeScript: `template` alone; ADR-0034 Amendment 3 correction, 2026-09-26, narrowed by Amendments 5 and 7). When you find a generator defect, fix the reference; do not describe it as a broken core guarantee. +**Core vs helpers (ADR-0034 Amendment 3, 2026-09-22).** Only the core is a product promise: the metamodel, loader, canonical format and registry; runtime metadata access (the `ObjectManager` and its drivers; the HTTP adapters that mount it on a web framework are helpers, 2026-09-24 ruling); schema migrations (`meta migrate`); the drift gates (`meta verify`); prompt render and the reply parser. Every generator that writes application code into an adopter's repo — routes, controllers, ORM wiring, DTOs, forms, grids, hooks, filter allowlists — is a **reference helper**: it must compile and pass its reference fixtures, and the adopter copies it with `meta eject` and owns the copy. Mechanical test: what the tool guarantees is core; what it writes into your repo is a helper. Every port can eject: `meta eject` (TypeScript), `metaobjects eject` (Python), `mvn metaobjects:eject` (Java/Kotlin), `dotnet meta eject` (C#). Not every generator ships a reference copy: `meta gen --list` marks the ones that do not `package-only` (in TypeScript: `template`, which is exempt for good, and `requirements` and `agent-docs`, which are pending; ADR-0034 Amendment 3 correction, 2026-09-26, narrowed by Amendments 5 and 7, and held to the shared manifest by Amendment 8). When you find a generator defect, fix the reference; do not describe it as a broken core guarantee. ## Six pillars diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c37cc6f0..863f9300e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -105,6 +105,29 @@ generator catalog and the Atlas evaluation are 1.2 work. ### Added +- **The eject contract: every generator hands over its emit, and a test in every port holds + it to that** (ADR-0034 Amendment 8). `fixtures/generator-registry-conformance/registry.json` + now says, for each generator, `"eject": "full"` or an exemption with its reason, and lists + per port the packaged modules an owned copy may still import (the engine surface). Each + port's eject-contract test asserts three things from it: every generator the port ships is + in its registry; every registered generator ships eject source unless exempt; and each + generator, ejected alone, compiles or loads, references no emit code left in the package, + and emits what the packaged generator emits. `template` is exempt in every port that ships + it, with the deprecated Java Mustache generator and the docs-site builders. Where the + contract does not hold yet, the file's `eject.pending` list names the generator, the port + and the packaged modules its owned copy still calls. A port's test fails on an unlisted gap + and on a listed one that has closed. The release does not ship while the list has entries: + `scripts/release.mjs`, `scripts/finish-release.mjs` and the four publish workflows refuse a + version with no prerelease suffix until the list is empty (`scripts/eject-release-gate.mjs`); a + release candidate is allowed. No generator emits different bytes because of this change. +- **Seven generators that no registry listed are registered**, so `--list`, the eject + commands and the registry-driven tests see them. TypeScript: `requirements` (the ledger + 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. + - **The Java `entity` and `extractor` generators can be ejected.** `mvn metaobjects:eject -Dnames=entity,extractor -Dport=java` copies each generator, with the rest of its emit, into your `codegen/` module. They were the two Java generators that @@ -872,6 +895,13 @@ until you regenerate. ### Fixed +- **The Java PlantUML generator no longer throws on a single-segment package.** + `PlantUMLGenerator` failed with `StringIndexOutOfBoundsException` for any model whose + package has one segment (`acme`, not `acme::shop`), which is most small models. It was in + no registry, so no registry-driven test ran it; registering it (above) put it in two such + suites and both failed. A package with no parent now draws no parent namespace. Output + for a multi-segment package is unchanged. + - **A doc generator skipped by `meta gen` is now reported in every output form.** `meta docs` is the door for documentation (ADR-0025), so `meta gen` warns about and skips a `docsFile()` or `apiDocsFile()` listed under `generators`. That has not changed. What changed is who hears diff --git a/agent-context/skills/metaobjects-codegen/references/typescript.md b/agent-context/skills/metaobjects-codegen/references/typescript.md index 86f11aecf..3bf129111 100644 --- a/agent-context/skills/metaobjects-codegen/references/typescript.md +++ b/agent-context/skills/metaobjects-codegen/references/typescript.md @@ -126,8 +126,10 @@ name. Import by default and eject to customize: an owned copy you never change o behind the reference. (`entityFile` / `queriesFile` / `routesFile` / `barrel` were removed from that subpath at 1.0 and restored in 1.1, so on a 1.0.x install an owned copy is the only path for those four.) The engine primitives come from the package main entry, -`@metaobjectsdev/codegen-ts`. One entry cannot be ejected and is `package-only` in -`meta gen --list`: the `template` primitive, which has no emit logic of its own. +`@metaobjectsdev/codegen-ts`. Three entries cannot be ejected and are `package-only` in +`meta gen --list`: the `template` primitive, which has no emit logic of its own, and +`requirements` and `agent-docs`, the pages `meta docs` writes, which do not ship a +reference template yet. Three ejectable entries behave differently from the rest: diff --git a/docs/CONFORMANCE.md b/docs/CONFORMANCE.md index 3f0ed77fd..bb8712188 100644 --- a/docs/CONFORMANCE.md +++ b/docs/CONFORMANCE.md @@ -87,8 +87,8 @@ the corpora above do two different jobs. Only the first is a promise to adopters exporter's output, a TypeScript reference helper), `requirement-test-identity-conformance/` (which tests the `requirement-tests` reference generator yields from a ledger and what each is called; the text it writes is not - pinned) and the codegen-compile gate. They check that the reference generators are correct - starting points; an adopter's ejected copy is theirs and is not gated. The hand-rolled + pinned), the codegen-compile gate and the eject contract (below). They check that the + reference generators are correct starting points; an adopter's ejected copy is theirs and is not gated. The hand-rolled reference-server lane of `api-contract-conformance/` still pins the wire contract that the runtimes and the client speak, which is core. @@ -99,6 +99,23 @@ corpora, or between its *vocabulary* and its *behaviour* — never gets a row of own, and its boundary ends up discoverable only from the feature doc. The split ones are recorded here instead. +**The eject contract** ([ADR-0034](../spec/decisions/ADR-0034-codegen-scaffold-and-own.md) +Amendment 8). A gate, not a corpus: it has no fixtures of its own, and reads +`generator-registry-conformance/registry.json`. + +- *Gated in all five ports.* Each port's eject-contract test asserts that every generator it + ships is in the registry (census), that every registered generator ships eject source + unless the shared file exempts it (completeness), and that each generator ejected alone + compiles or loads, references nothing in the codegen packages outside the engine surface + and emits what the packaged generator emits (depth). TypeScript: + `cli/test/eject-contract.test.ts`. C#: `EjectContractTests`. Java: `EjectContractTest` in + `maven-plugin`. Kotlin: `EjectContractTest` in `codegen-kotlin`. Python: + `tests/conformance/test_eject_contract.py`. +- *Not yet true everywhere, and the shared file says where.* `eject.pending` lists every + generator that still fails an assertion. A port's test fails when an unlisted gap appears + and when a listed one closes without its entry being removed. +- *Not gated: an adopter's owned copy.* The contract is about what the reference hands over. + **Capability requirements** ([features/requirements.md](features/requirements.md)): - *Vocabulary — gated in all five ports.* The types, their attributes and their closed diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 7f7395f83..db1638410 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -10,7 +10,8 @@ > - **Maven Central** → push a `java-v` tag (`publish-java.yml` signs and deploys > from repo secrets), or locally `~/.m2/settings.xml` (server ids `central` + > `gpg-credentials`) + the local GPG signing key and `mvn -Prelease deploy` from -> `server/java` (autoPublish — no manual staging promotion). One route per version, never both. +> `server/java` (autoPublish, so no manual staging promotion; run the eject release gate +> first, see the Java procedure). One route per version, never both. > - **NuGet** → keyless OIDC via the `publish-csharp.yml` workflow: `gh workflow run > publish-csharp.yml` (or push a `csharp-v` tag). > @@ -48,6 +49,26 @@ Decide explicitly for every branch the scan returns: merge it into this cut, or Note that `no-mistakes/*` branches are usually gate replays of work already on `main` — confirm rather than assume. Do not skip past the list. +**The eject pending list (1.1.0).** 1.1.0 does not ship while +`fixtures/generator-registry-conformance/registry.json` has an entry in `eject.pending` +(ADR-0034 Amendment 8). The scripted paths enforce it: `scripts/release.mjs` (first check of +its preflight), `scripts/finish-release.mjs` (gate 0, before the tag) and the four publish +workflows (the step after checkout) refuse a version with no prerelease suffix while the list +holds an entry. A release candidate (`1.1.0-rc.3`, or `1.1.0rc3` on PyPI) is allowed. + +A publish you type by hand passes through none of those, so run the gate yourself as the +command before it. This document has it before each of the four: the npm promote in step 3, +the isolated one-package `bun publish`, the PyPI fallback `uv publish`, and the local +`mvn -Prelease deploy`. It exits non-zero on a refusal: + +``` +node scripts/eject-release-gate.mjs # the version that registry is about to receive +``` + +To clear it for a final release: close the gaps and delete their entries. There is no other +step. Each port's eject-contract test fails on a gap that is not listed, so an entry cannot be +deleted before its gap is closed. + **Why this is step 0 and not a nicety:** `0.21.1` shipped *without* a `timestampMode` fix that was already written and sitting on an unmerged branch. Because versions are immutable on all four registries, the only correction was a full coordinated `0.21.2` within the hour — four publishes, an @@ -549,6 +570,7 @@ scripts/ci-local.sh --strict-toolchains # must end "LOCAL CI PASSED" # bump the candidate set to the final rm bun.lock && bun install # verify packed deps (rule 2); commit "chore(release): " +node scripts/eject-release-gate.mjs # must exit 0; refuses while eject.pending has entries ( cd && bun publish ) # default tag = latest, tier order git push origin main # NO tag here — see golden rule 7 ``` @@ -587,8 +609,8 @@ CDN-cached and lags right after publish. ## Isolated patch (one package) If only one package changed (e.g. a `cli` bugfix), bump just that package, `rm bun.lock && bun -install`, verify, and `bun publish` it — the others stay at their current version. Tag scoped -(e.g. `cli-v0.5.1`). +install`, verify, run `node scripts/eject-release-gate.mjs ` (it must exit 0), and +`bun publish` it. The others stay at their current version. Tag scoped (e.g. `cli-v0.5.1`). # Releasing the C# packages to NuGet @@ -750,9 +772,10 @@ Project `metaobjects` → **Settings → Publishing → Add a new GitHub publish python-v`. **Do not ALSO publish by hand.** The `1.0.3` cut ran a local `uv publish` and then pushed the tag, so the workflow went red with `400 File already exists` on a release that had shipped — a red run that reads exactly like a failed publish. - The local route is the fallback only when the workflow cannot run: `UV_PUBLISH_TOKEN= uv publish dist/*` from `server/python`, and then do not push a `python-v*` tag - for that version. + The local route is the fallback only when the workflow cannot run. Run + `node scripts/eject-release-gate.mjs ` from the repository root first (it must exit + 0; the workflow runs the same gate), then `UV_PUBLISH_TOKEN= uv publish + dist/*` from `server/python`, and then do not push a `python-v*` tag for that version. 4. **Verify:** `curl -s https://pypi.org/pypi/metaobjects/json | python3 -c "import sys,json;print(json.load(sys.stdin)['info']['version'])"`. # Releasing the Java/Kotlin modules to Maven Central @@ -789,7 +812,9 @@ versioned on its own major line — npm major + 7, so `7.x` while npm was `0.x` with `Component … already exists`): push a `java-v` tag, and `publish-java.yml` runs the deploy below in CI with the signing secrets (how `8.0.4` shipped; it takes no version override, so it deploys the COMMITTED version — do not fire - it for an uncommitted pre-release); or run `mvn -Prelease deploy` from `server/java`. + it for an uncommitted pre-release); or run + `node scripts/eject-release-gate.mjs ` from the repository root (it must exit + 0; the workflow runs the same gate) and then `mvn -Prelease deploy` from `server/java`. The `central-publishing-maven-plugin` (`central`, `true`) uploads the signed bundle and auto-releases — **no manual staging → release promotion**. Auth + the GPG diff --git a/docs/features/own-your-codegen.md b/docs/features/own-your-codegen.md index 73d932282..efae4176c 100644 --- a/docs/features/own-your-codegen.md +++ b/docs/features/own-your-codegen.md @@ -159,10 +159,13 @@ you how far it is from the reference. ## The docs generators and `shared-model` (TypeScript) -Every stock TypeScript generator can be ejected except one. `template` is the Mustache +Every stock TypeScript generator can be ejected except three. `template` is the Mustache primitive: you already supply its template, its walk and its name, and it has no emit -logic of its own, so a copy would own nothing. `meta gen --list` marks it `package-only`. -The C#, Java and Python ports rule the same way for theirs (ADR-0034 Amendment 7). +logic of its own, so a copy would own nothing. That exemption is permanent, and the C#, +Java and Python ports rule the same way for theirs (ADR-0034 Amendment 7). `requirements` +and `agent-docs`, the pages `meta docs` and `meta docs --agent` write, were registered by +the eject census and do not ship a reference template yet (ADR-0034 Amendment 8). +`meta gen --list` marks all three `package-only`. Three of the ejectable ones differ from the rest in what runs them or in what the copy may change. @@ -274,7 +277,11 @@ directory that holds `metaobjects.config.ts`. ## What `meta eject` hands over, and what stays core (TypeScript) Owning a generator means owning the code it writes **and the code that code calls**, -when that code is a helper. On TypeScript the generated routes call an HTTP adapter — +when that code is a helper. That is the rule for every generator in every port +(ADR-0034 Amendment 8). It is not yet true of all of them: several owned copies still call +emit code in the package, and `fixtures/generator-registry-conformance/registry.json` lists +each one under `eject.pending` until it is closed. What follows is the part that is +already handed over. On TypeScript the generated routes call an HTTP adapter — the mount helpers, the filter/sort parser, the error envelopes, pagination — and that adapter is where most route defects have lived. So ejecting a generator whose output calls it copies the adapter's source too: @@ -997,10 +1004,11 @@ against your copy, ignoring the package line and `^// metaobjects:owned-support` 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, and every Java generator except -`template`. `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. +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. ## C#: `dotnet meta eject` diff --git a/docs/ports/java.md b/docs/ports/java.md index f43e36894..41b6a8875 100644 --- a/docs/ports/java.md +++ b/docs/ports/java.md @@ -288,6 +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. | +| `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. Every one is independently configurable; the typical starting set is diff --git a/docs/superpowers/specs/2026-08-29-fr-040-framework-agnostic-codegen-ownership-design.md b/docs/superpowers/specs/2026-08-29-fr-040-framework-agnostic-codegen-ownership-design.md index 4a72efa14..d1f173778 100644 --- a/docs/superpowers/specs/2026-08-29-fr-040-framework-agnostic-codegen-ownership-design.md +++ b/docs/superpowers/specs/2026-08-29-fr-040-framework-agnostic-codegen-ownership-design.md @@ -207,6 +207,10 @@ zero MetaObjects knowledge of Next.js, and it survives regeneration. It also generalizes: the same one-line prepend is what Qwik (`"use client"` analogues), Angular (no directive, different imports) or Svelte would each vary in their own way. +> **Superseded by ADR-0034 Amendment 8 (2026-10-11).** The owned copy now holds the +> emit: `meta eject` copies the template modules a reference calls into the project with +> it. The paragraph below records the choice as it was made, and is no longer the rule. + **Not chosen:** shipping fat reference templates that inline the renderer. It produces a 600-line copied file that immediately stops receiving upstream fixes — the worst of both models. Ownership should be cheap to take and cheap to keep. diff --git a/docs/superpowers/specs/2026-09-22-eject-in-every-port-design.md b/docs/superpowers/specs/2026-09-22-eject-in-every-port-design.md index 702e787ee..41f35783b 100644 --- a/docs/superpowers/specs/2026-09-22-eject-in-every-port-design.md +++ b/docs/superpowers/specs/2026-09-22-eject-in-every-port-design.md @@ -20,6 +20,9 @@ drifted from the reference. - **No config edits.** Eject prints the one line or element to add, and the entry to remove. - **`--list` marks owned copies** `identical`, or `DIFFERS: N behind, M of your own`, comparing sorted, whitespace-normalised lines against the packaged reference, as `owned-copy.ts` does. +- *(Superseded by ADR-0034 Amendment 8, 2026-10-11: a helper that writes output is copied + with the generator. Making it public leaves the adopter calling code they cannot edit. The + bullet below records the rule as it was.)* - **A copy imports only public API.** Where a reference generator uses an internal helper, the helper is made public in the same change. The alternative, copying helpers alongside, would give the adopter code they did not ask to own. Making them public widens the engine's API surface, diff --git a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/typescript.md b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/typescript.md index 86f11aecf..3bf129111 100644 --- a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/typescript.md +++ b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/typescript.md @@ -126,8 +126,10 @@ name. Import by default and eject to customize: an owned copy you never change o behind the reference. (`entityFile` / `queriesFile` / `routesFile` / `barrel` were removed from that subpath at 1.0 and restored in 1.1, so on a 1.0.x install an owned copy is the only path for those four.) The engine primitives come from the package main entry, -`@metaobjectsdev/codegen-ts`. One entry cannot be ejected and is `package-only` in -`meta gen --list`: the `template` primitive, which has no emit logic of its own. +`@metaobjectsdev/codegen-ts`. Three entries cannot be ejected and are `package-only` in +`meta gen --list`: the `template` primitive, which has no emit logic of its own, and +`requirements` and `agent-docs`, the pages `meta docs` writes, which do not ship a +reference template yet. Three ejectable entries behave differently from the rest: diff --git a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-codegen/references/typescript.md b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-codegen/references/typescript.md index 86f11aecf..3bf129111 100644 --- a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-codegen/references/typescript.md +++ b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-codegen/references/typescript.md @@ -126,8 +126,10 @@ name. Import by default and eject to customize: an owned copy you never change o behind the reference. (`entityFile` / `queriesFile` / `routesFile` / `barrel` were removed from that subpath at 1.0 and restored in 1.1, so on a 1.0.x install an owned copy is the only path for those four.) The engine primitives come from the package main entry, -`@metaobjectsdev/codegen-ts`. One entry cannot be ejected and is `package-only` in -`meta gen --list`: the `template` primitive, which has no emit logic of its own. +`@metaobjectsdev/codegen-ts`. Three entries cannot be ejected and are `package-only` in +`meta gen --list`: the `template` primitive, which has no emit logic of its own, and +`requirements` and `agent-docs`, the pages `meta docs` writes, which do not ship a +reference template yet. Three ejectable entries behave differently from the rest: diff --git a/fixtures/generator-registry-conformance/README.md b/fixtures/generator-registry-conformance/README.md index f921cc2be..3e01555de 100644 --- a/fixtures/generator-registry-conformance/README.md +++ b/fixtures/generator-registry-conformance/README.md @@ -35,8 +35,8 @@ to make the choice cheap, and `layer` is the axis it is cheap along. | `persistence` | queries, db-context, repository, exposed-table, relations, stored-proc | app shape | | `api` | routes, routes-hono, filter-allowlist, validator, spring-config | app shape | | `client` | form, hooks, grid, grid-hook | app shape | -| `docs` | docs, api-docs | on by default (`meta docs`) | -| `capability` | prompt-render, output-parser, output-prompt, extractor, render-helper, trace-helper, requirement-tests, cube-model, shared-model, template, callable | `meta gen --list --probe` | +| `docs` | docs, api-docs, requirements, agent-docs, plantuml, html-docs, ai-docs | on by default (`meta docs`); the Java `plantuml`, `html-docs` and `ai-docs` are wired in the pom | +| `capability` | prompt-render, output-parser, output-prompt, extractor, render-helper, trace-helper, requirement-tests, cube-model, shared-model, template, callable, json-schema, mustache | `meta gen --list --probe` | Six, not ten. An earlier draft split `capability` into `trace` / `requirements` / `publish` / `primitive`, each with **one member** — a layer with one member does @@ -55,6 +55,58 @@ not a defect to be tidied. Do not add a seventh value to make it look tidier. `layer` rather than a reuse of "tier", which is already taken twice (native/neutral in ADR-0020, and server/UI elsewhere). +## `eject` — the eject contract + +Every stock generator can be ejected, and the owned copy holds its emit +([ADR-0034](../../spec/decisions/ADR-0034-codegen-scaffold-and-own.md) Amendment 8). This +file is where that is declared, and each port's eject-contract test asserts it. + +**Per generator**, `eject` is one of: + +- `"full"`: the generator ships eject source in every port that lists it, and its owned + copy imports nothing from the codegen packages but the engine surface. +- `{"exempt": "", "ruling": ""}`: the generator is not ejectable, and + this says why. An exemption cannot be made by leaving a name off a list. + +**The top-level `eject` block** holds: + +| Key | What it is | +|---|---| +| `engineSurface..codegenPackages` | The namespaces that hold generators and their emit code. TypeScript: source paths under `server/typescript/packages/`. Python: module names, with a package listed by its own name as well as by `name.**`, because importing the package reaches what its `__init__` re-exports. C#: type names. Java and Kotlin: class names. `**` matches any run of characters, `*` one segment. | +| `engineSurface..engine` | The members of those namespaces an owned copy may still import: the generator base and runner, questions about the model, naming and type-mapping tables. Anything else inside `codegenPackages` writes output and is handed over with the generator. Anything outside them is core and imported freely. | +| `exemptEmitters` | Code that writes into an adopter's repository and is not a generator, ruled out of eject with a reason. | +| `pending` | Every place the contract does not hold yet. The release scripts and the publish workflows refuse a final version (one with no prerelease suffix) while the list holds an entry (`scripts/eject-release-gate.mjs`). | + +**A `pending` entry** is `{generator, port, assertion, gap}`: + +- `assertion` is `census` (the manifest lists the generator for the port and the port's + registry does not hold it), `completeness` (registered, no eject source) or `depth` (its + owned copy references code outside the engine surface). +- `gap` is a number from the gap list in ADR-0034 Amendment 8. +- A `depth` entry also has `outside`: the packaged modules its owned copy still references, + sorted. A generator with two gaps has two `depth` entries, each listing what that gap + closes. + +The list is checked in both directions. A gap that is not listed fails the port's test. A +listed entry that passes fails it too, so the change that closes a gap deletes its entry. +`outside` is compared exactly, so partial progress is recorded as it is made. + +**Each port's test** (TypeScript `cli/test/eject-contract.test.ts`, C# +`EjectContractTests`, Java `EjectContractTest` in `maven-plugin`, Kotlin `EjectContractTest` +in `codegen-kotlin`, Python `tests/conformance/test_eject_contract.py`) makes three +assertions: + +1. **Census.** Every generator implementation the port ships is in its registry, and the + registry exposes the names this file gives the port. +2. **Completeness.** Every registered generator ships eject source unless exempt here, and + every other list of ejectable generators is derived from the registry or equal to it. +3. **Depth.** Each generator, ejected alone into an empty project, compiles or loads, + references nothing in `codegenPackages` outside `engine`, and emits what the packaged + generator emits. + +What each port's reference scan reads, and the limits of the guard, are in ADR-0034 +Amendment 8. + ## Shared (cross-port) names These concepts MUST use the same stable name wherever a port implements them: diff --git a/fixtures/generator-registry-conformance/registry.json b/fixtures/generator-registry-conformance/registry.json index cf9109d46..ab2cd2306 100644 --- a/fixtures/generator-registry-conformance/registry.json +++ b/fixtures/generator-registry-conformance/registry.json @@ -1,193 +1,227 @@ { - "$comment": "CANONICAL generator stable-name manifest (ADR-0021 D3). The single cross-port source of truth for generator stable names. Every port's generator registry is conformance-tested against this file: (1) every generator a port exposes MUST use a name listed here; (2) for each generator, the `ports` array lists exactly the ports expected to expose it (presence is checked both ways); (3) because all ports validate against this one file, a shared concept (e.g. `routes`) is spelled identically everywhere. Port ids: typescript, csharp, java, kotlin, python. `tier`: native (idiomatic per-port code) or neutral (Tier-2, owned by `meta docs`). `layer`: one of model, persistence, api, client, docs, capability — the grouping an adopter SELECTS BY, gated in every port exactly as `tier` is (see README.md). Adding/renaming a generator = edit THIS file + the port registry together; the conformance gate fails otherwise.", + "$comment": "CANONICAL generator stable-name manifest (ADR-0021 D3). The single cross-port source of truth for generator stable names. Every port's generator registry is conformance-tested against this file: (1) every generator a port exposes MUST use a name listed here; (2) for each generator, the `ports` array lists exactly the ports expected to expose it (presence is checked both ways); (3) because all ports validate against this one file, a shared concept (e.g. `routes`) is spelled identically everywhere. Port ids: typescript, csharp, java, kotlin, python. `tier`: native (idiomatic per-port code) or neutral (Tier-2, owned by `meta docs`). `layer`: one of model, persistence, api, client, docs, capability — the grouping an adopter SELECTS BY, gated in every port exactly as `tier` is (see README.md). `eject`: \"full\" (the owned copy holds the generator's emit) or an exemption with a written reason and the ruling that grants it; the top-level `eject` block carries the engine surface, the exempt non-generator emitters and the pending list (ADR-0034 Amendment 8, see README.md). Adding/renaming a generator = edit THIS file + the port registry together; the conformance gate fails otherwise.", "ports": ["typescript", "csharp", "java", "kotlin", "python"], "generators": { "entity": { "concept": "Per-entity model/class — the entity module (table-backed or value object).", "tier": "native", "layer": "model", + "eject": "full", "ports": ["typescript", "csharp", "java", "kotlin", "python"] }, "routes": { "concept": "Per-entity REST endpoint surface (controllers / routes / router).", "tier": "native", "layer": "api", + "eject": "full", "ports": ["typescript", "csharp", "java", "kotlin", "python"] }, "output-parser": { "concept": "Per-template response parser: a strict parse that rejects a reply not matching the @responseRef shape, plus a tolerant lenient extract for a raw model reply, which reports per field instead of throwing on a bad reply.", "tier": "native", "layer": "capability", + "eject": "full", "ports": ["typescript", "csharp", "java", "kotlin", "python"] }, "output-prompt": { "concept": "Per-template output-format prompt fragment generator.", "tier": "native", "layer": "capability", + "eject": "full", "ports": ["typescript", "csharp", "java", "kotlin", "python"] }, "render-helper": { "concept": "Per-renderable-template render helper (typed render wrappers). BOTH template subtypes render (ADR-0052), so on csharp/java/kotlin/python this covers template.output AND template.prompt; on typescript a prompt's render handle is the separate `prompt-render` generator instead, so typescript's render-helper is template.output only. Same stable name, one concept, one per-port split — stated here because the concept line is shared and the split is not.", "tier": "native", "layer": "capability", + "eject": "full", "ports": ["typescript", "csharp", "java", "kotlin", "python"] }, "extractor": { "concept": "Per-template typed extract helper: tolerant recovery of the typed response from dirty model text; throws only when a @required field is lost.", "tier": "native", "layer": "capability", + "eject": "full", "ports": ["typescript", "csharp", "java", "kotlin", "python"] }, "template": { "concept": "Generic Mustache template primitive (walk + template -> files).", "tier": "native", "layer": "capability", + "eject": { + "exempt": "The Mustache primitive. The template and the walk the adopter supplies are its emit logic, so the package holds none to hand over.", + "ruling": "ADR-0034 Amendment 8, ruling 5" + }, "ports": ["typescript", "csharp", "java", "python"] }, "filter-allowlist": { "concept": "Per-entity REST filter allowlist (queryable-field guard).", "tier": "native", "layer": "api", + "eject": "full", "ports": ["csharp", "java", "kotlin", "python"] }, "queries": { "concept": "Per-entity typed query helpers (findById/create/...).", "tier": "native", "layer": "persistence", + "eject": "full", "ports": ["typescript"] }, "callable": { "concept": "Per-entity callable wrapper for a callable source (storedProc / tableFunction); in TS it also covers the service surface wrapping the query helpers.", "tier": "native", "layer": "capability", + "eject": "full", "ports": ["typescript", "csharp"] }, "routes-hono": { "concept": "Per-entity Hono CRUD routes.", "tier": "native", "layer": "api", + "eject": "full", "ports": ["typescript"] }, "barrel": { "concept": "Single index module re-exporting every generated entity module.", "tier": "native", "layer": "model", + "eject": "full", "ports": ["typescript"] }, "names": { "concept": "Per-entity physical database name constants (table/view name, schema, column names).", "tier": "native", "layer": "model", + "eject": "full", "ports": ["typescript", "csharp", "kotlin", "python", "java"] }, "prompt-render": { "concept": "Per-template.prompt render handle over the render engine, aggregated into one module. TYPESCRIPT ONLY by design: the other four ports emit a prompt's render helper from `render-helper` (see its note) rather than a second generator.", "tier": "native", "layer": "capability", + "eject": "full", "ports": ["typescript"] }, "api-docs": { "concept": "Per-entity/template SDK API reference (generated-code API; human + agent forms).", "tier": "native", "layer": "docs", - "note": "Run by `meta docs` as its api surface (ADR-0025), not by `meta gen`: a `meta gen` config that lists it is warned about and skipped. On csharp it is run by `dotnet meta docs`, and `dotnet meta gen` skips it, warning only when `--generators` names it.", - "ports": ["typescript", "csharp"] + "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.", + "eject": "full", + "ports": ["typescript", "csharp", "java", "kotlin", "python"] }, "trace-helper": { "concept": "Per-entity typed record/call trace helpers (extract + buildLlmCallRow + persist; LlmCallBase-derived entities only).", "tier": "native", "layer": "capability", "note": "TS emits record + call; Java (codegen-spring) emits record only — the Java LlmClient seam is BYO/vendor-neutral and not ported (ADR-0024). Python emits record_ only (same BYO-caller rationale).", + "eject": "full", "ports": ["typescript", "java", "python"] }, "db-context": { "concept": "Single EF Core DbContext binding every generated entity.", "tier": "native", "layer": "persistence", + "eject": "full", "ports": ["csharp"] }, "repository": { "concept": "Per-entity repository seam (Java: Spring Data interface; Kotlin: an open Exposed persistence base with CRUD + patch bodies).", "tier": "native", "layer": "persistence", + "eject": "full", "ports": ["java", "kotlin"] }, "dto": { "concept": "Per-entity Spring DTO.", "tier": "native", "layer": "model", + "eject": "full", "ports": ["java"] }, "value-object": { "concept": "Per-value-object Spring record with jakarta constraints (the DTO/Patch bind target for a field.object jsonb column, and a template's payload/response type).", "tier": "native", "layer": "model", + "eject": "full", "ports": ["java"] }, "exposed-table": { "concept": "Kotlin Exposed table object per table-backed or view-backed entity or projection, and per view-backed report.", "tier": "native", "layer": "persistence", + "eject": "full", "ports": ["kotlin"] }, "relations": { "concept": "Cross-entity relationship helpers.", "tier": "native", "layer": "persistence", + "eject": "full", "ports": ["kotlin"] }, "spring-config": { "concept": "Spring wiring/configuration for the generated surface.", "tier": "native", "layer": "api", + "eject": "full", "ports": ["kotlin"] }, "stored-proc": { "concept": "Stored-procedure binding helpers.", "tier": "native", "layer": "persistence", + "eject": "full", "ports": ["kotlin"] }, "validator": { "concept": "Per-entity input validator.", "tier": "native", "layer": "api", + "eject": "full", "ports": ["kotlin"] }, "shared-model": { "concept": "FR-023: a publisher's flattened shared-model artifact + manifest for a consumer's `meta deps sync`.", "tier": "native", "layer": "capability", + "eject": "full", "ports": ["typescript"] }, "form": { "concept": "Per-entity React form component over the generated Zod schema.", "tier": "native", "layer": "client", + "eject": "full", "ports": ["typescript"] }, "hooks": { "concept": "Per-entity TanStack Query hooks (useEntity / useEntities / useCreate / useUpdate / useDelete).", "tier": "native", "layer": "client", + "eject": "full", "ports": ["typescript"] }, "grid": { "concept": "Per-entity TanStack Table column definitions, from a layout.dataGrid declaration.", "tier": "native", "layer": "client", + "eject": "full", "ports": ["typescript"] }, "grid-hook": { "concept": "Per-entity server-driven grid state hook (sort/filter/page) over the generated columns.", "tier": "native", "layer": "client", + "eject": "full", "ports": ["typescript"] }, "requirement-tests": { "concept": "Per-requirement test stub, one per requirement.functional claim in the ledger.", "tier": "native", "layer": "capability", + "eject": "full", "ports": ["typescript", "java", "kotlin", "python", "csharp"] }, "cube-model": { @@ -195,6 +229,7 @@ "tier": "native", "layer": "capability", "note": "TYPESCRIPT ONLY by design (FR-044 Plan 4, reporting design R6): a reference helper (ADR-0034 Amendment 3) writing YAML that imports nothing; no other port ships an exporter.", + "eject": "full", "ports": ["typescript"] }, "docs": { @@ -202,7 +237,446 @@ "tier": "neutral", "layer": "docs", "note": "Owned by `meta docs` (the single docs door, ADR-0021 D1); not part of the recommended native suite.", + "eject": "full", + "ports": ["typescript"] + }, + "requirements": { + "concept": "The requirement ledger as documentation: requirements.md for people and requirements.toon for tools.", + "tier": "neutral", + "layer": "docs", + "note": "Run by `meta docs` as its requirements surface, not by `meta gen`. A project that declares no requirement.* node gets no file.", + "eject": "full", "ports": ["typescript"] + }, + "agent-docs": { + "concept": "Pages written for a coding agent working in the project: the schema, the UI surface and the requirements.", + "tier": "neutral", + "layer": "docs", + "note": "Run by `meta docs --agent`, not by `meta gen`.", + "eject": "full", + "ports": ["typescript"] + }, + "plantuml": { + "concept": "PlantUML class diagram of the object model.", + "tier": "native", + "layer": "docs", + "eject": "full", + "ports": ["java"] + }, + "html-docs": { + "concept": "Single-page HTML reference of the registered metamodel types, read from the type registry.", + "tier": "native", + "layer": "docs", + "eject": "full", + "ports": ["java"] + }, + "ai-docs": { + "concept": "JSON reference of the registered metamodel types written for an AI reader, read from the type registry.", + "tier": "native", + "layer": "docs", + "eject": "full", + "ports": ["java"] + }, + "json-schema": { + "concept": "JSON Schema that validates metadata files, read from the type registry.", + "tier": "native", + "layer": "capability", + "eject": "full", + "ports": ["java"] + }, + "mustache": { + "concept": "The pre-`template` Mustache generator, kept so a pom that names its class keeps building.", + "tier": "native", + "layer": "capability", + "note": "Deprecated (ADR-0021 D4). Use `template`.", + "eject": { + "exempt": "Deprecated in favour of `template` (ADR-0021 D4) and kept only so existing poms build. A deprecated generator is not handed over.", + "ruling": "ADR-0034 Amendment 8, ruling 4" + }, + "ports": ["java"] } + }, + "eject": { + "$comment": "The eject contract (ADR-0034 Amendment 8), asserted in every port by its eject-contract test. `engineSurface.`: `codegenPackages` are the namespaces that hold generators and their emit code; `engine` lists the members of those namespaces an owned copy may still import (the generator base and runner, model questions, naming and type-mapping tables). Anything else inside `codegenPackages` writes output and must be handed over with the generator. Everything outside `codegenPackages` (loader, metamodel, runtime, render) is core and may be imported freely. `exemptEmitters`: code that writes into an adopter's repository but is not a generator, ruled out of eject with a reason. `pending`: every place the contract does not hold yet, by generator, port, assertion (census, completeness or depth) and gap number; a depth entry lists in `outside` the modules its owned copy still imports from outside the engine surface. A port's test fails when a pending entry passes, and when a gap appears that is not listed. 1.1.0 does not ship with a non-empty pending list: the release scripts and the publish workflows refuse a version with no prerelease suffix while eject.pending holds an entry (scripts/eject-release-gate.mjs).", + "engineSurface": { + "typescript": { + "codegenPackages": ["codegen-ts/src/**", "codegen-ts-react/src/**", "codegen-ts-tanstack/src/**"], + "engine": [ + "codegen-ts-tanstack/src/data-grid-gate.ts", + "codegen-ts/src/api-surface.ts", + "codegen-ts/src/client-directive.ts", + "codegen-ts/src/constants.ts", + "codegen-ts/src/dialect-module.ts", + "codegen-ts/src/docs-paths.ts", + "codegen-ts/src/format.ts", + "codegen-ts/src/generator.ts", + "codegen-ts/src/import-path.ts", + "codegen-ts/src/instance-artifacts.ts", + "codegen-ts/src/names.ts", + "codegen-ts/src/naming.ts", + "codegen-ts/src/owned-runtime.ts", + "codegen-ts/src/projection/projection-detector.ts", + "codegen-ts/src/relation-resolver.ts", + "codegen-ts/src/render-context.ts", + "codegen-ts/src/render-engine/framework-provider.ts", + "codegen-ts/src/requirement-walk.ts", + "codegen-ts/src/routes-expose.ts", + "codegen-ts/src/source-detect.ts", + "codegen-ts/src/templates/find-inbound.ts", + "codegen-ts/src/templates/find-templates.ts" + ] + }, + "csharp": { + "codegenPackages": ["MetaObjects.Codegen.**"], + "engine": [ + "MetaObjects.Codegen.CSharpNaming", + "MetaObjects.Codegen.ColumnNamingStrategy", + "MetaObjects.Codegen.DocsRunFacts", + "MetaObjects.Codegen.EmittedFile", + "MetaObjects.Codegen.FieldNames", + "MetaObjects.Codegen.GenConfig", + "MetaObjects.Codegen.GenContext", + "MetaObjects.Codegen.GeneratorApplicability", + "MetaObjects.Codegen.Generators.FindInbound", + "MetaObjects.Codegen.Generators.InboundShape", + "MetaObjects.Codegen.Generators.TphPlan", + "MetaObjects.Codegen.Generators.TphPlanBuilder", + "MetaObjects.Codegen.Generators.TphSubtypePlan", + "MetaObjects.Codegen.IDocsGenerator", + "MetaObjects.Codegen.IGenerator", + "MetaObjects.Codegen.IRequirementTestRenderer", + "MetaObjects.Codegen.InstanceArtifacts", + "MetaObjects.Codegen.KeyNames", + "MetaObjects.Codegen.ObjectNames", + "MetaObjects.Codegen.PackageBindingResolver", + "MetaObjects.Codegen.PerEntityGenerator", + "MetaObjects.Codegen.RenderedTest", + "MetaObjects.Codegen.ReportRows", + "MetaObjects.Codegen.RequirementTestArgs", + "MetaObjects.Codegen.RequirementTestClaim", + "MetaObjects.Codegen.SourceNames", + "MetaObjects.Codegen.SuperNames", + "MetaObjects.Codegen.ValueObjectNames" + ] + }, + "java": { + "codegenPackages": ["com.metaobjects.generator.**"], + "engine": [ + "com.metaobjects.generator.EmitsObjectExtractors", + "com.metaobjects.generator.EmitsPhysicalNameConstants", + "com.metaobjects.generator.FileEmittingGenerator", + "com.metaobjects.generator.Generator", + "com.metaobjects.generator.GeneratorBase", + "com.metaobjects.generator.GeneratorException", + "com.metaobjects.generator.GeneratorIOException", + "com.metaobjects.generator.GeneratorIOWriter", + "com.metaobjects.generator.MetaDataFilters", + "com.metaobjects.generator.ProjectClassLoaderAware", + "com.metaobjects.generator.direct.FileDirectWriter", + "com.metaobjects.generator.direct.GenerationContext", + "com.metaobjects.generator.direct.MultiFileDirectGeneratorBase", + "com.metaobjects.generator.direct.object.BaseObjectCodeGenerator", + "com.metaobjects.generator.requirement.RenderedTest", + "com.metaobjects.generator.requirement.RequirementTestArgs", + "com.metaobjects.generator.requirement.RequirementTestRenderer", + "com.metaobjects.generator.spring.AutoSetSupport", + "com.metaobjects.generator.spring.FindInbound", + "com.metaobjects.generator.spring.SpringM2mSupport", + "com.metaobjects.generator.spring.SpringNaming", + "com.metaobjects.generator.spring.SpringTypeMapper", + "com.metaobjects.generator.spring.TphPlan", + "com.metaobjects.generator.util.GeneratedFileWriter", + "com.metaobjects.generator.util.GeneratorUtil", + "com.metaobjects.generator.util.JavaIdentifiers", + "com.metaobjects.generator.util.RestSurfaceGate" + ] + }, + "kotlin": { + "codegenPackages": ["com.metaobjects.generator.**"], + "engine": [ + "com.metaobjects.generator.EmitsPhysicalNameConstants", + "com.metaobjects.generator.FileEmittingGenerator", + "com.metaobjects.generator.Generator", + "com.metaobjects.generator.GeneratorBase", + "com.metaobjects.generator.GeneratorException", + "com.metaobjects.generator.GeneratorIOWriter", + "com.metaobjects.generator.ProjectClassLoaderAware", + "com.metaobjects.generator.direct.MultiFileDirectGeneratorBase", + "com.metaobjects.generator.kotlin.ExposedApi", + "com.metaobjects.generator.kotlin.ExposedImports", + "com.metaobjects.generator.kotlin.FindInbound", + "com.metaobjects.generator.kotlin.KotlinGenUtil", + "com.metaobjects.generator.kotlin.KotlinM2mSupport", + "com.metaobjects.generator.kotlin.KotlinNaming", + "com.metaobjects.generator.kotlin.KotlinPoetFileWriter", + "com.metaobjects.generator.kotlin.KotlinTphPlan", + "com.metaobjects.generator.kotlin.KotlinTypeMapper", + "com.metaobjects.generator.kotlin.PackageMapping", + "com.metaobjects.generator.requirement.RenderedTest", + "com.metaobjects.generator.requirement.RequirementTestArgs", + "com.metaobjects.generator.requirement.RequirementTestRenderer", + "com.metaobjects.generator.util.GeneratedFileWriter", + "com.metaobjects.generator.util.GeneratorUtil", + "com.metaobjects.generator.util.RestSurfaceGate" + ] + }, + "python": { + "codegenPackages": ["metaobjects.codegen", "metaobjects.codegen.**", "metaobjects.apidocs", "metaobjects.apidocs.**"], + "engine": [ + "metaobjects.apidocs.naming", + "metaobjects.codegen.config", + "metaobjects.codegen.constants", + "metaobjects.codegen.format", + "metaobjects.codegen.generator", + "metaobjects.codegen.generator_registry", + "metaobjects.codegen.generators.find_inbound", + "metaobjects.codegen.generators.tph_plan", + "metaobjects.codegen.instance_artifacts", + "metaobjects.codegen.project_config", + "metaobjects.codegen.requirement_hooks", + "metaobjects.codegen.requirement_walk", + "metaobjects.codegen.type_map", + "metaobjects.codegen.value_objects" + ] + } + }, + "exemptEmitters": [ + { + "name": "docs-site builders", + "port": "typescript", + "path": "server/typescript/packages/docs-site/src", + "exempt": "`meta docs --scaffold-site` already copies the site's templates and assets into the project, and those are what an adopter changes. The builders assemble the page data the templates read.", + "ruling": "ADR-0034 Amendment 8, ruling 3" + } + ], + "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", + "port": "python", + "assertion": "depth", + "gap": 5, + "outside": ["metaobjects.codegen.generators.fr019_shared_enum", "metaobjects.codegen.generators.m2m_codegen"] + }, + { + "generator": "extractor", + "port": "python", + "assertion": "depth", + "gap": 4, + "outside": ["metaobjects.codegen.extract_delegate_emitter", "metaobjects.codegen.fr010_field_mapping"] + }, + {"generator": "filter-allowlist", "port": "python", "assertion": "depth", "gap": 5, "outside": ["metaobjects.codegen.generators.m2m_codegen"]}, + {"generator": "output-parser", "port": "python", "assertion": "depth", "gap": 4, "outside": ["metaobjects.codegen.extract_delegate_emitter"]}, + {"generator": "output-prompt", "port": "python", "assertion": "depth", "gap": 4, "outside": ["metaobjects.codegen.output_format_spec_emitter"]}, + {"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", + "assertion": "depth", + "gap": 3, + "outside": [ + "codegen-ts/src/templates/drizzle-schema.ts", + "codegen-ts/src/templates/entity-constants.ts", + "codegen-ts/src/templates/entity-file.ts", + "codegen-ts/src/templates/enums-file.ts", + "codegen-ts/src/templates/filter-allowlist.ts", + "codegen-ts/src/templates/filter-type.ts", + "codegen-ts/src/templates/inferred-types.ts", + "codegen-ts/src/templates/projection-decl.ts", + "codegen-ts/src/templates/tph-discriminator.ts", + "codegen-ts/src/templates/value-object-file.ts", + "codegen-ts/src/templates/zod-validators.ts" + ] + }, + {"generator": "names", "port": "typescript", "assertion": "depth", "gap": 3, "outside": ["codegen-ts/src/templates/names-decl.ts"]}, + { + "generator": "queries", + "port": "typescript", + "assertion": "depth", + "gap": 3, + "outside": [ + "codegen-ts/src/templates/queries-file.ts", + "codegen-ts/src/templates/queries.ts", + "codegen-ts/src/templates/tph-discriminator.ts", + "codegen-ts/src/templates/zod-validators.ts" + ] + }, + { + "generator": "routes", + "port": "typescript", + "assertion": "depth", + "gap": 3, + "outside": [ + "codegen-ts/src/templates/queries.ts", + "codegen-ts/src/templates/routes-index.ts", + "codegen-ts/src/templates/tph-discriminator.ts", + "codegen-ts/src/templates/zod-validators.ts" + ] + }, + { + "generator": "routes-hono", + "port": "typescript", + "assertion": "depth", + "gap": 3, + "outside": ["codegen-ts/src/templates/queries.ts", "codegen-ts/src/templates/routes-index.ts", "codegen-ts/src/templates/zod-validators.ts"] + }, + {"generator": "form", "port": "typescript", "assertion": "depth", "gap": 3, "outside": ["codegen-ts-react/src/templates/form-file.ts"]}, + { + "generator": "grid", + "port": "typescript", + "assertion": "depth", + "gap": 3, + "outside": ["codegen-ts-tanstack/src/templates/columns-file.ts", "codegen-ts/src/templates/zod-validators.ts"] + }, + { + "generator": "grid-hook", + "port": "typescript", + "assertion": "depth", + "gap": 3, + "outside": [ + "codegen-ts-tanstack/src/templates/grid-hook-file.ts", + "codegen-ts/src/templates/entity-meta-file.ts", + "codegen-ts/src/templates/zod-validators.ts" + ] + }, + { + "generator": "hooks", + "port": "typescript", + "assertion": "depth", + "gap": 3, + "outside": [ + "codegen-ts-tanstack/src/templates/hooks-file.ts", + "codegen-ts/src/templates/entity-meta-file.ts", + "codegen-ts/src/templates/zod-validators.ts" + ] + }, + {"generator": "callable", "port": "typescript", "assertion": "depth", "gap": 3, "outside": ["codegen-ts/src/templates/callable-file.ts"]}, + { + "generator": "cube-model", + "port": "typescript", + "assertion": "depth", + "gap": 3, + "outside": [ + "codegen-ts/src/cube/build-cube-model.ts", + "codegen-ts/src/cube/cube-errors.ts", + "codegen-ts/src/cube/cube-model-spec.ts", + "codegen-ts/src/cube/cube-yaml.ts" + ] + }, + {"generator": "extractor", "port": "typescript", "assertion": "depth", "gap": 3, "outside": ["codegen-ts/src/templates/extractor.ts"]}, + {"generator": "output-parser", "port": "typescript", "assertion": "depth", "gap": 3, "outside": ["codegen-ts/src/templates/output-parser.ts"]}, + {"generator": "output-prompt", "port": "typescript", "assertion": "depth", "gap": 3, "outside": ["codegen-ts/src/templates/output-prompt.ts"]}, + { + "generator": "prompt-render", + "port": "typescript", + "assertion": "depth", + "gap": 3, + "outside": ["codegen-ts/src/templates/value-object-import.ts"] + }, + {"generator": "render-helper", "port": "typescript", "assertion": "depth", "gap": 3, "outside": ["codegen-ts/src/templates/render-helper.ts"]}, + {"generator": "trace-helper", "port": "typescript", "assertion": "depth", "gap": 3, "outside": ["codegen-ts/src/templates/trace-helper.ts"]}, + { + "generator": "api-docs", + "port": "typescript", + "assertion": "depth", + "gap": 3, + "outside": ["codegen-ts/src/generators/api-doc-render.ts", "codegen-ts/src/generators/api-model.ts"] + }, + { + "generator": "docs", + "port": "typescript", + "assertion": "depth", + "gap": 3, + "outside": [ + "codegen-ts/src/generators/docs-data-builder.ts", + "codegen-ts/src/generators/template-doc-builder.ts", + "codegen-ts/src/render-engine/doc-page.ts", + "codegen-ts/src/templates/mermaid-er.ts" + ] + }, + { + "generator": "shared-model", + "port": "typescript", + "assertion": "depth", + "gap": 3, + "outside": ["codegen-ts/src/shared-model/build-shared-model.ts"] + }, + {"generator": "agent-docs", "port": "typescript", "assertion": "completeness", "gap": 2}, + {"generator": "requirements", "port": "typescript", "assertion": "completeness", "gap": 2} + ] } } diff --git a/scripts/ci-local.sh b/scripts/ci-local.sh index 707a3653c..2383a07ae 100755 --- a/scripts/ci-local.sh +++ b/scripts/ci-local.sh @@ -427,7 +427,7 @@ gate_site_reference() { bun_install && bun scripts/build-site-reference.ts --che # Also runs the Maven-coordinate derivation`s own tests: the same rule serves four release # scripts and two of them used to hardcode major 7, so the 1.0 cases (never released, never # exercised) are the ones worth a test at all. See scripts/maven-coordinate.mjs. -gate_release_tag() { bun test scripts/finish-release.test.ts && node scripts/test-maven-coordinate.mjs && bash tools/prerelease/test-prerelease-link.sh; } +gate_release_tag() { bun test scripts/finish-release.test.ts scripts/eject-release-gate.test.ts && node scripts/test-maven-coordinate.mjs && bash tools/prerelease/test-prerelease-link.sh; } # ── repo-root scripts/ typechecks ───────────────────────────────────────────── # `bun test` transpiles per file and never typechecks, and `bun run --filter '*' @@ -633,13 +633,18 @@ gate_conf_csharp() { # NOTE: these -Dtest= lists are a CHERRY-PICK, so a new test class that is not named here # runs in NO per-push lane and reports green. Adding a test to this module means adding its # class name here too — that is not a convention, it is the only thing that makes it run. +# The eject contract (ADR-0034 Amendment 8) runs in `maven-plugin`, the module that owns the +# eject goal and the only one that sees every codegen module, so that module is built here +# for the one test. Kotlin's runs in its own lane below. gate_conf_java() { ( cd server/java \ && mvn -pl metadata,render,codegen-base,codegen-spring -am install -DskipTests -q \ && mvn -pl metadata test -Dtest='ConformanceTest,YamlConformanceTest,ObjectModelConformanceTest,RegistryManifestConformanceTest' -q \ && mvn -pl render test -Dtest='RenderCrossPortReportTest,VerifyConformanceTest,ExtractConformanceTest,OutputPromptConformanceTest' -q \ && mvn -pl codegen-base test -Dtest='GeneratedFileWriterTest,JavaCodegenWriteGuardTest,JavaAccessorNameCollisionTest' -q \ - && mvn -pl codegen-spring test -Dtest='ValidationConformanceTest,GeneratorRegistryConformanceTest,NoMagicPhysicalNamesTest,CodegenCompileConformanceTest,IllegalRecordComponentNameTest,SpringIllegalNameCompileRunTest' -q ) + && mvn -pl codegen-spring test -Dtest='ValidationConformanceTest,GeneratorRegistryConformanceTest,NoMagicPhysicalNamesTest,CodegenCompileConformanceTest,IllegalRecordComponentNameTest,SpringIllegalNameCompileRunTest' -q \ + && mvn -pl maven-plugin -am install -DskipTests -q \ + && mvn -pl maven-plugin test -Dtest='EjectContractTest' -q ) } gate_conf_python() { # FULL suite, not cherry-picked paths. Until 2026-07-19 this ran only @@ -662,7 +667,7 @@ gate_conf_python() { gate_conf_kotlin() { ( cd server/java \ && mvn -pl codegen-kotlin -am install -DskipTests -q \ - && mvn -pl codegen-kotlin test -Dtest='ObjectModelConformanceTest,OutputPromptConformanceTest,ValidationConformanceTest,GeneratorRegistryConformanceTest,RegistryManifestConformanceTest,NoMagicPhysicalNamesTest,CodegenCompileConformanceTest' -q ) + && mvn -pl codegen-kotlin test -Dtest='ObjectModelConformanceTest,OutputPromptConformanceTest,ValidationConformanceTest,GeneratorRegistryConformanceTest,RegistryManifestConformanceTest,NoMagicPhysicalNamesTest,CodegenCompileConformanceTest,EjectContractTest' -q ) } # ── conformance.yml — full reactor + drift/mutation gates ───────────────────── diff --git a/scripts/eject-release-gate.mjs b/scripts/eject-release-gate.mjs new file mode 100644 index 000000000..bfb6f3fe2 --- /dev/null +++ b/scripts/eject-release-gate.mjs @@ -0,0 +1,113 @@ +#!/usr/bin/env node +/** + * The eject release gate (ADR-0034 Amendment 8): a FINAL version is not released while the + * eject contract's pending list has entries. + * + * node scripts/eject-release-gate.mjs 1.1.0 # exit 1 and say why, or exit 0 + * import { ejectReleaseRefusal } from "./eject-release-gate.mjs" + * + * `fixtures/generator-registry-conformance/registry.json` lists, under `eject.pending`, + * every place a stock generator's owned copy does not yet hold its emit. Each port's + * eject-contract test passes with entries in that list, which is what lets work land one + * gap at a time. It is also what would let a release go out with gaps left, so every + * scripted path that publishes or tags asks here before it does anything irreversible: + * + * - `scripts/release.mjs`, in its preflight, before the version bump and the first publish. + * - `scripts/finish-release.mjs`, before it cuts `v`. Every final release ends + * there, including one where npm sat out and `release.mjs` never ran. + * - the four publish workflows (`.github/workflows/publish-*.yml`), as the step after + * checkout, with the version that workflow is about to publish. + * + * A publish typed by hand passes through nothing. `docs/RELEASING.md` has this command + * before each one. + * + * A prerelease (`1.1.0-rc.3`, or `1.1.0rc3` as PyPI spells it) is allowed with entries left: + * a release candidate is how the remaining work is tried against an adopter. A version with + * no prerelease suffix is refused while an entry remains, and when the list cannot be read. + */ +import { existsSync, readFileSync, realpathSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +/** The shared file, relative to the repository root. */ +export const EJECT_MANIFEST = "fixtures/generator-registry-conformance/registry.json"; + +/** + * @typedef {{generator: string, port: string, assertion: string, gap: number}} PendingEntry + * @typedef {{eject?: {pending?: PendingEntry[]}}} EjectManifest + */ + +/** + * `major.minor.patch`, then nothing (a final version) or a prerelease suffix: `-rc.3` as npm, + * NuGet and Maven spell it, or the PEP 440 segment PyPI uses (`rc3`, `a1`, `b2`). + */ +const VERSION = /^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+|(?:a|b|rc)\d+)?$/; + +/** True for a version with a prerelease suffix, which the gate lets through. */ +/** @type {(version: string) => boolean} */ +export const isPrerelease = (version) => VERSION.exec(version)?.[1] !== undefined; + +/** + * Why `version` may not be released, or `null` when it may. + * + * @param {string} version + * @param {EjectManifest} manifest the parsed shared file + * @returns {string | null} + */ +export function ejectReleaseRefusal(version, manifest) { + if (isPrerelease(version)) return null; + const pending = manifest.eject?.pending; + if (!Array.isArray(pending)) { + // Fail closed. A file this gate cannot read is not evidence that nothing is pending. + return `${EJECT_MANIFEST} has no readable eject.pending list, ` + + `so ${version} cannot be shown to meet the eject contract`; + } + if (pending.length === 0) return null; + + const byPort = new Map(); + for (const entry of pending) byPort.set(entry.port, (byPort.get(entry.port) ?? 0) + 1); + const counts = [...byPort].sort(([a], [b]) => a.localeCompare(b)).map(([port, n]) => `${port} ${n}`).join(", "); + return [ + `${version} is a final release, and the eject contract does not hold yet (ADR-0034 Amendment 8).`, + ` ${pending.length} entr${pending.length === 1 ? "y is" : "ies are"} left in eject.pending of ${EJECT_MANIFEST}: ${counts}.`, + " Close each gap and delete its entry; a port's eject-contract test names what is left.", + " A release candidate (a version with a prerelease suffix, e.g. -rc.1) is not refused.", + ].join("\n"); +} + +/** + * {@link ejectReleaseRefusal} over the shared file of the repository at `root`. + * + * @param {string} version + * @param {string} root the repository root + * @returns {string | null} + */ +export function ejectReleaseRefusalAt(version, root) { + if (isPrerelease(version)) return null; + const file = join(root, EJECT_MANIFEST); + if (!existsSync(file)) return `${EJECT_MANIFEST} is missing, so ${version} cannot be shown to meet the eject contract`; + /** @type {EjectManifest} */ + let manifest; + try { + manifest = JSON.parse(readFileSync(file, "utf8")); + } catch (e) { + return `${EJECT_MANIFEST} is not valid JSON: ${e instanceof Error ? e.message : String(e)}`; + } + return ejectReleaseRefusal(version, manifest); +} + +// ── CLI ─────────────────────────────────────────────────────────────────────── +if (process.argv[1] && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))) { + const version = process.argv[2]; + if (version === undefined || !VERSION.test(version)) { + console.error("usage: node scripts/eject-release-gate.mjs (e.g. 1.1.0, 1.1.0-rc.3, or PyPI's 1.1.0rc3)"); + process.exit(2); + } + const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + const refusal = ejectReleaseRefusalAt(version, root); + if (refusal !== null) { + console.error(`\x1b[31m✗\x1b[0m ${refusal}`); + process.exit(1); + } + console.log(`\x1b[32m✓\x1b[0m eject contract: ${version} may be released`); +} diff --git a/scripts/eject-release-gate.test.ts b/scripts/eject-release-gate.test.ts new file mode 100644 index 000000000..1c72aec0d --- /dev/null +++ b/scripts/eject-release-gate.test.ts @@ -0,0 +1,226 @@ +// The eject release gate (scripts/eject-release-gate.mjs): a final version is refused while +// the eject contract's pending list has entries. Both release scripts and the four publish +// workflows call it, so what it refuses and what it lets through are pinned here. The wiring +// is pinned by running it: `release.mjs` and each workflow's gate step below, +// `finish-release.mjs` in scripts/finish-release.test.ts (gate 0). An unwired gate refuses +// nothing. +import { afterAll, describe, expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { EJECT_MANIFEST, ejectReleaseRefusal, ejectReleaseRefusalAt } from "./eject-release-gate.mjs"; + +const REPO = resolve(import.meta.dirname, ".."); +const ENTRY = { generator: "entity", port: "typescript", assertion: "depth", gap: 3 }; +const manifest = (pending: (typeof ENTRY)[]) => ({ eject: { pending } }); + +const created: string[] = []; +afterAll(() => { for (const d of created) rmSync(d, { recursive: true, force: true }); }); + +/** A throwaway repository root holding the gate, any other `scripts`, and one shared file. */ +function repoWith(body: string | undefined, scripts: string[] = []): string { + const root = mkdtempSync(join(tmpdir(), "eject-release-gate-")); + created.push(root); + mkdirSync(join(root, "scripts"), { recursive: true }); + for (const script of ["eject-release-gate.mjs", ...scripts]) { + writeFileSync(join(root, "scripts", script), readFileSync(join(REPO, "scripts", script))); + } + if (body !== undefined) put(root, EJECT_MANIFEST, body); + return root; +} + +function put(root: string, rel: string, body: string): void { + mkdirSync(dirname(join(root, rel)), { recursive: true }); + writeFileSync(join(root, rel), body); +} + +describe("eject release gate — what it refuses", () => { + test("a final version with entries left, naming how many per port", () => { + const refusal = ejectReleaseRefusal("1.1.0", manifest([ENTRY, { ...ENTRY, port: "java" }, ENTRY])); + expect(refusal).toContain("1.1.0 is a final release"); + expect(refusal).toContain("3 entries are left"); + expect(refusal).toContain("java 1, typescript 2"); + }); + + test("a final version with one entry left", () => { + expect(ejectReleaseRefusal("1.1.0", manifest([ENTRY]))).toContain("1 entry is left"); + }); + + test("a shared file it cannot read the list from", () => { + for (const broken of [{}, { eject: {} }, { eject: { pending: "none" } }, { eject: { pending: null } }]) { + expect(ejectReleaseRefusal("1.1.0", broken as never)).toContain("no readable eject.pending"); + } + }); + + test("a missing or malformed shared file", () => { + expect(ejectReleaseRefusalAt("1.1.0", repoWith(undefined))).toContain("is missing"); + expect(ejectReleaseRefusalAt("1.1.0", repoWith("{ not json"))).toContain("is not valid JSON"); + }); +}); + +describe("eject release gate — what it lets through", () => { + test("a final version once the list is empty", () => { + expect(ejectReleaseRefusal("1.1.0", manifest([]))).toBeNull(); + }); + + // PyPI spells a prerelease without a hyphen (PEP 440): `rc3`, `a1`, `b2`. + test("a prerelease in any registry's spelling, whatever the list says", () => { + for (const version of ["1.1.0-rc.1", "1.1.0-rc.3", "2.0.0-beta.1", "1.1.0rc3", "1.1.0a1", "1.1.0b2"]) { + expect(ejectReleaseRefusal(version, manifest([ENTRY]))).toBeNull(); + expect(ejectReleaseRefusalAt(version, repoWith(undefined))).toBeNull(); + } + }); +}); + +describe("eject release gate — the command and its wiring", () => { + const node = (cwd: string, script: string, ...args: string[]) => { + const r = spawnSync("node", [join(cwd, "scripts", script), ...args], { encoding: "utf8", cwd }); + return { code: r.status, out: `${r.stdout}${r.stderr}` }; + }; + const run = (root: string, ...args: string[]) => node(root, "eject-release-gate.mjs", ...args); + + test("exits 1 with the reason for a refused version, 0 otherwise, 2 for a bad argument", () => { + const blocked = repoWith(JSON.stringify(manifest([ENTRY]))); + expect(run(blocked, "1.1.0").code).toBe(1); + expect(run(blocked, "1.1.0").out).toContain("1 entry is left"); + for (const candidate of ["1.1.0-rc.3", "1.1.0rc3", "1.1.0a1", "1.1.0b2"]) expect(run(blocked, candidate).code).toBe(0); + expect(run(repoWith(JSON.stringify(manifest([]))), "1.1.0").code).toBe(0); + expect(run(blocked).code).toBe(2); + for (const bad of ["latest", "1.1.0rc", "1.1.0.post1"]) expect(run(blocked, bad).code).toBe(2); + }); + + test("refuses a final version when the script path goes through a symlink", () => { + const blocked = repoWith(JSON.stringify(manifest([ENTRY]))); + const linked = join(repoWith(undefined), "linked"); + symlinkSync(blocked, linked, "dir"); + const refused = run(linked, "1.1.0"); + expect(refused.code).toBe(1); + expect(refused.out).toContain("1 entry is left"); + }); + + // ADR-0034 Amendment 8 makes the gate the first preflight check of `release.mjs`. The + // check after it is the branch check, so a fixture on another branch tells the two apart: + // a refused version stops before "must be on main", and an allowed one reaches it. + test("release.mjs asks the gate first in its preflight, and stops on a refusal", () => { + const RELEASE_SCRIPTS = ["release.mjs", "publish-set.mjs", "maven-coordinate.mjs"]; + const onBranch = (pending: (typeof ENTRY)[]) => { + const root = repoWith(JSON.stringify(manifest(pending)), RELEASE_SCRIPTS); + const git = (...args: string[]) => spawnSync("git", ["-C", root, ...args], { encoding: "utf8" }); + git("init", "--quiet", "-b", "not-main"); + git("-c", "user.name=Fixture", "-c", "user.email=fixture@example.invalid", "-c", "core.hooksPath=/dev/null", + "-c", "commit.gpgsign=false", "commit", "--quiet", "--allow-empty", "-m", "fixture"); + return root; + }; + const release = (root: string, version: string) => node(root, "release.mjs", version, "--preflight-only"); + + const blocked = onBranch([ENTRY]); + const refused = release(blocked, "1.1.0"); + expect(refused.code).not.toBe(0); + expect(refused.out).toContain("the eject contract does not hold yet"); + expect(refused.out).not.toContain("must be on main"); + + for (const allowed of [release(blocked, "1.1.0-rc.1"), release(onBranch([]), "1.1.0")]) { + expect(allowed.out).not.toContain("the eject contract does not hold yet"); + expect(allowed.out).toContain("must be on main (you are on not-main)"); + } + }); + + test("the shared file in this repository is one the gate can read", () => { + const refusal = ejectReleaseRefusalAt("1.1.0", REPO); + // Refused or not is the release branch's business; "cannot read it" is a defect here. + if (refusal !== null) expect(refusal).not.toContain("no readable"); + expect(ejectReleaseRefusalAt("1.1.0-rc.1", REPO)).toBeNull(); + }); +}); + +// A publish workflow runs on a tag push, which the repository's own checks cannot make. So +// the rule is held here (ADR-0034 Amendment 8): each workflow is parsed, its steps are +// walked in order, and its gate step is run as the shell it is against a throwaway +// repository whose committed version this test wrote. +describe("eject release gate — the publish workflows", () => { + type Step = { uses?: string; run?: string; env?: Record }; + type Workflow = { jobs: Record }; + const WORKFLOWS = join(REPO, ".github/workflows"); + const VERSION_INPUT = "${{ github.event.inputs.version }}"; + + /** Each publish workflow, the file its version is committed in, and that file with a given version. */ + const PUBLISHERS: { workflow: string; versionFile: string; final: string; candidate: string; committed: (v: string) => string }[] = [ + { + workflow: "publish-npm.yml", versionFile: "server/typescript/packages/cli/package.json", final: "1.1.0", candidate: "1.1.0-rc.1", + committed: (v) => `${JSON.stringify({ name: "@metaobjectsdev/cli", version: v }, null, 2)}\n`, + }, + { + workflow: "publish-python.yml", versionFile: "server/python/pyproject.toml", final: "1.1.0", candidate: "1.1.0rc1", + committed: (v) => `[project]\nname = "metaobjects"\nversion = "${v}"\n\n[tool.other]\nversion = "9.9.9"\n`, + }, + { + workflow: "publish-java.yml", versionFile: "server/java/pom.xml", final: "8.1.0", candidate: "8.1.0-rc.1", + committed: (v) => `\n 4.0.0\n ${v}\n` + + ` \n \n 9.9.9\n \n \n\n`, + }, + { + workflow: "publish-csharp.yml", versionFile: "server/csharp/Directory.Build.props", final: "1.1.0", candidate: "1.1.0-rc.1", + committed: (v) => `\n \n ${v}\n \n\n`, + }, + ]; + + const stepsOf = (workflow: string): Step[][] => + Object.values((Bun.YAML.parse(readFileSync(join(WORKFLOWS, workflow), "utf8")) as Workflow).jobs).map((job) => job.steps); + const isCheckout = (step: Step) => (step.uses ?? "").startsWith("actions/checkout@"); + const isGate = (step: Step) => (step.run ?? "").includes("scripts/eject-release-gate.mjs"); + const publishes = (step: Step) => + /\bbun publish\b|\bmvn\b.*\bdeploy\b|\bdotnet nuget push\b/.test(step.run ?? "") || + (step.uses ?? "").startsWith("pypa/gh-action-pypi-publish@"); + + /** Run a workflow's gate step the way the runner does, in a repository holding `versionBody`. */ + function runGate(p: (typeof PUBLISHERS)[number], pending: (typeof ENTRY)[], versionBody: string, input = "") { + const gate = stepsOf(p.workflow).flat().find(isGate); + if (gate?.run === undefined) throw new Error(`${p.workflow} has no eject release gate step`); + const root = repoWith(JSON.stringify(manifest(pending))); + put(root, p.versionFile, versionBody); + const env = Object.fromEntries(Object.entries(gate.env ?? {}).map(([k, v]) => [k, v.replace(VERSION_INPUT, input)])); + const r = spawnSync("bash", ["-e", "-c", gate.run], { encoding: "utf8", cwd: root, env: { ...process.env, ...env } }); + return { code: r.status, out: `${r.stdout}${r.stderr}` }; + } + + test("every publish workflow is listed here", () => { + const found = readdirSync(WORKFLOWS).filter((f) => f.startsWith("publish-")).sort(); + expect(found).toEqual(PUBLISHERS.map((p) => p.workflow).sort()); + }); + + for (const p of PUBLISHERS) { + test(`${p.workflow} asks the gate after checkout and before its first publishing step`, () => { + const publishing = stepsOf(p.workflow).filter((steps) => steps.some(publishes)); + expect(publishing.length).toBeGreaterThan(0); + for (const steps of publishing) { + const checkout = steps.findIndex(isCheckout); + const gate = steps.findIndex(isGate); + expect(checkout).toBeGreaterThan(-1); + expect(gate).toBeGreaterThan(checkout); + expect(gate).toBeLessThan(steps.findIndex(publishes)); + } + }); + + test(`${p.workflow} refuses the committed final version while an entry is left`, () => { + const refused = runGate(p, [ENTRY], p.committed(p.final)); + expect(refused.code).toBe(1); + expect(refused.out).toContain(`${p.final} is a final release`); + }); + + test(`${p.workflow} lets a committed release candidate through, and the version this repository commits`, () => { + expect(runGate(p, [ENTRY], p.committed(p.candidate)).code).toBe(0); + const real = runGate(p, [], readFileSync(join(REPO, p.versionFile), "utf8")); + expect(real.out).toContain("may be released"); + expect(real.code).toBe(0); + }); + } + + test("publish-csharp.yml gates the `version` input when one is given", () => { + const csharp = PUBLISHERS.find((p) => p.workflow === "publish-csharp.yml")!; + expect(runGate(csharp, [ENTRY], csharp.committed(csharp.final), "1.1.0-rc.1").code).toBe(0); + const refused = runGate(csharp, [ENTRY], csharp.committed(csharp.candidate), "1.1.0"); + expect(refused.code).toBe(1); + expect(refused.out).toContain("1.1.0 is a final release"); + }); +}); diff --git a/scripts/finish-release.mjs b/scripts/finish-release.mjs index 9fbeade48..b3c790952 100644 --- a/scripts/finish-release.mjs +++ b/scripts/finish-release.mjs @@ -36,6 +36,7 @@ import { existsSync, readFileSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { mavenVersion } from "./maven-coordinate.mjs"; +import { ejectReleaseRefusalAt } from "./eject-release-gate.mjs"; const REPO = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const CHECK_ONLY = process.argv.includes("--check"); @@ -64,6 +65,15 @@ if (VERSION === undefined || !/^\d+\.\d+\.\d+$/.test(VERSION)) { // had it independently and two were wrong — see scripts/maven-coordinate.mjs. const MAVEN_VERSION = mavenVersion(VERSION); +// ── 0. the eject contract holds ──────────────────────────────────────────── +// Every final release ends here, including one where npm sat out and `release.mjs`, which +// asks the same question before its first publish, never ran. A tag is not cut for a tree +// whose shared manifest still lists a generator that does not hand over its emit +// (ADR-0034 Amendment 8). +const ejectRefusal = ejectReleaseRefusalAt(VERSION, REPO); +if (ejectRefusal !== null) die(ejectRefusal); +ok("eject contract: the pending list is empty"); + // ── 1. the working tree is the tree that will be tagged ────────────────────── // A tag points at a COMMIT, so anything uncommitted is silently excluded. Tagging with // a dirty tree is how a payload fix sits on disk while the tag names the tree without it. diff --git a/scripts/finish-release.test.ts b/scripts/finish-release.test.ts index 01a97f48e..ea089fc87 100644 --- a/scripts/finish-release.test.ts +++ b/scripts/finish-release.test.ts @@ -34,6 +34,11 @@ const SCRIPT = readFileSync(resolve(REPO, "scripts/finish-release.mjs"), "utf8") // module is that ONE derivation serves four scripts. Stubbing it would let this suite pass // while the shipped derivation was wrong — which is the defect the module exists for. const MAVEN_COORD = readFileSync(resolve(REPO, "scripts/maven-coordinate.mjs"), "utf8"); +// Gate 0 asks the eject release gate, and the fixture ships the real one for the same reason. +const EJECT_GATE = readFileSync(resolve(REPO, "scripts/eject-release-gate.mjs"), "utf8"); +const EJECT_MANIFEST = "fixtures/generator-registry-conformance/registry.json"; +/** The shared file as the release branch leaves it: nothing pending. */ +const EJECT_CLEAR = `${JSON.stringify({ eject: { pending: [] } })}\n`; const created: string[] = []; afterAll(() => { for (const d of created) rmSync(d, { recursive: true, force: true }); }); @@ -133,6 +138,7 @@ function makeRepo(spec: Spec = {}): Fixture { "site-reference/index.html": "reference\n", "docs/llms/llms.txt": mirror, "docs/llms/llms-full.txt": mirror, + [EJECT_MANIFEST]: EJECT_CLEAR, ...spec.files, }; for (const rel of spec.omit ?? []) delete files[rel]; @@ -166,6 +172,7 @@ function makeRepo(spec: Spec = {}): Fixture { ...files, "scripts/finish-release.mjs": SCRIPT, "scripts/maven-coordinate.mjs": MAVEN_COORD, + "scripts/eject-release-gate.mjs": EJECT_GATE, })) { const full = join(work, rel); mkdirSync(dirname(full), { recursive: true }); @@ -227,6 +234,36 @@ describe("finish-release: the version argument", () => { }); }); +describe("finish-release gate 0: the eject contract", () => { + const withEject = (eject: unknown) => makeRepo({ files: { [EJECT_MANIFEST]: `${JSON.stringify({ eject })}\n` } }); + const entry = { generator: "entity", port: "typescript", assertion: "depth", gap: 3 }; + + test("an entry left in the pending list is refused, before any other gate", () => { + const r = withEject({ pending: [entry] }).run(RELEASE, "--check"); + expect(r.code).not.toBe(0); + expect(r.out).toContain("the eject contract does not hold yet"); + expect(r.out).toContain("1 entry is left"); + expect(r.out).not.toContain("on main, clean, synced with origin"); + }); + + test("a shared file with no pending list is refused, not read as empty", () => { + const r = withEject({}).run(RELEASE, "--check"); + expect(r.code).not.toBe(0); + expect(r.out).toContain("no readable eject.pending"); + }); + + test("a tree with no shared file is refused, not waved through", () => { + const r = makeRepo({ omit: [EJECT_MANIFEST] }).run(RELEASE, "--check"); + expect(r.code).not.toBe(0); + expect(r.out).toContain("is missing"); + }); + + test("an empty list passes this gate", () => { + const r = makeRepo().run(RELEASE, "--check"); + expect(r.out).toContain("eject contract: the pending list is empty"); + }); +}); + describe("finish-release gate 1: the tree that will be tagged", () => { test("a dirty tree is refused — a tag names a COMMIT, not the disk", () => { const f = makeRepo(); diff --git a/scripts/release.mjs b/scripts/release.mjs index 8029e991b..ae481bdb9 100644 --- a/scripts/release.mjs +++ b/scripts/release.mjs @@ -27,9 +27,11 @@ import { execSync } from "node:child_process"; import { readFileSync, writeFileSync, mkdtempSync, mkdirSync, existsSync } from "node:fs"; import { join, dirname } from "node:path"; import { tmpdir, homedir } from "node:os"; +import { fileURLToPath } from "node:url"; import { createInterface } from "node:readline/promises"; import { publishSet } from "./publish-set.mjs"; import { mavenVersion } from "./maven-coordinate.mjs"; +import { ejectReleaseRefusalAt } from "./eject-release-gate.mjs"; const VERSION = process.argv[2]; const DRY = process.argv.includes("--dry-run"); @@ -60,6 +62,13 @@ if (!VERSION || !/^\d+\.\d+\.\d+(-rc\.\d+)?$/.test(VERSION)) { // --- PHASE 0: preflight (all must pass) ----------------------------------- console.log(`\n── Releasing ${VERSION}${DRY ? " (DRY RUN)" : ""} ──\n`); +// A final version does not go out while a stock generator's owned copy still calls emit +// code left in the package (ADR-0034 Amendment 8). First, because it is offline and +// instant, and everything after it costs time or cannot be undone. A release candidate +// passes: it is how the remaining gaps are tried against an adopter. +const ejectRefusal = ejectReleaseRefusalAt(VERSION, join(dirname(fileURLToPath(import.meta.url)), "..")); +if (ejectRefusal !== null) die(ejectRefusal); + const branch = out("git rev-parse --abbrev-ref HEAD"); if (branch !== "main") die(`must be on main (you are on ${branch})`); sh("git fetch origin", { quiet: true }); diff --git a/server/csharp/MetaObjects.Codegen.Tests/EjectContract.cs b/server/csharp/MetaObjects.Codegen.Tests/EjectContract.cs new file mode 100644 index 000000000..a62eb02cf --- /dev/null +++ b/server/csharp/MetaObjects.Codegen.Tests/EjectContract.cs @@ -0,0 +1,83 @@ +// The shared eject contract, as this port reads it (ADR-0034 Amendment 8). +// +// fixtures/generator-registry-conformance/registry.json says, for every generator, whether +// an owned copy holds its emit ("eject": "full") or why it is exempt, and in its top-level +// "eject" block what an owned copy may still reference (the engine surface) and every place +// the contract does not hold yet ("pending"). EjectContractTests asserts the contract; this +// class only reads the file. + +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace MetaObjects.Codegen.Tests; + +internal static class EjectContract +{ + internal const string Port = "csharp"; + + internal sealed record PendingEntry(string Generator, string Port, string Assertion, int Gap, IReadOnlyList? Outside); + + internal static string ManifestPath() => + Path.Combine(CorpusPaths.RepoRoot(), "fixtures", "generator-registry-conformance", "registry.json"); + + private static readonly Lazy Document = + new(() => JsonDocument.Parse(File.ReadAllText(ManifestPath()))); + + private static JsonElement Root => Document.Value.RootElement; + + private static JsonElement Eject => Root.GetProperty("eject"); + + /// Every generator in the shared file, with its eject declaration. + internal static IReadOnlyDictionary Generators() => + Root.GetProperty("generators").EnumerateObject().ToDictionary(p => p.Name, p => p.Value, StringComparer.Ordinal); + + /// The generators the shared file expects this port to expose. + internal static IReadOnlySet PortSlice() => + Generators() + .Where(g => g.Value.GetProperty("ports").EnumerateArray().Any(p => p.GetString() == Port)) + .Select(g => g.Key) + .ToHashSet(StringComparer.Ordinal); + + internal static bool IsExempt(string name) => + Generators()[name].GetProperty("eject").ValueKind == JsonValueKind.Object; + + internal static IReadOnlyList AllPending() => + Eject.GetProperty("pending").EnumerateArray() + .Select(p => new PendingEntry( + p.GetProperty("generator").GetString()!, + p.GetProperty("port").GetString()!, + p.GetProperty("assertion").GetString()!, + p.GetProperty("gap").GetInt32(), + p.TryGetProperty("outside", out var outside) + ? outside.EnumerateArray().Select(o => o.GetString()!).ToList() + : null)) + .ToList(); + + /// This port's pending entries for one assertion, by generator. A generator has + /// one entry per gap: a depth entry's Outside lists the types that gap closes. + internal static IReadOnlyDictionary> Pending(string assertion) => + AllPending() + .Where(p => p.Port == Port && p.Assertion == assertion) + .GroupBy(p => p.Generator, StringComparer.Ordinal) + .ToDictionary(g => g.Key, g => g.ToList(), StringComparer.Ordinal); + + private static JsonElement Surface => Eject.GetProperty("engineSurface").GetProperty(Port); + + internal static IReadOnlyList Engine() => + Surface.GetProperty("engine").EnumerateArray().Select(e => e.GetString()!).ToList(); + + /// ** matches any run of characters, * one dotted segment. + private static Regex Glob(string pattern) => + new("^" + string.Concat(Regex.Split(pattern, @"(\*\*|\*)").Select(part => + part == "**" ? ".*" : part == "*" ? "[^.]*" : Regex.Escape(part))) + "$"); + + private static readonly Lazy> Scope = new(() => + Surface.GetProperty("codegenPackages").EnumerateArray().Select(p => Glob(p.GetString()!)).ToList()); + + private static readonly Lazy> EnginePatterns = new(() => Engine().Select(Glob).ToList()); + + /// True when writes output: inside the codegen + /// namespaces, and not engine. + internal static bool OutsideEngineSurface(string typeName) => + Scope.Value.Any(p => p.IsMatch(typeName)) && !EnginePatterns.Value.Any(p => p.IsMatch(typeName)); +} diff --git a/server/csharp/MetaObjects.Codegen.Tests/EjectContractTests.cs b/server/csharp/MetaObjects.Codegen.Tests/EjectContractTests.cs new file mode 100644 index 000000000..c5545e93f --- /dev/null +++ b/server/csharp/MetaObjects.Codegen.Tests/EjectContractTests.cs @@ -0,0 +1,296 @@ +// The eject contract, asserted for the C# port (ADR-0034 Amendment 8). +// +// Every stock generator can be ejected, and the owned copy holds its emit. Three +// assertions, each driven by fixtures/generator-registry-conformance/registry.json: +// +// census every IGenerator the shipped assemblies define belongs to a registry +// entry, and the registry exposes exactly the names the shared file gives +// this port. +// completeness every registered generator ships eject source, unless the shared file +// exempts it with a reason. +// depth each generator, ejected ALONE by the real `dotnet meta eject` and compiled +// against the published assemblies, references nothing in the codegen +// namespaces outside the engine surface, and emits what the packaged +// generator emits. +// +// Where the contract does not hold yet, the shared file's `pending` list says so. A pending +// entry that passes fails here, and so does a gap that is not listed. + +using System.Reflection; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using MetaObjects.Loader; +using MetaObjects.Meta; +using Xunit; + +namespace MetaObjects.Codegen.Tests; + +public class EjectContractTests +{ + // --------------------------------------------------------------------------------- + // census + // --------------------------------------------------------------------------------- + + /// Every concrete in every MetaObjects assembly this + /// test project is built with: the shipped packages, found by file, not by a list. + private static List ShippedGeneratorTypes() => + Directory.GetFiles(AppContext.BaseDirectory, "MetaObjects*.dll") + .Where(path => !Path.GetFileNameWithoutExtension(path).EndsWith("Tests", StringComparison.Ordinal)) + .Select(Assembly.LoadFrom) + .SelectMany(asm => asm.GetTypes()) + .Where(t => typeof(IGenerator).IsAssignableFrom(t) && t is { IsAbstract: false, IsInterface: false }) + .ToList(); + + [Fact] + public void Census_every_generator_type_belongs_to_a_registry_entry() + { + var registered = GeneratorRegistry.List() + .Select(e => e.Factory(new GeneratorBuildContext()).GetType()) + .ToHashSet(); + var shipped = ShippedGeneratorTypes(); + Assert.NotEmpty(shipped); + var unregistered = shipped.Where(t => !registered.Contains(t)).Select(t => t.FullName).OrderBy(n => n).ToList(); + Assert.True(unregistered.Count == 0, + "generator types no registry entry constructs, so no catalog, eject command or test sees them: " + + $"[{string.Join(", ", unregistered)}]. Register each in GeneratorRegistry.cs and in {EjectContract.ManifestPath()}."); + } + + [Fact] + public void Census_the_registry_exposes_the_names_the_shared_file_gives_this_port() + { + var expected = EjectContract.PortSlice(); + var actual = GeneratorRegistry.Entries.Keys.ToHashSet(StringComparer.Ordinal); + var pending = EjectContract.Pending("census").Keys.OrderBy(n => n, StringComparer.Ordinal).ToList(); + Assert.Empty(actual.Except(expected)); + var missing = expected.Except(actual).OrderBy(n => n, StringComparer.Ordinal).ToList(); + Assert.True(missing.SequenceEqual(pending), + $"the shared file expects these generators in the C# registry and they are not there: [{string.Join(", ", missing)}]; " + + $"pending census entries: [{string.Join(", ", pending)}]. Register the generator, or remove the pending entry of one that is now registered."); + } + + // --------------------------------------------------------------------------------- + // completeness + // --------------------------------------------------------------------------------- + + [Fact] + public void Completeness_every_registered_generator_ships_eject_source_unless_exempt() + { + var ejectable = EjectableGenerators.Entries().Select(e => e.Name).ToHashSet(StringComparer.Ordinal); + var pending = EjectContract.Pending("completeness"); + var problems = new List(); + foreach (var name in GeneratorRegistry.Entries.Keys.OrderBy(n => n, StringComparer.Ordinal)) + { + if (EjectContract.IsExempt(name)) + { + if (ejectable.Contains(name)) problems.Add($"{name}: exempt in the shared file but ejectable; drop the exemption"); + continue; + } + if (ejectable.Contains(name) && pending.ContainsKey(name)) + problems.Add($"{name}: ejectable now; remove its pending completeness entry"); + if (!ejectable.Contains(name) && !pending.ContainsKey(name)) + problems.Add($"{name}: no eject source, and the shared file does not exempt it"); + } + problems.AddRange(pending.Keys.Where(n => !GeneratorRegistry.Entries.ContainsKey(n)) + .Select(n => $"{n}: pending completeness entry for a generator this port does not register")); + Assert.True(problems.Count == 0, string.Join("\n", problems)); + } + + // --------------------------------------------------------------------------------- + // depth + // --------------------------------------------------------------------------------- + + // The SDK generates these for a project with enable, + // which is what `dotnet meta eject` writes; a standalone Roslyn compile has no SDK step. + private const string ImplicitUsings = + "global using global::System;\n" + + "global using global::System.Collections.Generic;\n" + + "global using global::System.IO;\n" + + "global using global::System.Linq;\n" + + "global using global::System.Threading;\n" + + "global using global::System.Threading.Tasks;\n"; + + private sealed record OwnedCopy(CSharpCompilation Compilation, IReadOnlyList Trees, Assembly Assembly); + + /// The owned copy of one generator, alone: every file the real dotnet meta + /// eject writes under codegen/generators in an empty project (the generator and + /// the emitters handed over with it), compiled against the published assemblies only, + /// never a sibling generator's source. + private static OwnedCopy CompileAlone(string stableName) + { + var project = Directory.CreateTempSubdirectory("eject-contract-").FullName; + var options = new CSharpParseOptions(LanguageVersion.CSharp12); + List trees; + try + { + var outcome = MetaObjects.Cli.EjectCommand.Run([stableName], project, force: false); + Assert.True(outcome.ExitCode == 0, string.Join("\n", outcome.Err)); + trees = Directory.GetFiles(Path.Combine(project, "codegen", "generators"), "*.cs") + .OrderBy(f => f, StringComparer.Ordinal) + .Select(f => CSharpSyntaxTree.ParseText(File.ReadAllText(f), options, path: Path.GetFileName(f))) + .ToList(); + Assert.NotEmpty(trees); + } + finally + { + Directory.Delete(project, recursive: true); + } + var usings = CSharpSyntaxTree.ParseText(ImplicitUsings, options, path: "GlobalUsings.g.cs"); + var references = DbContextCompileTests.BuildReferences(); + references.Add(MetadataReference.CreateFromFile(typeof(IGenerator).Assembly.Location)); + references.Add(MetadataReference.CreateFromFile(typeof(MetaObject).Assembly.Location)); + references.Add(MetadataReference.CreateFromFile(typeof(MetaObjects.Render.FilesystemProvider).Assembly.Location)); + var compilation = CSharpCompilation.Create( + "owned_" + stableName.Replace('-', '_') + "_" + Guid.NewGuid().ToString("N"), + [.. trees, usings], references, new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + using var ms = new MemoryStream(); + var emit = compilation.Emit(ms); + var errors = emit.Diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error).Select(d => d.ToString()).ToList(); + Assert.True(emit.Success, + $"\"{stableName}\", ejected alone, does not compile against the published API ({errors.Count} error(s)):\n" + + string.Join("\n", errors)); + return new OwnedCopy(compilation, trees, Assembly.Load(ms.ToArray())); + } + + /// Every type of the packaged codegen assembly the owned copy references, outside + /// the engine surface. Read from the semantic model, so a using that brings a whole + /// namespace into scope counts only for what the copy actually names. + private static List OutsideEngineSurface(OwnedCopy copy) + { + var codegen = typeof(IGenerator).Assembly.GetName().Name; + var outside = new SortedSet(StringComparer.Ordinal); + foreach (var tree in copy.Trees) + { + var model = copy.Compilation.GetSemanticModel(tree); + foreach (var name in tree.GetRoot().DescendantNodes().OfType()) + { + var info = model.GetSymbolInfo(name); + var symbol = info.Symbol ?? info.CandidateSymbols.FirstOrDefault(); + var type = symbol as INamedTypeSymbol ?? symbol?.ContainingType; + if (type is null || type.ContainingAssembly?.Name != codegen) continue; + while (type.ContainingType is not null) type = type.ContainingType; + var fullName = type.ContainingNamespace.ToDisplayString() + "." + type.Name; + if (EjectContract.OutsideEngineSurface(fullName)) outside.Add(fullName); + } + } + return outside.ToList(); + } + + [Fact] + public void Depth_an_owned_copy_references_only_the_engine_surface() + { + var pending = EjectContract.Pending("depth"); + var ejectable = EjectableGenerators.Entries().Select(e => e.Name).OrderBy(n => n, StringComparer.Ordinal).ToList(); + var problems = new List(); + foreach (var name in ejectable) + { + var actual = OutsideEngineSurface(CompileAlone(name)); + var expected = pending.TryGetValue(name, out var entries) + ? entries.SelectMany(e => e.Outside ?? []).OrderBy(n => n, StringComparer.Ordinal).ToList() + : []; + if (actual.SequenceEqual(expected)) continue; + if (expected.Count == 0) + problems.Add($"{name}: its owned copy calls emit code that stays in the package: [{string.Join(", ", actual)}]. " + + "Hand it over with the generator; a reference to the engine belongs in eject.engineSurface.csharp.engine."); + else if (actual.Count == 0) + problems.Add($"{name}: its owned copy is whole now; remove its pending depth entry"); + else + problems.Add($"{name}: pending depth entries list [{string.Join(", ", expected)}], the owned copy references [{string.Join(", ", actual)}]"); + } + problems.AddRange(pending.Keys.Where(n => !ejectable.Contains(n)) + .Select(n => $"{n}: pending depth entry for a generator that is not ejectable")); + Assert.True(problems.Count == 0, string.Join("\n", problems)); + } + + [Fact] + public void Depth_every_engine_surface_entry_names_a_real_type() + { + var asm = typeof(IGenerator).Assembly; + var stale = EjectContract.Engine().Where(n => !n.Contains('*') && asm.GetType(n) is null).ToList(); + Assert.True(stale.Count == 0, $"eject.engineSurface.csharp.engine names types that do not exist: [{string.Join(", ", stale)}]"); + } + + private sealed record Corpus(string Label, MetaRoot Root, string? TemplateRoot); + + /// Shared models that between them make every ejectable generator emit. + private static List Corpora() + { + MetaRoot Directory(params string[] segments) + { + var load = MetaDataLoader.FromDirectory(Path.Combine([CorpusPaths.RepoRoot(), "fixtures", .. segments]), strict: true); + Assert.Empty(load.Errors.Select(e => e.Code + ": " + e.Message)); + return load.Root; + } + return + [ + new("fitness", EjectedGeneratorCompileTests.LoadCorpus(), CorpusPaths.FitnessTemplateRoot), + new("requirements", Directory("requirement-test-identity-conformance", "worked-example", "input"), null), + new("stored-proc", Directory("conformance", "parameter-ref-on-stored-proc", "input"), null), + // A reply with a nested object: the extractor emits only over one. + new("nested-reply", Directory("template-output-render-conformance", "xpkg-collision-json"), + Path.Combine(CorpusPaths.RepoRoot(), "fixtures", "template-output-render-conformance", "templates")), + ]; + } + + private static IGenerator Instantiate(Assembly owned, string? templateRoot) + { + var type = owned.GetTypes().Single(t => typeof(IGenerator).IsAssignableFrom(t) && !t.IsAbstract); + // The one generator constructed with an argument takes the template root. + var takesTemplateRoot = type.GetConstructors().Any(c => + c.GetParameters() is [{ ParameterType: var p }] && p == typeof(string)); + return (IGenerator)Activator.CreateInstance(type, takesTemplateRoot ? [templateRoot ?? Path.GetTempPath()] : [])!; + } + + public static TheoryData Ejectable() + { + var data = new TheoryData(); + foreach (var entry in EjectableGenerators.Entries()) data.Add(entry.Name); + return data; + } + + [Theory] + [MemberData(nameof(Ejectable))] + public void Depth_an_unchanged_owned_copy_emits_what_the_packaged_generator_emits(string stableName) + { + var copy = CompileAlone(stableName); + // The one difference an owned copy's output is allowed: it imports the helper + // runtime the adopter owns where packaged output imports the package's. + var usesRuntime = HelperRuntime.UsedBy(EjectableGenerators.ReadSource(stableName)!); + string Expected(string packaged) => usesRuntime + ? packaged.Replace($"using {HelperRuntime.PackagedNamespace};", $"using {HelperRuntime.OwnedNamespace};", StringComparison.Ordinal) + : packaged; + + var emitted = 0; + foreach (var corpus in Corpora()) + { + List Run(IGenerator generator) => + generator.Generate(new GenContext + { + Entities = corpus.Root.Objects(), + Root = corpus.Root, + Config = new GenConfig { OutDir = "/tmp", Namespace = "Acme.Generated", IncludeNames = true }, + }).OrderBy(f => f.Path, StringComparer.Ordinal).ToList(); + + var packaged = Run(GeneratorRegistry.Get(stableName)!.Factory(new GeneratorBuildContext(corpus.TemplateRoot))); + var owned = Run(Instantiate(copy.Assembly, corpus.TemplateRoot)); + emitted += packaged.Count; + Assert.Equal(packaged.Select(f => f.Path), owned.Select(f => f.Path)); + for (var i = 0; i < packaged.Count; i++) + Assert.Equal(Expected(packaged[i].Content), owned[i].Content); + + // An owned copy's output reaches into the package's runtime namespace only for + // core: ExtractObject, the reply parser the prompt tier delegates to. + var packageRuntimeLines = owned + .SelectMany(f => f.Content.Split('\n').Select(line => (f.Path, Line: line))) + .Where(x => x.Line.Contains(HelperRuntime.PackagedNamespace, StringComparison.Ordinal) + && !x.Line.Contains("ExtractObject", StringComparison.Ordinal)) + .Select(x => $"{x.Path}: {x.Line.Trim()}") + .ToList(); + Assert.True(packageRuntimeLines.Count == 0, + $"owned \"{stableName}\" output still references a helper in {HelperRuntime.PackagedNamespace}:\n" + + string.Join("\n", packageRuntimeLines)); + } + Assert.True(emitted > 0, $"{stableName} emitted nothing over the corpora: nothing was compared"); + } +} diff --git a/server/csharp/MetaObjects.Codegen.Tests/EjectableGeneratorsTests.cs b/server/csharp/MetaObjects.Codegen.Tests/EjectableGeneratorsTests.cs index 7ae20ebbb..2c2bb34bd 100644 --- a/server/csharp/MetaObjects.Codegen.Tests/EjectableGeneratorsTests.cs +++ b/server/csharp/MetaObjects.Codegen.Tests/EjectableGeneratorsTests.cs @@ -27,14 +27,6 @@ public void Every_ejectable_entry_has_a_source_file_name() Assert.All(entries, e => Assert.False(string.IsNullOrEmpty(e.SourceFileName))); } - [Fact] - public void Template_primitive_is_not_ejectable() - { - // ADR-0034 Amendment 3 (C# section): "template" has no emit logic of its own to - // own, so it carries no SourceFileName and must not appear in the ejectable set. - Assert.DoesNotContain(EjectableGenerators.Entries(), e => e.Name == "template"); - } - [Fact] public void Embedded_copies_are_byte_identical_to_their_source_files() { diff --git a/server/csharp/MetaObjects.Codegen.Tests/GeneratorRegistryConformanceTests.cs b/server/csharp/MetaObjects.Codegen.Tests/GeneratorRegistryConformanceTests.cs index 7803a2de1..db816a860 100644 --- a/server/csharp/MetaObjects.Codegen.Tests/GeneratorRegistryConformanceTests.cs +++ b/server/csharp/MetaObjects.Codegen.Tests/GeneratorRegistryConformanceTests.cs @@ -72,8 +72,11 @@ public void RegistryStableNames_EqualManifestCsharpSlice() { var manifest = LoadManifest(); + // A name the shared file expects here and lists as a pending census gap is the eject + // contract's to report (EjectContractTests), which fails once it is registered. + var pendingCensus = EjectContract.Pending("census"); var expected = manifest - .Where(e => e.Ports.Contains(Port)) + .Where(e => e.Ports.Contains(Port) && !pendingCensus.ContainsKey(e.Name)) .Select(e => e.Name) .ToHashSet(StringComparer.Ordinal); diff --git a/server/java/README.md b/server/java/README.md index 99fe7c53f..a9967dd45 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, ejectable)* — Spring REST + DTO + repository-interface emit (you supply the impl) (`codegen-spring`), Mustache template engine (`codegen-mustache`), PlantUML diagrams (`codegen-plantuml`), and a Kotlin emit pipeline on KotlinPoet (`codegen-kotlin`). 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`), 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. - **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-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/EjectContractTest.kt b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/EjectContractTest.kt new file mode 100644 index 000000000..6f2e298fc --- /dev/null +++ b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/EjectContractTest.kt @@ -0,0 +1,317 @@ +package com.metaobjects.generator.kotlin + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import com.metaobjects.generator.Generator +import com.metaobjects.generator.ProjectClassLoaderAware +import com.metaobjects.metadata.ktx.loadDirectory +import com.tschuchort.compiletesting.KotlinCompilation +import com.tschuchort.compiletesting.SourceFile +import java.io.ByteArrayInputStream +import java.io.DataInputStream +import java.io.IOException +import java.lang.reflect.Modifier +import java.nio.file.Files +import java.nio.file.Path +import java.util.jar.JarFile +import kotlin.io.path.isRegularFile +import kotlin.io.path.readText +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * The eject contract, asserted for the Kotlin port (ADR-0034 Amendment 8). + * + * Every stock generator can be ejected, and the owned copy holds its emit. Three assertions, + * each driven by `fixtures/generator-registry-conformance/registry.json`: + * + * - **Census.** Every concrete [Generator] this module ships belongs to a registry entry, and + * the registry exposes exactly the names the shared file gives this port. + * - **Completeness.** Every registered generator ships eject source unless the shared file + * exempts it with a reason, and the reference sources the jar ships are exactly the ones + * the registry names (the pom's include list, read from what it produced). + * - **Depth.** Each generator, package-renamed as `mvn metaobjects:eject` writes it and + * compiled ALONE, references nothing in the generator packages outside the engine surface, + * and emits what the packaged generator emits. + * + * Where the contract does not hold yet, the shared file's `pending` list says so. A pending + * 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. + */ +@OptIn(org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi::class) +class EjectContractTest { + + // --------------------------------------------------------------------------------- + // the shared file + // --------------------------------------------------------------------------------- + + private fun pending(assertion: String): Map> { + val out = sortedMapOf>() + for (p in manifest.get("eject").get("pending")) { + if (p.get("port").asText() != PORT || p.get("assertion").asText() != assertion) continue + val outside = out.getOrPut(p.get("generator").asText()) { sortedSetOf() } + p.get("outside")?.forEach { outside.add(it.asText()) } + } + return out + } + + private fun isExempt(name: String): Boolean = manifest.get("generators").get(name).get("eject").isObject + + private fun surface(key: String): List = + manifest.get("eject").get("engineSurface").get(PORT).get(key).map { it.asText() } + + /** `**` matches any run of characters, `*` one dotted segment. */ + private fun glob(pattern: String): Regex = + Regex(Regex("""\*\*|\*|[^*]+""").findAll(pattern).joinToString("") { + when (it.value) { "**" -> ".*"; "*" -> "[^.]*"; else -> Regex.escape(it.value) } + }) + + /** True for a class that writes output: inside the generator packages, and not engine. */ + private fun outsideEngineSurface(className: String): Boolean = + surface("codegenPackages").any { glob(it).matches(className) } && + surface("engine").none { glob(it).matches(className) } + + // --------------------------------------------------------------------------------- + // census + // --------------------------------------------------------------------------------- + + /** Every path this module ships under [prefix]: its classes directory, or its jar. */ + private fun shipped(prefix: String): Set { + val home = Path.of(GeneratorInfo::class.java.protectionDomain.codeSource.location.toURI()) + if (Files.isDirectory(home)) { + val root = home.resolve(prefix) + if (!Files.isDirectory(root)) return emptySet() + return Files.walk(root).use { walk -> + walk.filter { it.isRegularFile() } + .map { home.relativize(it).toString().replace(java.io.File.separatorChar, '/') } + .toList().toSortedSet() + } + } + return JarFile(home.toFile()).use { jar -> + jar.entries().asSequence().filter { !it.isDirectory && it.name.startsWith(prefix) } + .map { it.name }.toSortedSet() + } + } + + @Test + fun `census - every generator class this module ships belongs to a registry entry`() { + val registered = GENERATOR_REGISTRY.values.map { it.factory().javaClass.name }.toSet() + val found = shipped("com/metaobjects/") + .filter { it.endsWith(".class") && '$' !in it } + .map { Class.forName(it.removeSuffix(".class").replace('/', '.'), false, javaClass.classLoader) } + .filter { Generator::class.java.isAssignableFrom(it) && !it.isInterface && !Modifier.isAbstract(it.modifiers) } + .map { it.name } + assertTrue(found.size >= 10, "the scan found too few generators to be believed: $found") + val unregistered = found.filterNot { it in registered }.sorted() + assertTrue(unregistered.isEmpty(), + "generator classes no registry entry constructs, so no catalog, eject goal or test sees them: " + + "$unregistered. Register each in GeneratorRegistry.kt and in $MANIFEST_PATH.") + } + + @Test + fun `census - the registry exposes the names the shared file gives this port`() { + val generators = manifest.get("generators") + val expected = generators.fieldNames().asSequence() + .filter { name -> generators.get(name).get("ports").any { it.asText() == PORT } }.toSortedSet() + val actual = GENERATOR_REGISTRY.keys.toSortedSet() + assertEquals(emptySet(), actual - expected, "registered, but the shared file does not list it for kotlin") + assertEquals(pending("census").keys.toSortedSet(), (expected - actual).toSortedSet(), + "expected in the Kotlin registry by the shared file; a pending census entry must list each") + } + + // --------------------------------------------------------------------------------- + // completeness + // --------------------------------------------------------------------------------- + + @Test + fun `completeness - every registered generator ships eject source unless exempt`() { + val waiting = pending("completeness").keys + val problems = mutableListOf() + for ((name, info) in GENERATOR_REGISTRY.toSortedMap()) { + val ejectable = info.ejectResourcePath != null + if (isExempt(name)) { + if (ejectable) problems += "$name: exempt in the shared file but ejectable; drop the exemption" + continue + } + if (ejectable && name in waiting) problems += "$name: ejectable now; remove its pending completeness entry" + if (!ejectable && name !in waiting) problems += "$name: no eject source, and the shared file does not exempt it" + } + problems += (waiting - GENERATOR_REGISTRY.keys) + .map { "$it: pending completeness entry for a generator this port does not register" } + assertTrue(problems.isEmpty(), problems.joinToString("\n")) + } + + @Test + fun `completeness - the shipped reference sources are exactly the ones the registry names`() { + val named = GENERATOR_REGISTRY.values.mapNotNull { it.ejectResourcePath }.toSortedSet() + assertEquals(named, shipped(EJECT_RESOURCE_ROOT)) + } + + // --------------------------------------------------------------------------------- + // depth + // --------------------------------------------------------------------------------- + + private fun ejectable(): Map = + GENERATOR_REGISTRY.filterValues { it.ejectResourcePath != null }.toSortedMap() + + /** + * Every packaged class a compiled class file names: the class names, descriptors and + * signatures in its constant pool. A string the generator EMITS never qualifies: emitted + * Kotlin spells packages with dots, and a constant holding whitespace is not a name. + */ + private fun referencedClasses(classFile: ByteArray): Set { + val out = sortedSetOf() + val input = DataInputStream(ByteArrayInputStream(classFile)) + input.skipBytes(8) // magic, minor, major + val count = input.readUnsignedShort() + var i = 1 + while (i < count) { + when (val tag = input.readUnsignedByte()) { + 1 -> { + val text = input.readUTF() + if ('.' !in text && text.none { it.isWhitespace() }) { + PACKAGED_CLASS.findAll(text).forEach { out += it.value.replace('/', '.').substringBefore('$') } + } + } + 3, 4, 9, 10, 11, 12, 17, 18 -> input.skipBytes(4) + 5, 6 -> { input.skipBytes(8); i++ } + 7, 8, 16, 19, 20 -> input.skipBytes(2) + 15 -> input.skipBytes(3) + else -> throw IOException("unknown constant pool tag $tag") + } + i++ + } + return out + } + + private fun outsideEngineSurface(result: KotlinCompilation.Result): Set = + result.outputDirectory.walkTopDown().filter { it.extension == "class" } + .flatMap { referencedClasses(it.readBytes()) } + .filter { outsideEngineSurface(it) }.toSortedSet() + + @Test + fun `depth - an owned copy references only the engine surface`() { + val waiting = pending("depth") + val problems = mutableListOf() + for (name in ejectable().keys) { + val actual = outsideEngineSurface(ownedCopy(name)) + val expected = waiting[name] ?: emptySet() + if (actual == expected) continue + problems += when { + expected.isEmpty() -> + "$name: its owned copy calls emit code that stays in the package: $actual. Hand it over with " + + "the generator; a reference to the engine belongs in eject.engineSurface.kotlin.engine." + actual.isEmpty() -> "$name: its owned copy is whole now; remove its pending depth entry" + else -> "$name: pending depth entries list $expected, the owned copy references $actual" + } + } + problems += (waiting.keys - ejectable().keys).map { "$it: pending depth entry for a generator that is not ejectable" } + assertTrue(problems.isEmpty(), problems.joinToString("\n")) + } + + @Test + fun `depth - every engine surface entry names a real class`() { + val stale = surface("engine").filter { '*' !in it }.filter { + runCatching { Class.forName(it, false, javaClass.classLoader) }.isFailure + } + assertEquals(emptyList(), stale, "eject.engineSurface.kotlin.engine names classes that do not exist") + } + + /** A shared model directory, and the directory its template refs resolve under. */ + private class Corpus(val label: String, model: String, templates: String? = null) { + val model: Path = FIXTURES.resolve(model) + val templates: Path? = templates?.let { FIXTURES.resolve(it) } + /** Loaded once and shared by both sides. A loader's name is an identifier. */ + val loader by lazy { loadDirectory("ejectContract_" + label.replace('-', '_'), this.model) } + } + + private fun run(generator: Generator, corpus: Corpus): Map { + val out = Files.createTempDirectory("eject-contract-") + try { + val args = mutableMapOf( + "outputDir" to out.toString(), + "packageName" to "acme", + "testPackage" to "com.example.requirements", + "witnessClass" to "com.example.Witnesses", + "warnUncovered" to "false", + // Required by the generators that read template text, whatever the model holds. + "templateRoot" to (corpus.templates ?: CORPORA.first().templates).toString(), + ) + if (generator is ProjectClassLoaderAware) generator.setProjectClassLoader(javaClass.classLoader) + generator.setArgs(args).execute(corpus.loader) + return Files.walk(out).use { walk -> + walk.filter { it.isRegularFile() }.toList() + .associate { out.relativize(it).toString() to it.readText() }.toSortedMap() + } + } finally { + out.toFile().deleteRecursively() + } + } + + @Test + fun `depth - an unchanged owned copy emits what the packaged generator emits`() { + val silent = mutableListOf() + for ((name, info) in ejectable()) { + val owned = ownedCopy(name).classLoader.loadClass("$OWNED_PACKAGE.${simpleName(info)}") + var emitted = 0 + for (corpus in CORPORA) { + val where = "$name over ${corpus.label}" + val a = run(info.factory(), corpus) + val b = run(owned.getDeclaredConstructor().newInstance() as Generator, corpus) + emitted += a.size + assertEquals(a.keys, b.keys, where) + for ((path, content) in a) assertEquals(content, b[path], "$where: $path") + } + // An empty emit on both sides compares equal and proves nothing. + if (emitted == 0) silent += name + } + assertEquals(emptyList(), silent, "emitted nothing over the corpora, so nothing was compared") + } + + companion object { + private const val PORT = "kotlin" + private const val OWNED_PACKAGE = "com.acme.owned" + private val PACKAGED_CLASS = Regex("com/metaobjects/[A-Za-z0-9_/$]+") + private val PACKAGE_LINE = Regex("(?m)^package\\s+[\\w.]+\\s*$") + + private val REPO_ROOT: Path = generateSequence(Path.of("").toAbsolutePath()) { it.parent } + .first { Files.isDirectory(it.resolve("fixtures")) && Files.isDirectory(it.resolve("server")) } + private val FIXTURES: Path = REPO_ROOT.resolve("fixtures") + private val MANIFEST_PATH: Path = FIXTURES.resolve("generator-registry-conformance/registry.json") + private val manifest: JsonNode by lazy { ObjectMapper().readTree(Files.readString(MANIFEST_PATH)) } + + /** Shared models that between them make every ejectable generator emit. */ + private val CORPORA = listOf( + Corpus("fitness", "persistence-conformance/canonical", "persistence-conformance/canonical/prompts"), + Corpus("requirements", "requirement-test-identity-conformance/worked-example/input"), + Corpus("stored-proc", "conformance/parameter-ref-on-stored-proc/input"), + ) + + private fun simpleName(info: GeneratorInfo): String = + info.ejectResourcePath!!.substringAfterLast('/').substringBeforeLast('.') + + 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") + val result = KotlinCompilation().apply { + sources = listOf(SourceFile.kotlin("${simpleName(info)}.kt", + reference.replaceRange(line.range, "package $OWNED_PACKAGE"))) + inheritClassPath = true + messageOutputStream = java.io.OutputStream.nullOutputStream() + }.compile() + assertEquals(KotlinCompilation.ExitCode.OK, result.exitCode, + "$name, ejected alone, does not compile:\n${result.messages}") + result + } + } +} diff --git a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/EjectedGeneratorAloneCompilesTest.kt b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/EjectedGeneratorAloneCompilesTest.kt deleted file mode 100644 index 0ca0aaa0f..000000000 --- a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/EjectedGeneratorAloneCompilesTest.kt +++ /dev/null @@ -1,51 +0,0 @@ -package com.metaobjects.generator.kotlin - -import com.tschuchort.compiletesting.KotlinCompilation -import com.tschuchort.compiletesting.SourceFile -import java.nio.file.Files -import java.nio.file.Path -import kotlin.test.Test -import kotlin.test.assertTrue - -/** - * Eject proof (a), one generator at a time: every ejectable Kotlin generator, copied out - * ALONE into a foreign package, compiles against the published API. - * - * An adopter ejects the generator they want to change, not the whole tier. A copy that calls - * a helper on a sibling generator resolves only while that sibling sits in the same package, - * so [EjectedGeneratorsCompileTest], which compiles every copy together, cannot see the - * defect. Each copy here is compiled with nothing else beside it. - * - * The list comes from [GENERATOR_REGISTRY], never a hand-typed one. The package line is - * rewritten exactly as `mvn metaobjects:eject` rewrites it. - */ -@OptIn(org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi::class) -class EjectedGeneratorAloneCompilesTest { - - private val packageLine = Regex("(?m)^package\\s+[\\w.]+\\s*$") - - @Test - fun everyEjectableGeneratorCompilesAlone() { - val srcDir = Path.of("src/main/kotlin/com/metaobjects/generator/kotlin") - val ejectable = GENERATOR_REGISTRY.values.filter { it.ejectResourcePath != null } - assertTrue(ejectable.isNotEmpty(), "expected the registry to name ejectable generators") - - val failures = ejectable.mapNotNull { info -> - val simpleName = info.ejectResourcePath!!.substringAfterLast('/').substringBeforeLast('.') - val file = srcDir.resolve("$simpleName.kt") - assertTrue(Files.exists(file), "expected $file to exist") - val original = Files.readString(file) - val match = packageLine.find(original) ?: throw AssertionError("$simpleName has no package line") - val rewritten = original.replaceRange(match.range, "package com.acme.owned") - - val result = KotlinCompilation().apply { - sources = listOf(SourceFile.kotlin("$simpleName.kt", rewritten)) - inheritClassPath = true - messageOutputStream = System.out - }.compile() - if (result.exitCode == KotlinCompilation.ExitCode.OK) null - else "${info.name} ($simpleName) does not compile alone:\n${result.messages}" - } - assertTrue(failures.isEmpty(), failures.joinToString("\n")) - } -} 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 a6d648430..6b8a8ea73 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 @@ -13,8 +13,8 @@ import kotlin.test.assertTrue * module compiles, package-renamed, against the PUBLISHED API (this module's own compiled * classes plus its declared dependencies — exactly what an adopter's new `codegen/` module * depends on after eject). See `docs/superpowers/specs/ - * 2026-09-22-eject-in-every-port-design.md` (JVM section) and `GeneratorRegistry.kt`'s - * `ejectPath(...)` calls, whose class names this list mirrors. + * 2026-09-22-eject-in-every-port-design.md` (JVM section). Each copy compiled ALONE, and + * what it may reference, is `EjectContractTest`'s to assert. * * Reads straight off `src/main/kotlin` (the maven-plugin module that runs the real eject * mojo depends on THIS module, not the reverse, so the mojo isn't reachable here) and @@ -26,24 +26,9 @@ import kotlin.test.assertTrue @OptIn(org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi::class) class EjectedGeneratorsCompileTest { - /** Every stable name codegen-kotlin ships as an ejectable reference (GeneratorRegistry.kt). */ - private val ejectableSimpleNames = listOf( - "KotlinEntityGenerator", - "KotlinSpringControllerGenerator", - "KotlinRepositoryGenerator", - "KotlinOutputParserGenerator", - "KotlinOutputPromptGenerator", - "KotlinRenderHelperGenerator", - "KotlinExtractorGenerator", - "KotlinFilterAllowlistGenerator", - "KotlinNamesGenerator", - "KotlinExposedTableGenerator", - "KotlinRelationsGenerator", - "KotlinSpringConfigGenerator", - "KotlinStoredProcGenerator", - "KotlinValidatorGenerator", - "KotlinRequirementTestsGenerator", - ) + /** 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 packageLine = Regex("(?m)^package\\s+[\\w.]+\\s*$") diff --git a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/GeneratorRegistryConformanceTest.kt b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/GeneratorRegistryConformanceTest.kt index afadfd767..98754843a 100644 --- a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/GeneratorRegistryConformanceTest.kt +++ b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/GeneratorRegistryConformanceTest.kt @@ -59,6 +59,11 @@ class GeneratorRegistryConformanceTest { val ports: Set, ) + private fun pendingCensus(): Set = + ObjectMapper().readTree(Files.readString(manifestPath)).get("eject").get("pending") + .filter { it.get("port").asText() == port && it.get("assertion").asText() == "census" } + .map { it.get("generator").asText() }.toSet() + private fun loadManifest(): List { val root: JsonNode = ObjectMapper().readTree(Files.readString(manifestPath)) val generators = root.get("generators") @@ -78,7 +83,9 @@ class GeneratorRegistryConformanceTest { fun `registry stable names equal the manifest kotlin slice`() { val manifest = loadManifest() - val expected = manifest.filter { port in it.ports }.map { it.name }.toSortedSet() + // A name the shared file expects here and lists as a pending census gap is the eject + // contract's to report (EjectContractTest), which fails once it is registered. + val expected = (manifest.filter { port in it.ports }.map { it.name } - pendingCensus()).toSortedSet() val actual = GENERATOR_REGISTRY.keys.toSortedSet() val missing = (expected - actual).sorted() diff --git a/server/java/codegen-plantuml/src/main/java/com/metaobjects/generator/direct/plantuml/PlantUMLWriter.java b/server/java/codegen-plantuml/src/main/java/com/metaobjects/generator/direct/plantuml/PlantUMLWriter.java index 8b630d406..087892a7d 100644 --- a/server/java/codegen-plantuml/src/main/java/com/metaobjects/generator/direct/plantuml/PlantUMLWriter.java +++ b/server/java/codegen-plantuml/src/main/java/com/metaobjects/generator/direct/plantuml/PlantUMLWriter.java @@ -189,7 +189,10 @@ protected void writeObjectPackages() throws IOException { protected void writeEmptyPackageNesting(List pkgsIn) { List pkgs = new ArrayList<>( pkgsIn ); for( String pkg : pkgsIn ) { - String p = pkg.substring( 0, pkg.lastIndexOf( "::" )); + // A single-segment package ("acme") has no parent package to nest under. + int parentEnd = pkg.lastIndexOf( "::" ); + if ( parentEnd < 0 ) continue; + String p = pkg.substring( 0, parentEnd ); if ( !p.isEmpty() && !pkgs.contains(p)) { if ( hasDifferentSubPackages( p, pkgsIn )) { diff --git a/server/java/codegen-spring/pom.xml b/server/java/codegen-spring/pom.xml index e761dddc7..f86a59298 100644 --- a/server/java/codegen-spring/pom.xml +++ b/server/java/codegen-spring/pom.xml @@ -53,6 +53,20 @@ + + + com.metaobjects + metaobjects-codegen-plantuml + ${project.version} + test + + + com.metaobjects + metaobjects-codegen-mustache + ${project.version} + test + 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 260546025..8a9c056ac 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,5 +1,8 @@ package com.metaobjects.generator; +import com.metaobjects.generator.direct.metadata.ai.MetaDataAIDocumentationGenerator; +import com.metaobjects.generator.direct.metadata.file.json.MetaDataFileJsonSchemaGenerator; +import com.metaobjects.generator.direct.metadata.html.MetaDataHtmlDocumentationGenerator; import com.metaobjects.generator.direct.object.BaseObjectCodeGenerator; import com.metaobjects.generator.direct.object.BaseObjectCodeWriter; import com.metaobjects.generator.direct.object.javacode.ExtractorCodeGenerator; @@ -117,9 +120,10 @@ public GeneratorInfo(String stableName, String classname, String description, * as shipped inside the {@code codegen-*} jar that owns it (e.g. {@code * META-INF/metaobjects/reference/java/SpringNamesGenerator.java}), or {@code * null} when this generator is not ejectable — {@code mvn metaobjects:eject} - * reads this field to resolve {@code -Dnames}. One registered generator carries - * none: "template", the Mustache primitive, which has no emit logic to own - * (ADR-0034 Amendment 6). + * reads this field to resolve {@code -Dnames}. Which generators may carry none + * is ruled in the shared manifest ({@code fixtures/generator-registry-conformance/ + * registry.json}, the {@code eject} entries) and asserted by {@code + * EjectContractTest}: "template" and "mustache" are exempt, with reasons. * @param ejectRuntime simple names of the helper-runtime classes this generator's * OUTPUT imports (from {@link #RUNTIME_PACKAGE}), closed over their own references * to each other. {@code mvn metaobjects:eject} copies their source (shipped under @@ -233,6 +237,14 @@ public String toString() { BaseObjectCodeWriter.class.getName(), JavaCodeWriter.class.getName(), PojoAwareCodeWriter.class.getName(), ValueObjectCodeWriter.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 MUSTACHE_GENERATOR = + "com.metaobjects.generator.mustache.MustacheTemplateGenerator"; + private static final Map REGISTRY = buildRegistry(); /** Classpath root every ejectable Java generator's reference source is shipped under @@ -350,6 +362,24 @@ private static Map buildRegistry() { "Per-object physical database name constants (table/view/schema/column) " + "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". + register(m, "plantuml", PLANTUML_GENERATOR, + "PlantUML class diagram of the object model.", Tier.NATIVE, Layer.DOCS); + 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); + 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); + register(m, "json-schema", MetaDataFileJsonSchemaGenerator.class.getName(), + "JSON Schema that validates metadata files, read from the type registry.", + Tier.NATIVE, Layer.CAPABILITY); + 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); return Collections.unmodifiableMap(m); } diff --git a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/GeneratorRegistryConformanceTest.java b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/GeneratorRegistryConformanceTest.java index 5e66fba6f..3e814c0b0 100644 --- a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/GeneratorRegistryConformanceTest.java +++ b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/GeneratorRegistryConformanceTest.java @@ -76,9 +76,20 @@ private static JsonNode loadManifest() throws IOException { return MAPPER.readTree(Files.readString(manifest)); } + private static Set pendingCensus(JsonNode manifest) { + Set names = new java.util.TreeSet<>(); + for (JsonNode p : manifest.path("eject").path("pending")) { + if (PORT_ID.equals(p.path("port").asText()) && "census".equals(p.path("assertion").asText())) { + names.add(p.path("generator").asText()); + } + } + return names; + } + /** Manifest entries whose {@code ports} array includes this port. */ private static Map manifestSliceForPort(JsonNode manifest) { Map slice = new TreeMap<>(); + Set pending = pendingCensus(manifest); JsonNode generators = manifest.get("generators"); assertTrue("manifest must have a 'generators' object", generators != null && generators.isObject()); Iterator> it = generators.fields(); @@ -96,7 +107,9 @@ private static Map manifestSliceForPort(JsonNode manifest) { } } } - if (includesPort) { + // A name the shared file expects here and lists as a pending census gap is the + // eject contract's to report (EjectContractTest), which fails once it is registered. + if (includesPort && !pending.contains(name)) { Tier tier = "neutral".equals(entry.path("tier").asText("native")) ? Tier.NEUTRAL : Tier.NATIVE; slice.put(name, tier); diff --git a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/EjectedGeneratorsCompileTest.java b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/EjectedGeneratorsCompileTest.java index c45b20a48..55cd42079 100644 --- a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/EjectedGeneratorsCompileTest.java +++ b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/EjectedGeneratorsCompileTest.java @@ -1,5 +1,6 @@ package com.metaobjects.generator.spring; +import com.metaobjects.generator.GeneratorRegistry; import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; @@ -28,8 +29,7 @@ * classes plus its declared dependencies — exactly what an adopter's new {@code codegen/} * module depends on after eject). See {@code docs/superpowers/specs/ * 2026-09-22-eject-in-every-port-design.md} (JVM section) and {@code - * com.metaobjects.generator.GeneratorRegistry}'s {@code ejectPath(...)} calls, whose - * classnames this list mirrors. + * com.metaobjects.generator.GeneratorRegistry}, which this test reads its list from. * *

This reads straight off {@code src/main/java} rather than through the Maven eject * mojo (which lives in a downstream module, {@code maven-plugin}, that depends on THIS one — @@ -43,20 +43,15 @@ public class EjectedGeneratorsCompileTest { @Rule public TemporaryFolder tempFolder = new TemporaryFolder(); - /** Every stable name codegen-spring ships as an ejectable reference (GeneratorRegistry). */ - private static final List EJECTABLE_SIMPLE_NAMES = List.of( - "SpringControllerGenerator", - "SpringOutputParserGenerator", - "SpringOutputPromptGenerator", - "SpringRenderHelperGenerator", - "SpringFilterAllowlistGenerator", - "SpringRepositoryGenerator", - "SpringDtoGenerator", - "SpringValueObjectGenerator", - "LlmTraceHelperGenerator", - "SpringNamesGenerator", - "JUnitRequirementTestsGenerator" - ); + /** Every reference this module ships, read off the registry so the list cannot fall + * behind it: the ejectable generators whose class lives in this package. */ + private static final List EJECTABLE_SIMPLE_NAMES = GeneratorRegistry.list().values().stream() + .filter(info -> info.ejectResourcePath() != null) + .map(GeneratorRegistry.GeneratorInfo::classname) + .filter(name -> name.startsWith(GeneratorRegistry.SUPPORT_PACKAGE + ".") + && name.lastIndexOf('.') == GeneratorRegistry.SUPPORT_PACKAGE.length()) + .map(name -> name.substring(name.lastIndexOf('.') + 1)) + .collect(java.util.stream.Collectors.toList()); private static final Pattern PACKAGE_LINE = Pattern.compile("(?m)^package\\s+[\\w.]+\\s*;\\s*$"); 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 7a6f1fcbe..7a669645c 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 @@ -4,6 +4,7 @@ import com.metaobjects.generator.EmitsPhysicalNameConstants; import com.metaobjects.generator.Generator; import com.metaobjects.generator.GeneratorRegistry; +import com.metaobjects.generator.direct.MultiFileDirectGeneratorBase; import com.metaobjects.generator.GeneratorRegistryConformanceTest; import com.metaobjects.loader.MetaDataLoader; import org.junit.Rule; @@ -465,7 +466,13 @@ private Map generate() throws Exception { Map runArgs = EmitsObjectExtractors.deriveEmitExtractors( EmitsPhysicalNameConstants.deriveUseNames(args, suite), suite); for (Generator g : suite) { - g.setArgs(new HashMap<>(runArgs)); + Map own = new HashMap<>(runArgs); + // A generator that writes ONE file is told its name; a per-object generator + // given one would write every object into it. + if (!(g instanceof MultiFileDirectGeneratorBase)) { + own.put("outputFilename", g.getClass().getSimpleName() + ".out"); + } + g.setArgs(own); g.execute(loader); } 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 840ef8209..9bd9e6970 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 @@ -1,5 +1,6 @@ package com.metaobjects.generator.spring; +import com.metaobjects.generator.direct.MultiFileDirectGeneratorBase; import com.metaobjects.generator.Generator; import com.metaobjects.generator.GeneratorRegistry; import com.metaobjects.generator.GeneratorRegistry.GeneratorInfo; @@ -110,6 +111,11 @@ private Map emit(String variant, List suite) thro args.put("type", "class"); args.put("flavor", "pojoAware"); } + // A generator that writes ONE file is told its name; a per-object generator + // given one would write every object into it. + if (!(gen instanceof MultiFileDirectGeneratorBase)) { + args.put("outputFilename", gen.getClass().getSimpleName() + ".out"); + } gen.setArgs(args).execute(loader); } } catch (Exception | LinkageError e) { 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 new file mode 100644 index 000000000..9d843c221 --- /dev/null +++ b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/EjectContractTest.java @@ -0,0 +1,551 @@ +package com.metaobjects.mojo; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.metaobjects.generator.Generator; +import com.metaobjects.generator.GeneratorRegistry; +import com.metaobjects.generator.ProjectClassLoaderAware; +import com.metaobjects.loader.DirectorySource; +import com.metaobjects.loader.LoaderOptions; +import com.metaobjects.loader.MetaDataLoader; +import com.metaobjects.loader.uri.URIHelper; +import org.apache.maven.project.MavenProject; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +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.ByteArrayInputStream; +import java.io.DataInputStream; +import java.io.File; +import java.io.IOException; +import java.lang.reflect.Modifier; +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.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.TreeSet; +import java.util.jar.JarFile; +import java.util.jar.Manifest; +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; + +/** + * The eject contract, asserted for the Java port (ADR-0034 Amendment 8). + * + *

Every stock generator can be ejected, and the owned copy holds its emit. Three assertions, + * each driven by {@code fixtures/generator-registry-conformance/registry.json}:

+ *
    + *
  • Census. Every concrete {@link Generator} on this plugin's classpath belongs to a + * registry entry, and the registry exposes exactly the names the shared file gives this + * port.
  • + *
  • Completeness. Every registered generator ships eject source unless the shared + * file exempts it with a reason, and the reference sources the jars ship are exactly the + * ones the registry names.
  • + *
  • Depth. Each generator, ejected ALONE by the real mojo into an empty project, + * compiles; references nothing in the generator packages outside the engine surface; and + * emits what the packaged generator emits.
  • + *
+ *

Where the contract does not hold yet, the shared file's {@code pending} list says so. A + * pending entry that passes fails here, and so does a gap that is not listed.

+ * + *

It lives in this module because this is where the eject mojo is, and the only module that + * sees every codegen module at once.

+ */ +public class EjectContractTest { + + private static final String PORT = "java"; + private static final String OWNED_PACKAGE = "com.acme.codegen"; + private static final String OWNED_RUNTIME_PACKAGE = "com.acme.runtime"; + + @Rule + public TemporaryFolder tempFolder = new TemporaryFolder(); + + // --------------------------------------------------------------------------------- + // the shared file + // --------------------------------------------------------------------------------- + + private static Path repoRoot() { + for (Path p = Path.of("").toAbsolutePath(); p != null; p = p.getParent()) { + if (Files.isDirectory(p.resolve("fixtures")) && Files.isDirectory(p.resolve("server"))) return p; + } + throw new IllegalStateException("could not locate the repo root"); + } + + private static final Path MANIFEST_PATH = + repoRoot().resolve("fixtures/generator-registry-conformance/registry.json"); + + private static JsonObject manifest() { + try { + return JsonParser.parseString(Files.readString(MANIFEST_PATH, StandardCharsets.UTF_8)).getAsJsonObject(); + } catch (IOException e) { + throw new IllegalStateException(e); + } + } + + private static List strings(JsonElement array) { + List out = new ArrayList<>(); + for (JsonElement e : array.getAsJsonArray()) out.add(e.getAsString()); + return out; + } + + /** The generators the shared file expects this port to expose. */ + private static Set portSlice() { + Set out = new TreeSet<>(); + for (Map.Entry g : manifest().getAsJsonObject("generators").entrySet()) { + if (strings(g.getValue().getAsJsonObject().get("ports")).contains(PORT)) out.add(g.getKey()); + } + return out; + } + + private static boolean isExempt(String name) { + return manifest().getAsJsonObject("generators").getAsJsonObject(name).get("eject").isJsonObject(); + } + + private static JsonArray allPending() { + return manifest().getAsJsonObject("eject").getAsJsonArray("pending"); + } + + /** This port's pending entries for one assertion: generator to the union of what its + * entries list in {@code outside} (one entry per gap; empty for census and completeness). */ + private static Map> pending(String assertion) { + Map> out = new TreeMap<>(); + for (JsonElement e : allPending()) { + JsonObject p = e.getAsJsonObject(); + if (!PORT.equals(p.get("port").getAsString()) || !assertion.equals(p.get("assertion").getAsString())) continue; + Set outside = out.computeIfAbsent(p.get("generator").getAsString(), k -> new TreeSet<>()); + if (p.has("outside")) outside.addAll(strings(p.get("outside"))); + } + return out; + } + + /** {@code **} matches any run of characters, {@code *} one dotted segment. */ + private static Pattern glob(String pattern) { + StringBuilder regex = new StringBuilder(); + Matcher m = Pattern.compile("\\*\\*|\\*|[^*]+").matcher(pattern); + while (m.find()) { + String part = m.group(); + regex.append(part.equals("**") ? ".*" : part.equals("*") ? "[^.]*" : Pattern.quote(part)); + } + return Pattern.compile(regex.toString()); + } + + private static List surface(String key) { + return strings(manifest().getAsJsonObject("eject").getAsJsonObject("engineSurface") + .getAsJsonObject(PORT).get(key)); + } + + /** True for a class that writes output: inside the generator packages, and not engine. */ + private static boolean outsideEngineSurface(String className) { + return surface("codegenPackages").stream().anyMatch(p -> glob(p).matcher(className).matches()) + && surface("engine").stream().noneMatch(p -> glob(p).matcher(className).matches()); + } + + // --------------------------------------------------------------------------------- + // the classpath + // --------------------------------------------------------------------------------- + + /** Every classpath entry, with a launcher jar's manifest {@code Class-Path} expanded. */ + private static List classpath() throws IOException { + List out = new ArrayList<>(); + for (String entry : System.getProperty("java.class.path").split(File.pathSeparator)) { + Path path = Path.of(entry); + out.add(path); + if (!Files.isRegularFile(path)) continue; + try (JarFile jar = new JarFile(path.toFile())) { + Manifest mf = jar.getManifest(); + String classPath = mf == null ? null : mf.getMainAttributes().getValue("Class-Path"); + if (classPath == null) continue; + for (String url : classPath.trim().split("\\s+")) { + out.add(url.startsWith("file:") ? Path.of(java.net.URI.create(url)) : path.resolveSibling(url)); + } + } + } + return out; + } + + /** Every resource path on the classpath under {@code prefix} (a directory or a jar). */ + private static Set resourcesUnder(String prefix) throws IOException { + Set out = new TreeSet<>(); + for (Path entry : classpath()) { + if (Files.isDirectory(entry)) { + Path root = entry.resolve(prefix); + if (!Files.isDirectory(root)) continue; + try (Stream s = Files.walk(root)) { + s.filter(Files::isRegularFile) + .forEach(p -> out.add(entry.relativize(p).toString().replace(File.separatorChar, '/'))); + } + } else if (Files.isRegularFile(entry) && entry.toString().endsWith(".jar")) { + try (JarFile jar = new JarFile(entry.toFile())) { + jar.stream().filter(e -> !e.isDirectory() && e.getName().startsWith(prefix)) + .forEach(e -> out.add(e.getName())); + } + } + } + return out; + } + + private static final String GENERATOR_PACKAGE_PATH = "com/metaobjects/"; + + /** Every concrete {@link Generator} class the plugin's classpath holds, test classes aside. */ + private static Set generatorClasses() throws Exception { + Set out = new TreeSet<>(); + for (String resource : resourcesUnder(GENERATOR_PACKAGE_PATH)) { + if (!resource.endsWith(".class") || resource.contains("$")) continue; + String name = resource.substring(0, resource.length() - ".class".length()).replace('/', '.'); + Class c; + try { + c = Class.forName(name, false, EjectContractTest.class.getClassLoader()); + } catch (LinkageError | ClassNotFoundException e) { + continue; // an optional dependency this classpath does not carry + } + URL from = c.getProtectionDomain().getCodeSource().getLocation(); + if (from.toString().contains("test-classes")) continue; + if (Generator.class.isAssignableFrom(c) && !c.isInterface() && !Modifier.isAbstract(c.getModifiers())) { + out.add(name); + } + } + return out; + } + + // --------------------------------------------------------------------------------- + // census + // --------------------------------------------------------------------------------- + + private static List javaEntries() { + return EjectSupport.allEntries().stream() + .filter(e -> e.port == EjectSupport.Port.JAVA).collect(Collectors.toList()); + } + + @Test + public void censusEveryGeneratorClassBelongsToARegistryEntry() throws Exception { + Set registered = new TreeSet<>(); + for (EjectSupport.Entry e : EjectSupport.allEntries()) { + // A generator's bases and writers are copied with it, and some are generators. + if (e.classname != null) registered.add(e.classname); + registered.addAll(e.sources()); + } + // Kotlin names its classes by constructor, so its registry is asked for instances. + com.metaobjects.generator.kotlin.GeneratorRegistryKt.getGENERATOR_REGISTRY().values() + .forEach(info -> registered.add(info.getFactory().invoke().getClass().getName())); + + Set found = generatorClasses(); + assertTrue("the classpath scan found too few generators to be believed: " + found, found.size() > 20); + Set unregistered = new TreeSet<>(found); + unregistered.removeAll(registered); + assertTrue("generator classes no registry entry names, so no catalog, eject goal or test sees them: " + + unregistered + ". Register each in its port's GeneratorRegistry and in " + MANIFEST_PATH + ".", + unregistered.isEmpty()); + } + + @Test + public void censusTheRegistryExposesTheNamesTheSharedFileGivesThisPort() { + Set expected = portSlice(); + Set actual = new TreeSet<>(GeneratorRegistry.list().keySet()); + Set extra = new TreeSet<>(actual); + extra.removeAll(expected); + assertEquals("registered, but the shared file does not list it for java", Set.of(), extra); + Set missing = new TreeSet<>(expected); + missing.removeAll(actual); + assertEquals("expected in the Java registry by the shared file; a pending census entry must list each", + pending("census").keySet(), missing); + } + + // --------------------------------------------------------------------------------- + // completeness + // --------------------------------------------------------------------------------- + + @Test + public void completenessEveryRegisteredGeneratorShipsEjectSourceUnlessExempt() { + Set waiting = pending("completeness").keySet(); + List problems = new ArrayList<>(); + Set registered = new TreeSet<>(); + for (EjectSupport.Entry e : javaEntries()) { + registered.add(e.stableName); + boolean ejectable = e.resourcePath != null; + if (isExempt(e.stableName)) { + if (ejectable) problems.add(e.stableName + ": exempt in the shared file but ejectable; drop the exemption"); + continue; + } + if (ejectable && waiting.contains(e.stableName)) { + problems.add(e.stableName + ": ejectable now; remove its pending completeness entry"); + } + if (!ejectable && !waiting.contains(e.stableName)) { + problems.add(e.stableName + ": no eject source, and the shared file does not exempt it"); + } + } + for (String name : waiting) { + if (!registered.contains(name)) { + problems.add(name + ": pending completeness entry for a generator this port does not register"); + } + } + assertTrue(String.join("\n", problems), problems.isEmpty()); + } + + /** The pom include lists, read from what they produced: a reference source the registry + * does not name is shipped for nobody, and one it names and no jar ships cannot be ejected. */ + @Test + public void completenessTheShippedReferenceSourcesAreExactlyTheOnesTheRegistryNames() throws IOException { + String root = GeneratorRegistry.EJECT_RESOURCE_ROOT; + Set named = new TreeSet<>(); + for (EjectSupport.Entry e : javaEntries()) { + 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 support : GeneratorRegistry.EJECT_SUPPORT) { + named.add(GeneratorRegistry.EJECT_SUPPORT_RESOURCE_ROOT + support + ".java"); + } + assertEquals(named, resourcesUnder(root)); + } + + // --------------------------------------------------------------------------------- + // depth + // --------------------------------------------------------------------------------- + + private static List ejectable() { + return javaEntries().stream().filter(e -> e.resourcePath != null).collect(Collectors.toList()); + } + + /** Eject one generator by itself, through the real mojo, into an empty project. */ + private Path ejectAlone(String stableName) throws Exception { + Path projectDir = tempFolder.newFolder().toPath(); + MavenProject project = Mockito.mock(MavenProject.class); + Mockito.when(project.getBasedir()).thenReturn(projectDir.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()); + MetaDataEjectMojo mojo = new MetaDataEjectMojo(); + mojo.project = project; + mojo.names = stableName; + mojo.port = PORT; + mojo.execute(); + return projectDir; + } + + /** Compile what eject wrote under {@code codegen/}, alone, against the published classpath. */ + private Path compileAlone(String stableName, Path projectDir) throws IOException { + List sources; + try (Stream s = Files.walk(projectDir.resolve("codegen/src/main/java"))) { + sources = s.filter(p -> p.toString().endsWith(".java")).map(Path::toFile).collect(Collectors.toList()); + } + assertFalse(stableName + ": eject wrote no source", sources.isEmpty()); + Path classes = tempFolder.newFolder().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(stableName + ", ejected alone, does not compile", "", errors); + return classes; + } + + private static final Pattern PACKAGED_CLASS = Pattern.compile("com/metaobjects/[A-Za-z0-9_/$]+"); + + /** + * Every packaged class a compiled class file names: the class names, descriptors and + * signatures in its constant pool. A string the generator EMITS never qualifies: emitted + * Java spells packages with dots, and a constant holding whitespace is not a name. + */ + static Set referencedClasses(byte[] classFile) throws IOException { + Set out = new TreeSet<>(); + DataInputStream in = new DataInputStream(new ByteArrayInputStream(classFile)); + in.skipBytes(8); // magic, minor, major + int count = in.readUnsignedShort(); + for (int i = 1; i < count; i++) { + int tag = in.readUnsignedByte(); + switch (tag) { + case 1: { + String text = in.readUTF(); + if (text.indexOf('.') >= 0 || text.chars().anyMatch(Character::isWhitespace)) break; + Matcher m = PACKAGED_CLASS.matcher(text); + while (m.find()) out.add(m.group().replace('/', '.').replaceFirst("\\$.*", "")); + break; + } + case 3: case 4: case 9: case 10: case 11: case 12: case 17: case 18: in.skipBytes(4); break; + case 5: case 6: in.skipBytes(8); i++; break; + case 7: case 8: case 16: case 19: case 20: in.skipBytes(2); break; + case 15: in.skipBytes(3); break; + default: throw new IOException("unknown constant pool tag " + tag); + } + } + return out; + } + + private static Set outsideEngineSurface(Path classes) throws IOException { + Set outside = new TreeSet<>(); + try (Stream s = Files.walk(classes)) { + for (Path file : s.filter(p -> p.toString().endsWith(".class")).collect(Collectors.toList())) { + for (String name : referencedClasses(Files.readAllBytes(file))) { + if (outsideEngineSurface(name)) outside.add(name); + } + } + } + return outside; + } + + @Test + public void depthAnOwnedCopyReferencesOnlyTheEngineSurface() throws Exception { + Map> waiting = pending("depth"); + List problems = new ArrayList<>(); + Set names = new TreeSet<>(); + for (EjectSupport.Entry e : ejectable()) { + names.add(e.stableName); + Set actual = outsideEngineSurface(compileAlone(e.stableName, ejectAlone(e.stableName))); + Set expected = waiting.getOrDefault(e.stableName, Set.of()); + if (actual.equals(expected)) continue; + if (expected.isEmpty()) { + problems.add(e.stableName + ": its owned copy calls emit code that stays in the package: " + actual + + ". Hand it over with the generator; a reference to the engine belongs in " + + "eject.engineSurface.java.engine."); + } else if (actual.isEmpty()) { + problems.add(e.stableName + ": its owned copy is whole now; remove its pending depth entry"); + } else { + problems.add(e.stableName + ": pending depth entries list " + expected + + ", the owned copy references " + actual); + } + } + for (String name : waiting.keySet()) { + if (!names.contains(name)) problems.add(name + ": pending depth entry for a generator that is not ejectable"); + } + assertTrue(String.join("\n", problems), problems.isEmpty()); + } + + @Test + public void depthEveryEngineSurfaceEntryNamesARealClass() { + List stale = new ArrayList<>(); + for (String name : surface("engine")) { + if (name.contains("*")) continue; + try { + Class.forName(name, false, getClass().getClassLoader()); + } catch (ClassNotFoundException | LinkageError e) { + stale.add(name); + } + } + assertEquals("eject.engineSurface.java.engine names classes that do not exist", List.of(), stale); + } + + /** A shared model, and the directory its template refs resolve under. */ + private static final class Corpus { + final String label; + final Path model; + final Path templates; + + Corpus(String label, String model, String templates) { + this.label = label; + this.model = repoRoot().resolve("fixtures").resolve(model); + this.templates = templates == null ? null : repoRoot().resolve("fixtures").resolve(templates); + } + + private MetaDataLoader loader; + + /** Loaded once and shared by both sides: generated headers name the loader. */ + MetaDataLoader loader() throws IOException { + if (loader != null) return loader; + // A loader's name is an identifier. + MetaDataLoader loading = new MetaDataLoader(LoaderOptions.create(false, false, true), + MetaDataLoader.SUBTYPE_MANUAL, "ejectContract_" + label.replace('-', '_')); + if (Files.isDirectory(model)) { + loading.init(); + loading.load(new DirectorySource(model, new DirectorySource.Options()).expandToList()); + } else { + loading.setSourceURIs(List.of(URIHelper.toURI( + "model:file:" + model.toAbsolutePath().toString().replace('\\', '/')))); + loading.init(); + } + assertTrue(label + ": " + loading.getErrors(), loading.getErrors().isEmpty()); + loader = loading; + return loader; + } + } + + /** Shared models that between them make every ejectable generator emit. */ + private static final List CORPORA = List.of( + new Corpus("fitness", "persistence-conformance/canonical/meta.fitness.json", + "persistence-conformance/canonical/prompts"), + new Corpus("requirements", "requirement-test-identity-conformance/worked-example/input", null), + new Corpus("llm-call", "conformance/ai-trace-prompt-nested/input", null)); + + private Map run(Generator generator, Corpus corpus, MetaDataLoader loader, boolean packaged) + throws IOException { + Path out = tempFolder.newFolder().toPath(); + Map args = new LinkedHashMap<>(); + args.put("outputDir", out.toString()); + args.put("packageName", "acme"); + args.put("type", "interface"); // the one argument "entity" requires + // Required by the generators that read template text, whatever the model holds. + args.put("templateRoot", (corpus.templates != null ? corpus.templates : CORPORA.get(0).templates).toString()); + args.put("testPackage", "com.example.requirements"); + args.put("witnessClass", "com.example.Witnesses"); + args.put("warnUncovered", "false"); + // 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); + if (generator instanceof ProjectClassLoaderAware) { + ((ProjectClassLoaderAware) generator).setProjectClassLoader(getClass().getClassLoader()); + } + generator.setArgs(args).execute(loader); + Map tree = new TreeMap<>(); + try (Stream s = Files.walk(out)) { + for (Path p : s.filter(Files::isRegularFile).collect(Collectors.toList())) { + tree.put(out.relativize(p).toString(), Files.readString(p, StandardCharsets.UTF_8)); + } + } + return tree; + } + + @Test + public void depthAnUnchangedOwnedCopyEmitsWhatThePackagedGeneratorEmits() throws Exception { + List silent = new ArrayList<>(); + for (EjectSupport.Entry e : ejectable()) { + Path classes = compileAlone(e.stableName, ejectAlone(e.stableName)); + int emitted = 0; + try (URLClassLoader cl = new URLClassLoader(new URL[]{classes.toUri().toURL()}, getClass().getClassLoader())) { + 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) + .getDeclaredConstructor().newInstance(); + Map a = run(packaged, corpus, corpus.loader(), true); + Map b = run(owned, corpus, corpus.loader(), false); + emitted += a.size(); + assertEquals(where, a.keySet(), b.keySet()); + for (Map.Entry file : a.entrySet()) { + assertEquals(where + ": " + file.getKey(), file.getValue(), b.get(file.getKey())); + } + } + } + // An empty emit on both sides compares equal and proves nothing. + if (emitted == 0) silent.add(e.stableName); + } + assertEquals("emitted nothing over the corpora, so nothing was compared", List.of(), silent); + } +} 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 6e8211d19..280dbc26a 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 @@ -33,18 +33,6 @@ public void everyEntryHasAPortAndAStableName() { } } - @Test - public void onJavaOnlyTheGenericTemplateIsRegisteredButNotEjectable() { - java.util.Set notEjectable = new java.util.TreeSet<>(); - for (EjectSupport.Entry e : EjectSupport.allEntries()) { - if (e.port == EjectSupport.Port.JAVA && e.resourcePath == null) notEjectable.add(e.stableName); - } - // PINNED: template is the Mustache primitive, with no emit logic to own. entity and - // extractor were the other two until ADR-0034 Amendment 6; a generator that joins - // this set has to be ruled on, not slipped in. - assertEquals(java.util.Set.of("template"), notEjectable); - } - @Test public void entityAndExtractorAreEjectableOnBothPortsAndSoNeedAPort() { Map> catalog = EjectSupport.ejectableByStableName(); diff --git a/server/python/tests/codegen/test_eject.py b/server/python/tests/codegen/test_eject.py index c267dac3e..105f001b8 100644 --- a/server/python/tests/codegen/test_eject.py +++ b/server/python/tests/codegen/test_eject.py @@ -41,8 +41,9 @@ def _project(tmp_path: Path, generators: str) -> Path: def test_every_ejectable_entry_names_its_source(tmp_path: Path) -> None: ejectable = [e for e in GENERATOR_REGISTRY.values() if e.source_module is not None] - # `template` is a primitive with no emit logic of its own; everything else is ejectable. - assert {e.name for e in ejectable} == set(GENERATOR_REGISTRY) - {"template"} + # Which generators are ejectable, and why the rest are not, is asserted against the + # shared file in tests/conformance/test_eject_contract.py. + assert ejectable for e in ejectable: module = importlib.import_module(e.source_module) assert callable(getattr(module, e.symbol)), e.name diff --git a/server/python/tests/conformance/eject_contract.py b/server/python/tests/conformance/eject_contract.py new file mode 100644 index 000000000..181d540c3 --- /dev/null +++ b/server/python/tests/conformance/eject_contract.py @@ -0,0 +1,78 @@ +"""The shared eject contract, as this port reads it (ADR-0034 Amendment 8). + +``fixtures/generator-registry-conformance/registry.json`` says, for every generator, whether +an owned copy holds its emit (``"eject": "full"``) or why it is exempt, and in its top-level +``eject`` block what an owned copy may still import (the engine surface) and every place the +contract does not hold yet (``pending``). The tests in ``test_eject_contract.py`` assert the +contract; this module only reads the file. +""" +from __future__ import annotations + +import json +import re +from pathlib import Path +from typing import Any + +PORT = "python" + +ASSERTIONS = ("census", "completeness", "depth") + + +def manifest_path() -> Path: + """Walk up to the repo root (the directory holding both ``fixtures/`` and ``server/``).""" + p = Path(__file__).resolve() + while p != p.parent: + candidate = p / "fixtures" / "generator-registry-conformance" / "registry.json" + if (p / "server").is_dir() and candidate.is_file(): + return candidate + p = p.parent + raise RuntimeError("fixtures/generator-registry-conformance/registry.json not found") + + +def manifest() -> dict[str, Any]: + return json.loads(manifest_path().read_text(encoding="utf-8")) + + +def port_slice() -> dict[str, dict[str, Any]]: + """The generators the manifest expects this port to expose.""" + return {n: s for n, s in manifest()["generators"].items() if PORT in s.get("ports", [])} + + +def is_exempt(name: str) -> bool: + return isinstance(manifest()["generators"][name]["eject"], dict) + + +def pending(assertion: str) -> dict[str, list[dict[str, Any]]]: + """This port's pending entries for one assertion, by generator. A generator has one + entry per gap: a depth entry's ``outside`` lists the imports that gap closes.""" + assert assertion in ASSERTIONS, assertion + out: dict[str, list[dict[str, Any]]] = {} + for p in manifest()["eject"]["pending"]: + if p["port"] == PORT and p["assertion"] == assertion: + out.setdefault(p["generator"], []).append(p) + return out + + +def _glob(pattern: str) -> re.Pattern[str]: + """``**`` matches any run of characters, ``*`` one dotted segment.""" + out = "" + for part in re.split(r"(\*\*|\*)", pattern): + out += ".*" if part == "**" else "[^.]*" if part == "*" else re.escape(part) + return re.compile(out + r"\Z") + + +class EngineSurface: + """What an owned copy may import from the port's codegen packages.""" + + def __init__(self) -> None: + spec = manifest()["eject"]["engineSurface"][PORT] + self._scope = [_glob(p) for p in spec["codegenPackages"]] + self._engine = [_glob(p) for p in spec["engine"]] + self.engine = list(spec["engine"]) + + def in_codegen_packages(self, module: str) -> bool: + return any(p.match(module) for p in self._scope) + + def outside(self, module: str) -> bool: + """True when *module* writes output: inside the codegen packages, not engine.""" + return self.in_codegen_packages(module) and not any(p.match(module) for p in self._engine) diff --git a/server/python/tests/conformance/test_eject_contract.py b/server/python/tests/conformance/test_eject_contract.py new file mode 100644 index 000000000..c603a41c4 --- /dev/null +++ b/server/python/tests/conformance/test_eject_contract.py @@ -0,0 +1,342 @@ +"""The eject contract, asserted for the Python port (ADR-0034 Amendment 8). + +Every stock generator can be ejected, and the owned copy holds its emit. Three assertions, +each driven by ``fixtures/generator-registry-conformance/registry.json``: + +* **Census.** Every generator class under ``metaobjects.codegen`` belongs to a registry + entry, and the registry exposes exactly the names the shared file gives this port. +* **Completeness.** Every registered generator ships eject source, unless the shared file + exempts it with a reason. +* **Depth.** Each generator, ejected alone into an empty project, loads; imports nothing + from the codegen packages outside the engine surface; and emits what the packaged + generator emits. + +Where the contract does not hold yet, the shared file's ``pending`` list says so. A pending +entry that passes fails here, and so does a gap that is not listed. +""" +from __future__ import annotations + +import ast +import importlib +import importlib.util +import inspect +import json +import os +import pkgutil +import subprocess +import sys +from collections import Counter +from pathlib import Path + +import pytest + +import metaobjects +import metaobjects.codegen +from metaobjects.codegen import eject as owned +from metaobjects.codegen.generator import Generator +from metaobjects.codegen.generator_registry import GENERATOR_REGISTRY, GeneratorBuildContext + +from . import eject_contract as contract + +REPO = contract.manifest_path().parents[2] +FIXTURES = REPO / "fixtures" + +#: Models that between them make every ejectable generator emit. ``files`` are loaded +#: together; ``templates`` is the directory template refs resolve under. +CORPORA: dict[str, dict[str, object]] = { + "fitness": { + "files": [FIXTURES / "persistence-conformance" / "canonical" / "meta.fitness.json"], + "templates": FIXTURES / "persistence-conformance" / "canonical" / "prompts", + }, + "requirements": { + "files": sorted( + (FIXTURES / "requirement-test-identity-conformance" / "worked-example" / "input").iterdir() + ), + }, + "llm-call": { + "files": sorted((FIXTURES / "conformance" / "ai-trace-prompt-nested" / "input").iterdir()), + }, +} + +#: The one difference an owned copy's output is allowed: it imports the helper runtime the +#: adopter owns, emitted beside it, where packaged output imports the installed one. +PACKAGED_RUNTIME_IMPORT = "from metaobjects.codegen.runtime." +OWNED_RUNTIME_IMPORT = "from ._runtime." +OWNED_RUNTIME_DIR = "_runtime/" + + +# --------------------------------------------------------------------------------------- +# census +# --------------------------------------------------------------------------------------- + + +def _generator_classes() -> set[type]: + """Every class under ``metaobjects.codegen`` that is a generator: it has ``generate``.""" + found: set[type] = set() + for info in pkgutil.walk_packages(metaobjects.codegen.__path__, "metaobjects.codegen."): + module = importlib.import_module(info.name) + for _, cls in inspect.getmembers(module, inspect.isclass): + if cls.__module__ != info.name or cls is Generator: + continue + if callable(getattr(cls, "generate", None)): + found.add(cls) + return found + + +def test_census_every_generator_class_belongs_to_a_registry_entry() -> None: + registered = {type(e.factory(GeneratorBuildContext())) for e in GENERATOR_REGISTRY.values()} + unregistered = sorted(f"{c.__module__}.{c.__name__}" for c in _generator_classes() - registered) + assert not unregistered, ( + "generator classes no registry entry constructs, so no catalog, eject command or " + f"test sees them: {unregistered}. Register each in generator_registry.py and in " + f"{contract.manifest_path().name}." + ) + + +def test_census_the_registry_exposes_the_names_the_shared_file_gives_this_port() -> None: + expected = set(contract.port_slice()) + actual = set(GENERATOR_REGISTRY) + pending = set(contract.pending("census")) + assert actual - expected == set(), "registered, but the shared file does not list it for python" + assert expected - actual == pending, ( + "the shared file expects these generators in the Python registry and they are not " + f"there: {sorted(expected - actual)}; pending census entries: {sorted(pending)}. " + "Register the generator, or remove the pending entry of one that is now registered." + ) + + +# --------------------------------------------------------------------------------------- +# completeness +# --------------------------------------------------------------------------------------- + + +def test_completeness_every_registered_generator_ships_eject_source_unless_exempt() -> None: + ejectable = {e.name for e in owned.ejectable()} + pending = set(contract.pending("completeness")) + problems: list[str] = [] + for name in sorted(GENERATOR_REGISTRY): + if contract.is_exempt(name): + if name in ejectable: + problems.append(f"{name}: exempt in the shared file but ejectable; drop the exemption") + continue + if name in ejectable and name in pending: + problems.append(f"{name}: ejectable now; remove its pending completeness entry") + if name not in ejectable and name not in pending: + problems.append(f"{name}: no eject source, and the shared file does not exempt it") + problems += [f"{n}: pending completeness entry for a generator this port does not register" + for n in sorted(pending - set(GENERATOR_REGISTRY))] + assert not problems, "\n".join(problems) + + +# --------------------------------------------------------------------------------------- +# depth +# --------------------------------------------------------------------------------------- + + +#: The calls that import a module named by a string: ``importlib.import_module("m")``, +#: a bare ``import_module("m")`` and ``__import__("m")``. +_IMPORT_CALLS = {"import_module", "__import__"} + + +def _imported_by_call(node: ast.Call) -> str | None: + """The module a call imports by a string LITERAL, or ``None``. A name built at run time + cannot be read here; that limit is stated in ADR-0034 Amendment 8.""" + func = node.func + name = func.attr if isinstance(func, ast.Attribute) else func.id if isinstance(func, ast.Name) else None + if name not in _IMPORT_CALLS or not node.args: + return None + first = node.args[0] + return first.value if isinstance(first, ast.Constant) and isinstance(first.value, str) else None + + +def _imports(path: Path, module_name: str) -> set[str]: + """Every module *path* imports, resolved to a dotted name: its import statements, and + its ``import_module`` / ``__import__`` calls on a string literal. A ``from p import a`` + names ``p.a`` when that is a module, else ``p``.""" + package = module_name.rpartition(".")[0] + out: set[str] = set() + for node in ast.walk(ast.parse(path.read_text(encoding="utf-8"))): + if isinstance(node, ast.Call): + named = _imported_by_call(node) + if named is not None: + out.add(named) + elif isinstance(node, ast.Import): + out.update(alias.name for alias in node.names) + elif isinstance(node, ast.ImportFrom): + base = node.module or "" + if node.level: + parent = package.rsplit(".", node.level - 1)[0] if node.level > 1 else package + base = f"{parent}.{base}" if base else parent + for alias in node.names: + candidate = f"{base}.{alias.name}" + is_module = base.startswith("metaobjects") and _is_module(candidate) + out.add(candidate if is_module else base) + return out + + +def _is_module(name: str) -> bool: + try: + return importlib.util.find_spec(name) is not None + except (ImportError, ValueError): + return False + + +def _eject_alone(name: str, root: Path) -> list[Path]: + """Eject *name* by itself into the empty project at *root*; the files eject wrote.""" + root.mkdir(parents=True) + result = owned.eject([name], root) + assert result.code == 0, result.err + return sorted(p for p in (root / "codegen").rglob("*.py") if p.name != "__init__.py") + + +def _outside_engine_surface(name: str, root: Path) -> list[str]: + surface = contract.EngineSurface() + outside: set[str] = set() + for path in _eject_alone(name, root): + module_name = ".".join(path.relative_to(root).with_suffix("").parts) + outside.update(m for m in _imports(path, module_name) if surface.outside(m)) + return sorted(outside) + + +def test_depth_an_owned_copy_imports_only_the_engine_surface(tmp_path: Path) -> None: + pending = contract.pending("depth") + ejectable = {e.name for e in owned.ejectable()} + problems: list[str] = [] + for name in sorted(ejectable): + actual = _outside_engine_surface(name, tmp_path / owned.owned_stem(name)) + expected = sorted(m for entry in pending.get(name, []) for m in entry["outside"]) + if actual == expected: + continue + if not expected: + problems.append( + f"{name}: its owned copy imports emit code that stays in the package: {actual}. " + "Hand it over with the generator; an import of the engine belongs in " + "eject.engineSurface.python.engine." + ) + elif not actual: + problems.append(f"{name}: its owned copy is whole now; remove its pending depth entry") + else: + problems.append(f"{name}: pending depth entry lists {expected}, the owned copy imports {actual}") + problems += [f"{n}: pending depth entry for a generator that is not ejectable" + for n in sorted(set(pending) - ejectable)] + assert not problems, "\n".join(problems) + + +def test_depth_the_scan_reads_string_imports_as_well_as_import_statements(tmp_path: Path) -> None: + probe = tmp_path / "probe.py" + probe.write_text( + "import importlib\n" + "from importlib import import_module\n" + "a = importlib.import_module('metaobjects.codegen.extract_delegate_emitter')\n" + "b = import_module('metaobjects.codegen.type_map')\n" + "c = __import__('metaobjects.codegen.fr010_field_mapping')\n" + "d = importlib.import_module(name)\n" + "from metaobjects.apidocs import PythonApiDocsRenderer\n" + "from metaobjects.apidocs import naming\n", + encoding="utf-8", + ) + found = _imports(probe, "codegen.generators.probe") + assert { + "metaobjects.codegen.extract_delegate_emitter", + "metaobjects.codegen.type_map", + "metaobjects.codegen.fr010_field_mapping", + "metaobjects.apidocs", + "metaobjects.apidocs.naming", + } <= found + surface = contract.EngineSurface() + assert sorted(m for m in found if surface.outside(m)) == [ + "metaobjects.apidocs", + "metaobjects.codegen.extract_delegate_emitter", + "metaobjects.codegen.fr010_field_mapping", + ] + + +def test_depth_every_engine_surface_entry_names_a_real_module() -> None: + stale = [m for m in contract.EngineSurface().engine if "*" not in m and not _is_module(m)] + assert not stale, f"eject.engineSurface.python.engine names modules that do not exist: {stale}" + + +#: Runs in a fresh interpreter with the project root as its working directory, so the owned +#: copy is loaded the way an adopter's ``metaobjects gen`` loads it and nothing this test +#: process imported is in the way. Prints ``{"owned": {...}, "packaged": {...}}``, each +#: ``{corpus: {relative path: content}}``. +_DRIVER = r""" +import json, sys, tempfile +from pathlib import Path + +root, name, corpora = Path(sys.argv[1]), sys.argv[2], json.loads(sys.argv[3]) + +from metaobjects import MetaDataLoader +from metaobjects.codegen import eject as owned +from metaobjects.codegen.config import GenConfig +from metaobjects.codegen.generator_registry import GENERATOR_REGISTRY, GeneratorBuildContext +from metaobjects.codegen.runner import run_gen +from metaobjects.loader.sources import FileSource + +entry = GENERATOR_REGISTRY[name] +out = {"owned": {}, "packaged": {}} +for corpus, spec in corpora.items(): + loaded = MetaDataLoader(strict=True).load([FileSource(Path(f)) for f in spec["files"]]) + assert not loaded.errors, [str(e) for e in loaded.errors] + ctx = GeneratorBuildContext(template_root=spec.get("templates")) + mine, err = owned.build_owned(owned.owned_token(entry), root, ctx) + assert err is None, err + for side, generator in (("owned", mine), ("packaged", entry.factory(ctx))): + with tempfile.TemporaryDirectory() as tmp: + run_gen(GenConfig(out_dir=tmp, emit_package_init=False), loaded.root, generators=[generator]) + out[side][corpus] = { + p.relative_to(tmp).as_posix(): p.read_text(encoding="utf-8") + for p in sorted(Path(tmp).rglob("*")) if p.is_file() + } +print(json.dumps(out)) +""" + + +def _run_alone(name: str, root: Path) -> dict[str, dict[str, dict[str, str]]]: + src = str(Path(metaobjects.__file__).resolve().parents[1]) + env = {**os.environ, "PYTHONPATH": os.pathsep.join( + p for p in (src, os.environ.get("PYTHONPATH", "")) if p)} + corpora = { + corpus: {"files": [str(f) for f in spec["files"]], # type: ignore[union-attr] + **({"templates": str(spec["templates"])} if "templates" in spec else {})} + for corpus, spec in CORPORA.items() + } + proc = subprocess.run( + [sys.executable, "-c", _DRIVER, str(root), name, json.dumps(corpora)], + cwd=root, env=env, capture_output=True, text=True, + ) + assert proc.returncode == 0, f"{name}, ejected alone, does not load and run:\n{proc.stderr}" + return json.loads(proc.stdout) + + +def _lines(text: str) -> Counter[str]: + return Counter(line for line in text.splitlines() if line.strip()) + + +@pytest.mark.parametrize("name", sorted(e.name for e in owned.ejectable())) +def test_depth_an_unchanged_owned_copy_emits_what_the_packaged_generator_emits( + name: str, tmp_path: Path +) -> None: + root = tmp_path / owned.owned_stem(name) + _eject_alone(name, root) + result = _run_alone(name, root) + hands_runtime_over = owned.ejected_source(GENERATOR_REGISTRY[name]) != owned.packaged_source( + GENERATOR_REGISTRY[name]) + + emitted = 0 + for corpus in CORPORA: + packaged, mine = result["packaged"][corpus], result["owned"][corpus] + emitted += len(packaged) + extra = sorted(set(mine) - set(packaged)) + assert sorted(set(packaged) - set(mine)) == [], (name, corpus) + if not hands_runtime_over: + assert extra == [], (name, corpus) + assert mine == packaged, (name, corpus) + continue + # Import sorting moves the now-relative runtime imports below the third-party + # block, so this one comparison is over lines, not positions. + assert all(OWNED_RUNTIME_DIR in path for path in extra), (name, corpus, extra) + for path, text in packaged.items(): + expected = text.replace(PACKAGED_RUNTIME_IMPORT, OWNED_RUNTIME_IMPORT) + assert _lines(mine[path]) == _lines(expected), (name, corpus, path) + assert emitted > 0, f"{name} emitted nothing over {sorted(CORPORA)}: nothing was compared" diff --git a/server/python/tests/conformance/test_generator_registry_conformance.py b/server/python/tests/conformance/test_generator_registry_conformance.py index 57f4d1bc9..40bc22020 100644 --- a/server/python/tests/conformance/test_generator_registry_conformance.py +++ b/server/python/tests/conformance/test_generator_registry_conformance.py @@ -16,6 +16,8 @@ from metaobjects.codegen.generator_registry import GENERATOR_REGISTRY, GeneratorBuildContext +from . import eject_contract + PORT = "python" @@ -45,7 +47,9 @@ def _manifest_python_slice() -> dict[str, dict]: def test_registry_name_set_equals_manifest_python_slice() -> None: - expected = set(_manifest_python_slice().keys()) + # A name the shared file expects here and lists as a pending census gap is the eject + # contract's to report (test_eject_contract.py), which fails once it is registered. + expected = set(_manifest_python_slice().keys()) - set(eject_contract.pending("census")) actual = set(GENERATOR_REGISTRY.keys()) extras = sorted(actual - expected) diff --git a/server/typescript/packages/cli/test/eject-contract.test.ts b/server/typescript/packages/cli/test/eject-contract.test.ts new file mode 100644 index 000000000..de26eeb34 --- /dev/null +++ b/server/typescript/packages/cli/test/eject-contract.test.ts @@ -0,0 +1,578 @@ +// The eject contract, asserted for the TypeScript port (ADR-0034 Amendment 8). +// +// Every stock generator can be ejected, and the owned copy holds its emit. Three +// assertions, each driven by fixtures/generator-registry-conformance/registry.json: +// +// census every generator factory a published codegen package exports belongs to +// a catalog entry, so none ships outside the catalog, `meta eject` and +// every gate that reads them. +// completeness every catalog entry ships a reference template, unless the shared file +// exempts it with a reason. +// depth each generator, ejected ALONE into an empty project by the real +// `meta eject`, loads; reaches nothing in the codegen packages outside +// the engine surface (values, types and dynamic imports alike); and emits +// what the packaged generator emits. +// +// Where the contract does not hold yet, the shared file's `pending` list says so. A +// pending entry that passes fails here, and so does a gap that is not listed. +// +// This lives in `cli` for the reason catalog-conformance.test.ts does: it is the only +// package that sees all three registry slices, and the only one that owns `meta eject`. + +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { join, relative, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import ts from "typescript"; +import { + buildPkMap, buildRelationMap, defineConfig, makeRenderContext, runGen, HTTP_RUNTIME_PACKAGE, OWNED_RUNTIME_DIR, +} from "@metaobjectsdev/codegen-ts"; +import type { GenContext, Generator } from "@metaobjectsdev/codegen-ts"; +import { MetaDataLoader } from "@metaobjectsdev/metadata"; +import { FileSource } from "@metaobjectsdev/metadata/core"; +import { catalogPackages, factoryModuleOf, listCatalog, packageOf } from "../src/lib/catalog.js"; +import { ejectGenerator } from "../src/commands/eject.js"; + +const PORT = "typescript"; +const REPO_ROOT = resolve(import.meta.dirname, "..", "..", "..", "..", ".."); +const PACKAGES = join(REPO_ROOT, "server", "typescript", "packages"); +const FIXTURES = join(REPO_ROOT, "fixtures"); +const MANIFEST_PATH = join(FIXTURES, "generator-registry-conformance", "registry.json"); +/** Inside the workspace, so an ejected generator resolves `@metaobjectsdev/*` as a project's does. */ +const WORKSPACE_TMP = resolve(import.meta.dirname, "fixtures", "__tmp__"); +const CODEGEN_ENGINE = "@metaobjectsdev/codegen-ts"; + +const ASSERTIONS = ["census", "completeness", "depth"] as const; +type Assertion = (typeof ASSERTIONS)[number]; +interface Pending { generator: string; port: string; assertion: Assertion; gap: number; outside?: string[] } +interface Manifest { + ports: string[]; + generators: Record; + eject: { + engineSurface: Record; + exemptEmitters: { name: string; port: string; path: string; exempt: string; ruling: string }[]; + pending: Pending[]; + }; +} +const manifest = JSON.parse(readFileSync(MANIFEST_PATH, "utf8")) as Manifest; + +/** This port's pending entries for one assertion, by generator (one entry per gap). */ +function pending(assertion: Assertion): Map { + const out = new Map(); + for (const p of manifest.eject.pending) { + if (p.port !== PORT || p.assertion !== assertion) continue; + out.set(p.generator, [...(out.get(p.generator) ?? []), p]); + } + return out; +} + +const isExempt = (name: string): boolean => manifest.generators[name]?.eject !== "full"; + +/** `**` matches any run of characters, `*` one path segment. */ +function glob(pattern: string): RegExp { + const body = pattern + .split(/(\*\*|\*)/) + .map((part) => (part === "**" ? ".*" : part === "*" ? "[^/]*" : part.replace(/[.+?^${}()|[\]\\]/g, "\\$&"))) + .join(""); + return new RegExp(`^${body}$`); +} + +const SURFACE = manifest.eject.engineSurface[PORT]!; +const CODEGEN_PACKAGES = SURFACE.codegenPackages.map(glob); +const ENGINE = SURFACE.engine.map(glob); +/** True for a module that writes output: inside the codegen packages, not engine. */ +const isOutsideEngineSurface = (module: string): boolean => + CODEGEN_PACKAGES.some((p) => p.test(module)) && !ENGINE.some((p) => p.test(module)); + +const COMPILER_OPTIONS: ts.CompilerOptions = { + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + // The condition Bun resolves the workspace packages by: source, not a built `dist/`. + customConditions: ["bun"], + target: ts.ScriptTarget.ES2023, + jsx: ts.JsxEmit.ReactJSX, + strict: true, + noEmit: true, + skipLibCheck: true, + types: [], +}; + +const catalog = listCatalog(); + +/** Which packages a release publishes: the repository's one definition, read not restated. */ +const { publishSet } = (await import(pathToFileURL(join(REPO_ROOT, "scripts", "publish-set.mjs")).href)) as { + publishSet: () => { set: { dir: string }[] }; +}; + +// --------------------------------------------------------------------------------------- +// census +// --------------------------------------------------------------------------------------- + +/** + * Every exported function, in every public entry point of every PUBLISHED package that is + * or depends on the codegen engine, whose return type is a `Generator`. Read from the + * types, never called: `templateGenerator` and `sharedModelFile` take required options. + */ +function exportedGeneratorFactories(): Map { + const entryFiles: string[] = []; + for (const { dir } of publishSet().set) { + const pkg = JSON.parse(readFileSync(join(REPO_ROOT, dir, "package.json"), "utf8")) as { + name: string; + dependencies?: Record; + peerDependencies?: Record; + exports?: Record; + }; + const deps = { ...pkg.dependencies, ...pkg.peerDependencies }; + if (pkg.name !== CODEGEN_ENGINE && !(CODEGEN_ENGINE in deps)) continue; + for (const target of Object.values(pkg.exports ?? {})) { + const source = typeof target === "string" ? target : target.bun; + if (source?.endsWith(".ts") === true) entryFiles.push(join(REPO_ROOT, dir, source)); + } + } + const generatorModule = join(PACKAGES, "codegen-ts", "src", "generator.ts"); + const program = ts.createProgram({ rootNames: [...entryFiles, generatorModule], options: COMPILER_OPTIONS }); + const checker = program.getTypeChecker(); + const moduleOf = (file: string) => checker.getSymbolAtLocation(program.getSourceFile(file)!)!; + const generatorSymbol = checker.getExportsOfModule(moduleOf(generatorModule)).find((s) => s.name === "Generator")!; + const generatorType = checker.getDeclaredTypeOfSymbol(generatorSymbol); + + const found = new Map(); + for (const file of entryFiles) { + for (const exported of checker.getExportsOfModule(moduleOf(file))) { + const target = exported.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(exported) : exported; + const declaration = target.valueDeclaration; + if (declaration === undefined) continue; + const returnsGenerator = checker + .getTypeOfSymbolAtLocation(target, declaration) + .getCallSignatures() + .some((signature) => { + const returned = signature.getReturnType(); + return !(returned.flags & ts.TypeFlags.Any) && checker.isTypeAssignableTo(returned, generatorType); + }); + if (returnsGenerator) found.set(exported.name, relative(PACKAGES, declaration.getSourceFile().fileName)); + } + } + return found; +} + +describe("eject contract — census", () => { + test("every exported generator factory belongs to a catalog entry", () => { + const factories = exportedGeneratorFactories(); + // Vacuity guard: the scan reads types, so a resolution failure finds nothing at all. + expect(factories.size).toBeGreaterThan(20); + const registered = new Set(catalog.map((e) => e.factoryExport)); + const unregistered = [...factories] + .filter(([name]) => !registered.has(name)) + .map(([name, file]) => `${name} (${file})`) + .sort(); + expect( + unregistered, + "generator factories no catalog entry names, so `meta gen --list`, `meta eject` and every " + + `gate that reads the catalog cannot see them. Register each in its package's registry and in ${MANIFEST_PATH}.`, + ).toEqual([]); + }); + + test("the catalog exposes the names the shared file gives this port", () => { + const expected = Object.entries(manifest.generators).filter(([, e]) => e.ports.includes(PORT)).map(([n]) => n); + const actual = new Set(catalog.map((e) => e.name)); + const missing = expected.filter((n) => !actual.has(n)).sort(); + expect(missing, "expected in the catalog by the shared file; a pending census entry must list each").toEqual( + [...pending("census").keys()].sort(), + ); + }); + + test("every exempt non-generator emitter names code that exists, with a reason and a ruling", () => { + for (const emitter of manifest.eject.exemptEmitters) { + expect(manifest.ports).toContain(emitter.port); + expect(existsSync(join(REPO_ROOT, emitter.path)), `${emitter.name}: ${emitter.path}`).toBe(true); + expect(emitter.exempt.trim()).not.toBe(""); + expect(emitter.ruling.trim()).not.toBe(""); + } + }); +}); + +// --------------------------------------------------------------------------------------- +// completeness +// --------------------------------------------------------------------------------------- + +describe("eject contract — completeness", () => { + test("every catalog entry ships a reference template unless the shared file exempts it", () => { + const waiting = pending("completeness"); + const registered = new Set(catalog.map((e) => e.name)); + const problems: string[] = []; + for (const entry of catalog) { + if (isExempt(entry.name)) { + if (entry.ejectable) problems.push(`${entry.name}: exempt in the shared file but ejectable; drop the exemption`); + continue; + } + if (entry.ejectable && waiting.has(entry.name)) { + problems.push(`${entry.name}: ejectable now; remove its pending completeness entry`); + } + if (!entry.ejectable && !waiting.has(entry.name)) { + problems.push(`${entry.name}: no reference template, and the shared file does not exempt it`); + } + } + for (const name of waiting.keys()) { + if (!registered.has(name)) problems.push(`${name}: pending completeness entry for a generator the catalog does not hold`); + } + expect(problems).toEqual([]); + }); + + test("each package's reference directory holds exactly its ejectable generators", () => { + // `REFERENCE_GENERATOR_NAMES` and its two siblings are hand-kept lists; this holds + // each to the files on disk, so a template cannot ship unlisted or be listed unshipped. + for (const pkg of catalogPackages()) { + const dir = join(PACKAGES, pkg.slice(pkg.indexOf("/") + 1), "src", "reference"); + const onDisk = readdirSync(dir).map((f) => f.replace(/\.tsx?$/, "")).sort(); + const listed = catalog.filter((e) => e.ejectable && packageOf(e.name) === pkg).map((e) => e.name).sort(); + expect(onDisk, dir).toEqual(listed); + } + }); +}); + +// --------------------------------------------------------------------------------------- +// depth +// --------------------------------------------------------------------------------------- + +interface Owned { + name: string; + /** The empty project `meta eject ` ran in. */ + root: string; + /** Every file eject wrote, absolute: the generator, and any runtime handed over with it. */ + files: string[]; + generatorPath: string; + handsRuntimeOver: boolean; +} + +function filesUnder(dir: string): string[] { + if (!existsSync(dir)) return []; + return readdirSync(dir, { recursive: true, withFileTypes: true }) + .filter((f) => f.isFile()) + .map((f) => join(f.parentPath, f.name)) + .sort(); +} + +/** + * The codegen-package modules the owned files reach, outside the engine surface. Each + * imported binding is resolved through any re-exporting barrel to the file that declares + * it. Three ways in are counted: a static import or re-export (a type-only one too, since + * a type declared in an emit module ties the copy to that module's shape), an inline + * `import("…").Type`, and a dynamic `import("…")`, which is counted as the module it names. + */ +function outsideEngineSurface(files: readonly string[]): string[] { + const sources = files.filter((f) => /\.tsx?$/.test(f)); + const program = ts.createProgram({ rootNames: sources, options: COMPILER_OPTIONS }); + const checker = program.getTypeChecker(); + const outside = new Set(); + + const recordFile = (fileName: string): void => { + const module = relative(PACKAGES, fileName); + if (isOutsideEngineSurface(module)) outside.add(module); + }; + const record = (symbol: ts.Symbol | undefined): void => { + if (symbol === undefined) return; + const target = symbol.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(symbol) : symbol; + for (const declaration of target.declarations ?? []) recordFile(declaration.getSourceFile().fileName); + }; + const isPackage = (specifier: ts.Node | undefined): specifier is ts.StringLiteral => + specifier !== undefined && ts.isStringLiteral(specifier) && !specifier.text.startsWith("."); + /** Everything a module exports, for an `import *` or an `export *`. */ + const recordAll = (specifier: ts.StringLiteral): void => { + const module = checker.getSymbolAtLocation(specifier); + if (module !== undefined) for (const exported of checker.getExportsOfModule(module)) record(exported); + }; + + const visit = (node: ts.Node): void => { + if (ts.isImportDeclaration(node) && isPackage(node.moduleSpecifier)) { + const clause = node.importClause; + if (clause?.name !== undefined) record(checker.getSymbolAtLocation(clause.name)); + const bindings = clause?.namedBindings; + if (bindings !== undefined && ts.isNamespaceImport(bindings)) recordAll(node.moduleSpecifier); + else if (bindings !== undefined) for (const e of bindings.elements) record(checker.getSymbolAtLocation(e.name)); + } else if (ts.isExportDeclaration(node) && isPackage(node.moduleSpecifier)) { + const clause = node.exportClause; + if (clause !== undefined && ts.isNamedExports(clause)) { + for (const e of clause.elements) record(checker.getSymbolAtLocation(e.name)); + } else { + recordAll(node.moduleSpecifier); + } + } else if (ts.isImportTypeNode(node) && ts.isLiteralTypeNode(node.argument) && isPackage(node.argument.literal)) { + // `import("pkg").Name`: the named member when there is one, else the whole module. + const named = node.qualifier === undefined ? undefined : checker.getSymbolAtLocation(node.qualifier); + if (named !== undefined) record(named); + else recordAll(node.argument.literal); + } else if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword) { + // `await import("pkg")` hands back the whole module, so the module is what counts: + // a barrel that re-exports emit code is itself outside the engine surface. + const specifier = node.arguments[0]; + if (isPackage(specifier)) { + const module = checker.getSymbolAtLocation(specifier); + for (const declaration of module?.declarations ?? []) recordFile(declaration.getSourceFile().fileName); + } + } + ts.forEachChild(node, visit); + }; + for (const file of sources) visit(program.getSourceFile(file)!); + return [...outside].sort(); +} + +interface Corpus { + label: string; + files: string[]; + /** Copied to `/templates`, where render-helper's gen-time drift gate reads. */ + templates?: string; +} + +function inputs(...segments: string[]): string[] { + const dir = join(FIXTURES, ...segments); + return readdirSync(dir).sort().map((f) => join(dir, f)); +} + +/** Shared models that between them make every ejectable generator emit. */ +const CORPORA: Corpus[] = [ + { label: "fitness", files: [join(FIXTURES, "persistence-conformance", "canonical", "meta.fitness.json")] }, + { + label: "template-output", + files: [join(FIXTURES, "template-output-render-conformance", "meta.json")], + templates: join(FIXTURES, "template-output-render-conformance", "templates"), + }, + { label: "requirements", files: inputs("requirement-test-identity-conformance", "worked-example", "input") }, + { label: "stored-proc", files: inputs("conformance", "parameter-ref-on-stored-proc", "input") }, + // The grid tier is TypeScript-only, so its model is this port's own fixture. + { label: "data-grid", files: [join(PACKAGES, "codegen-ts-tanstack", "test", "fixtures", "single-entity.json")] }, + { label: "llm-call", files: inputs("conformance", "ai-trace-prompt-nested", "input") }, +]; + +/** + * The options a factory cannot be called without, given to the packaged factory and the + * owned one alike. `shared-model` publishes nothing until it is told what to include. + */ +const FACTORY_OPTIONS: Record = { + "shared-model": { name: "shared", include: ["**"], version: "1.0.0" }, +}; + +/** The key a refusal is recorded under, in place of the files a run would have written. */ +const REFUSED = ""; + +type Factory = (opts?: unknown) => Generator; +const build = (factory: Factory, name: string): Generator => + name in FACTORY_OPTIONS ? factory(FACTORY_OPTIONS[name]) : factory(); + +/** Run one generator over one corpus the way its door runs it; the files it wrote. */ +async function emit( + generator: Generator, + corpus: Corpus, + projectRoot: string, + outDir: string, + throughDocsDoor: boolean, +): Promise> { + const loaded = await new MetaDataLoader().load(corpus.files.map((f) => new FileSource(f))); + expect(loaded.errors, corpus.label).toEqual([]); + const settings = { outDir, extStyle: "none", dbImport: "../db", dialect: "postgres" } as const; + if (throughDocsDoor) { + // `meta docs` runs a docs generator, never `runGen`, which skips one: `.generate(ctx)` + // with the generator's own `filter` as `ctx.matches`. + const ctx: GenContext = { + entities: loaded.root.objects(), + loadedRoot: loaded.root, + matches: generator.filter ?? (() => true), + config: settings, + renderContext: makeRenderContext({ + dialect: "postgres", + loadedRoot: loaded.root, + outDir, + dbImport: "../db", + apiPrefix: "", + pkMap: buildPkMap(loaded.root), + relationMap: buildRelationMap(loaded.root), + }), + projectRoot, + warn: () => {}, + }; + return Object.fromEntries((await generator.generate(ctx)).map((f) => [f.path, f.content])); + } + try { + await runGen({ + config: defineConfig({ ...settings, generators: [generator] }), + metadata: loaded.root, + projectRoot, + sourceFiles: corpus.files, + }); + } catch (e) { + // A generator may refuse a model (`shared-model` re-reads the source files strictly). + // The refusal is then what the two sides are compared on: the same words, or a failure. + return { [REFUSED]: e instanceof Error ? e.message : String(e) }; + } + const out: Record = {}; + for (const file of filesUnder(outDir)) out[relative(outDir, file)] = readFileSync(file, "utf8"); + return out; +} + +/** + * The one difference an owned copy's output is allowed: it imports the HTTP adapter the + * adopter owns in `codegen/runtime/`, where packaged output imports the package. The + * formatter orders a package import and a relative one differently, so the import block + * is compared as a set of statements and everything after it exactly. + */ +function adapterNeutral(source: string): string { + const adapter = "@@ADAPTER@@"; + const moved = source + .replace(new RegExp(`${HTTP_RUNTIME_PACKAGE}/[\\w-]+`, "g"), adapter) + .replace(new RegExp(`(?:\\.\\./)+${OWNED_RUNTIME_DIR}/[\\w-]+/[\\w-]+`, "g"), adapter); + const IMPORT = /^import [^;]*;\n/gm; + return [...(moved.match(IMPORT) ?? [])].sort().join("") + moved.replace(IMPORT, ""); +} + +describe("eject contract — depth", () => { + const owned = new Map(); + + beforeAll(async () => { + mkdirSync(WORKSPACE_TMP, { recursive: true }); + for (const entry of catalog) { + if (!entry.ejectable) continue; + const root = mkdtempSync(join(WORKSPACE_TMP, "eject-contract-")); + const result = await ejectGenerator({ cwd: root, name: entry.name }); + owned.set(entry.name, { + name: entry.name, + root, + files: filesUnder(join(root, "codegen")), + generatorPath: join(root, result.path), + handsRuntimeOver: (result.runtime?.files ?? []).length > 0, + }); + } + }); + + afterAll(() => { + for (const { root } of owned.values()) rmSync(root, { recursive: true, force: true }); + }); + + test("the scan counts a type-only import, an inline import type and a dynamic import", () => { + // The scan is what the assertion below rests on, so it is held to a file whose + // answer is known: three ways into emit code, and one import of the engine. + const root = mkdtempSync(join(WORKSPACE_TMP, "eject-contract-probe-")); + try { + const probe = join(root, "probe.ts"); + writeFileSync( + probe, + [ + 'import type { Generator, RequirementTestsOpts } from "@metaobjectsdev/codegen-ts";', + 'export type Row = import("@metaobjectsdev/codegen-ts").RequirementRow;', + "export type Kept = [Generator, RequirementTestsOpts];", + 'export const load = () => import("@metaobjectsdev/codegen-ts/generators");', + "", + ].join("\n"), + ); + expect(outsideEngineSurface([probe])).toEqual([ + "codegen-ts/src/generators/index.ts", + "codegen-ts/src/generators/requirement-tests.ts", + "codegen-ts/src/generators/requirements-view.ts", + ]); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }, 60_000); + + test("an owned copy imports only the engine surface", () => { + const waiting = pending("depth"); + const problems: string[] = []; + for (const [name, copy] of owned) { + const actual = outsideEngineSurface(copy.files); + const expected = (waiting.get(name) ?? []).flatMap((p) => p.outside ?? []).sort(); + if (JSON.stringify(actual) === JSON.stringify(expected)) continue; + if (expected.length === 0) { + problems.push( + `${name}: its owned copy imports emit code that stays in the package: ${actual.join(", ")}. ` + + `Hand it over with the generator; an import of the engine belongs in eject.engineSurface.${PORT}.engine.`, + ); + } else if (actual.length === 0) { + problems.push(`${name}: its owned copy is whole now; remove its pending depth entry`); + } else { + problems.push(`${name}: pending depth entries list [${expected.join(", ")}], the owned copy imports [${actual.join(", ")}]`); + } + } + for (const name of waiting.keys()) { + if (!owned.has(name)) problems.push(`${name}: pending depth entry for a generator that is not ejectable`); + } + expect(problems).toEqual([]); + }, 60_000); + + test("every engine surface entry names a real module", () => { + const stale = SURFACE.engine.filter((m) => !m.includes("*") && !existsSync(join(PACKAGES, m))); + expect(stale).toEqual([]); + }); + + for (const entry of catalog) { + if (!entry.ejectable) continue; + test(`${entry.name}: an unchanged owned copy loads alone and emits what the packaged generator emits`, async () => { + const copy = owned.get(entry.name)!; + const module = (await import(copy.generatorPath)) as Record; + const factory = module[entry.factoryExport]; + expect(typeof factory, `${copy.generatorPath} exports ${entry.factoryExport}`).toBe("function"); + // The packaged side is the factory an adopter imports, by the name the catalog prints. + const shipped = ((await import(factoryModuleOf(entry.name)!)) as Record)[entry.factoryExport]; + const throughDocsDoor = entry.docsSurface !== undefined; + + let emitted = 0; + for (const corpus of CORPORA) { + if (corpus.templates !== undefined) cpSync(corpus.templates, join(copy.root, "templates"), { recursive: true }); + // Same depth under the project root on both sides, so a relative import of the + // owned adapter is spelled the same way in each. + const packaged = await emit( + build(shipped as Factory, entry.name), corpus, copy.root, join(copy.root, "out", corpus.label, "packaged"), throughDocsDoor); + const mine = await emit( + build(factory as Factory, entry.name), corpus, copy.root, join(copy.root, "out", corpus.label, "owned"), throughDocsDoor); + rmSync(join(copy.root, "templates"), { recursive: true, force: true }); + emitted += Object.keys(packaged).filter((path) => path !== REFUSED).length; + expect(Object.keys(mine).sort(), `${entry.name} over ${corpus.label}`).toEqual(Object.keys(packaged).sort()); + for (const [path, content] of Object.entries(packaged)) { + const [a, b] = copy.handsRuntimeOver ? [adapterNeutral(content), adapterNeutral(mine[path]!)] : [content, mine[path]!]; + expect(`${path}:\n${b}`, `${entry.name} over ${corpus.label}`).toBe(`${path}:\n${a}`); + } + } + // An empty emit on both sides compares equal and proves nothing. + expect(emitted, `${entry.name} emitted nothing over ${CORPORA.map((c) => c.label).join(", ")}`).toBeGreaterThan(0); + }); + } +}); + +// --------------------------------------------------------------------------------------- +// the shared file itself +// --------------------------------------------------------------------------------------- + +describe("eject contract — the shared file", () => { + test("every exemption carries a reason and a ruling", () => { + for (const [name, spec] of Object.entries(manifest.generators)) { + if (spec.eject === "full") continue; + expect(Object.keys(spec.eject).sort(), name).toEqual(["exempt", "ruling"]); + expect(spec.eject.exempt.trim(), name).not.toBe(""); + expect(spec.eject.ruling.trim(), name).not.toBe(""); + } + }); + + test("every port declares an engine surface", () => { + expect(Object.keys(manifest.eject.engineSurface).sort()).toEqual([...manifest.ports].sort()); + }); + + test("every pending entry is well formed, and listed once", () => { + const seen = new Set(); + for (const entry of manifest.eject.pending) { + const where = JSON.stringify(entry); + expect(Object.keys(entry).filter((key) => key !== "outside").sort(), where) + .toEqual(["assertion", "gap", "generator", "port"]); + expect(manifest.ports, where).toContain(entry.port); + expect(ASSERTIONS as readonly string[], where).toContain(entry.assertion); + expect(Object.keys(manifest.generators), where).toContain(entry.generator); + expect(Number.isInteger(entry.gap), where).toBe(true); + expect(entry.outside !== undefined, where).toBe(entry.assertion === "depth"); + if (entry.outside !== undefined) { + expect(entry.outside.length, where).toBeGreaterThan(0); + expect(entry.outside, where).toEqual([...entry.outside].sort()); + } + // One entry per generator, port and assertion; a depth entry per gap as well. + const identity = JSON.stringify( + [entry.generator, entry.port, entry.assertion, entry.outside === undefined ? null : entry.gap]); + expect(seen.has(identity), `duplicate pending entry: ${where}`).toBe(false); + seen.add(identity); + } + }); +}); diff --git a/server/typescript/packages/cli/test/gen-list.test.ts b/server/typescript/packages/cli/test/gen-list.test.ts index 9f045bb97..4ea16ca66 100644 --- a/server/typescript/packages/cli/test/gen-list.test.ts +++ b/server/typescript/packages/cli/test/gen-list.test.ts @@ -3,6 +3,7 @@ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { genCommand } from "../src/commands/gen.js"; +import { listCatalog } from "../src/lib/catalog.js"; // ADR-0021 D3 — `meta gen --list` discoverability surface. // @@ -93,29 +94,21 @@ describe("meta gen --list (ADR-0021 D3)", () => { } }); - test("marks each generator that ships no reference copy as package-only, and says why", async () => { + test("marks exactly the generators that ship no reference copy as package-only, and says why", async () => { // The note above says `meta eject` copies a generator; for an entry with no // reference template that is false, so the row has to say so where it is read. + // WHICH entries those are is the eject contract's to rule on (eject-contract.test.ts, + // against the shared manifest); this holds the mark to the catalog, row by row. const tmp = mkdtempSync(join(tmpdir(), "meta-gen-list-")); try { await genCommand(["--list"], tmp); const text = out(); const line = (name: string) => text.split("\n").find((l) => l.startsWith(` ${name} `)) ?? ""; - // The one entry left: the Mustache primitive, where the adopter already supplies - // the template and there is no emit logic to own (ADR-0034 Amendment 7). - expect(line("template")).toContain("package-only"); - expect(text.split("\n").filter((l) => l.includes("[package-only]")).length).toBe(1); - // An ejectable entry is never marked. `callable` and `trace-helper` were marked - // until they gained reference templates; `shared-model` and the docs tier until - // Amendment 7. - expect(line("callable")).not.toContain("package-only"); - expect(line("trace-helper")).not.toContain("package-only"); - expect(line("shared-model")).not.toContain("package-only"); - expect(line("docs")).not.toContain("package-only"); - expect(line("api-docs")).not.toContain("package-only"); - expect(line("prompt-render")).not.toContain("package-only"); - expect(line("entity")).not.toContain("package-only"); - expect(text).toContain("package-only = no reference template ships"); + const catalog = listCatalog(); + for (const entry of catalog) { + expect(line(entry.name).includes("package-only"), entry.name).toBe(!entry.ejectable); + } + expect(text.includes("package-only = no reference template ships")).toBe(catalog.some((e) => !e.ejectable)); } finally { rmSync(tmp, { recursive: true, force: true }); } diff --git a/server/typescript/packages/cli/test/unit/reporting-inert.test.ts b/server/typescript/packages/cli/test/unit/reporting-inert.test.ts index 612f717fa..1e3e83b64 100644 --- a/server/typescript/packages/cli/test/unit/reporting-inert.test.ts +++ b/server/typescript/packages/cli/test/unit/reporting-inert.test.ts @@ -150,6 +150,9 @@ const ADDED_FOR_WITH: Readonly> = { }; /** The one existing file a served report changes: it gains the report's export line. */ const BARREL = `${OUT}/index.ts`; +/** The agent UI page, which `agent-docs` writes when the run also wires the client tier. It + * is one page for the whole model, so a served report adds its section to it. */ +const AGENT_UI_PAGE = `${OUT}/agent/ui.md`; /** The client UI generators that emit NOTHING for a report: a report has a list hook and no * grid, grid hook or form. */ const UI_TIER = ["grid", "grid-hook", "form"] as const; @@ -158,16 +161,17 @@ const UI_TIER = ["grid", "grid-hook", "form"] as const; const SOURCELESS_REPORTS = ["ProgramEngagement", "DailyRevenue", "ProgramCatalogue"] as const; /** Assert `actual` is `expected` plus exactly `added`, with every shared file - * byte-identical except the barrel. */ + * byte-identical except the barrel and any whole-model page named in `grows`. */ function expectOnlyAdds( expected: Record, actual: Record, added: readonly string[], + grows: readonly string[] = [], ): void { expect(Object.keys(actual).filter((p) => !(p in expected)).sort()).toEqual([...added].sort()); expect(Object.keys(expected).filter((p) => !(p in actual))).toEqual([]); for (const [path, content] of Object.entries(expected)) { - if (path === BARREL) continue; + if (path === BARREL || grows.includes(path)) continue; expect({ path, content: actual[path] }).toEqual({ path, content }); } // Nothing at all for a sourceless report, in any file name. @@ -306,7 +310,16 @@ describe("FR-044 a sourceless report is inert; a served report emits exactly its expect(expected[""]).toBeUndefined(); expect(actual[""]).toBeUndefined(); expect(Object.keys(expected).length).toBeGreaterThan(10); - expectOnlyAdds(expected, actual, Object.values(ADDED_FOR_WITH).flat()); + expectOnlyAdds(expected, actual, Object.values(ADDED_FOR_WITH).flat(), [AGENT_UI_PAGE]); + // The agent UI page moves by the served report's section alone: every line it had is + // still there, in order, and the section names the report. + const before = expected[AGENT_UI_PAGE]!.split("\n"); + const after = actual[AGENT_UI_PAGE]!.split("\n"); + let kept = 0; + for (const line of after) if (line === before[kept]) kept++; + expect(kept).toBe(before.length); + expect(after.filter((l) => !before.includes(l)).join("\n")).toContain("StoreTotals"); + for (const name of SOURCELESS_REPORTS) expect(actual[AGENT_UI_PAGE]).not.toContain(name); // In the full suite the barrel re-exports the report's modules. expect(actual[BARREL]).toContain("StoreTotals"); expect(expected[BARREL]).not.toContain("StoreTotals"); diff --git a/server/typescript/packages/codegen-ts/src/generator-registry.ts b/server/typescript/packages/codegen-ts/src/generator-registry.ts index d2e0aa01a..1133d8088 100644 --- a/server/typescript/packages/codegen-ts/src/generator-registry.ts +++ b/server/typescript/packages/codegen-ts/src/generator-registry.ts @@ -48,6 +48,8 @@ import { traceHelperFile, sharedModelFile, requirementTests, + requirementsFile, + agentDocsFile, cubeModel, } from "./generators/index.js"; @@ -310,6 +312,31 @@ export const generatorRegistry: Record = { note: "neutral artifact — use `meta docs` (the single docs door, ADR-0021 D1); not part of the recommended `meta gen` native suite.", }, + requirements: { + name: "requirements", + kind: "generator", + layer: "docs", + description: "The requirement ledger as documentation: requirements.md for people and requirements.toon for tools.", + tier: "neutral", + factory: () => requirementsFile(), + factoryExport: "requirementsFile", + options: "outDir?, target?", + ejectable: ejectable("requirements"), + note: "neutral artifact — `meta docs` writes it as its requirements surface; a project that declares no requirement.* node gets no file.", + }, + "agent-docs": { + name: "agent-docs", + kind: "generator", + layer: "docs", + description: "Pages written for a coding agent working in the project: the schema, the UI surface and the requirements.", + tier: "neutral", + factory: () => agentDocsFile(), + factoryExport: "agentDocsFile", + options: "subDir?, schema?, columnNamingStrategy?, target?", + ejectable: ejectable("agent-docs"), + note: "neutral artifact — `meta docs --agent` writes it; not part of the recommended `meta gen` native suite.", + }, + // ----- capability ------------------------------------------------------- // Not chosen by browsing this list — chosen because the MODEL already asks for // them. `meta gen --list --probe` reports a real file count per entry. diff --git a/server/typescript/packages/codegen-ts/src/generators/index.ts b/server/typescript/packages/codegen-ts/src/generators/index.ts index 402e79796..8e113c67c 100644 --- a/server/typescript/packages/codegen-ts/src/generators/index.ts +++ b/server/typescript/packages/codegen-ts/src/generators/index.ts @@ -14,10 +14,12 @@ // // Which entries have a reference copy is declared once, in `REFERENCE_GENERATOR_NAMES` // (reference-templates.ts); `meta gen --list` marks every other entry `package-only` — -// today that is `template` alone, the Mustache primitive, which has no emit logic of its -// own. `docsFile` and `apiDocsFile` are exported here as the generators `meta docs` runs; -// they need no wiring. An ejected copy of either is listed in `docs: { generators: [...] }`, -// and `meta docs` then runs the copy (Amendment 7). +// today that is `template`, the Mustache primitive, which has no emit logic of its own, +// and `requirements` and `agent-docs`, which do not ship a reference template yet. +// +// `docsFile` and `apiDocsFile` are exported here as the generators `meta docs` runs; they +// need no wiring. An ejected copy of either is listed in `docs: { generators: [...] }`, and +// `meta docs` then runs the copy (Amendment 7). // See spec/decisions/ADR-0034-codegen-scaffold-and-own.md. export { entityFile, type EntityFileOpts } from "./entity-file.js"; diff --git a/server/typescript/packages/codegen-ts/src/generators/requirement-tests.ts b/server/typescript/packages/codegen-ts/src/generators/requirement-tests.ts index 63e0ba132..a441b841b 100644 --- a/server/typescript/packages/codegen-ts/src/generators/requirement-tests.ts +++ b/server/typescript/packages/codegen-ts/src/generators/requirement-tests.ts @@ -27,15 +27,14 @@ import { requirementTestUnits, requirementTestIdentity, NO_CONCERN, + type RequirementTestArgs, type RequirementTestGrain, + type RequirementTestRenderer, type RequirementView, } from "../requirement-walk.js"; -import { - renderRequirementTest, - type RequirementTestArgs, -} from "../templates/requirement-test.js"; +import { renderRequirementTest } from "../templates/requirement-test.js"; -export type RequirementTestRenderer = (a: RequirementTestArgs) => string; +export type { RequirementTestRenderer }; export interface RequirementTestsOpts { /** Generator name — surfaces in diagnostics and drift logs. */ diff --git a/server/typescript/packages/codegen-ts/src/requirement-walk.ts b/server/typescript/packages/codegen-ts/src/requirement-walk.ts index 85252d274..ae0c0197e 100644 --- a/server/typescript/packages/codegen-ts/src/requirement-walk.ts +++ b/server/typescript/packages/codegen-ts/src/requirement-walk.ts @@ -176,6 +176,39 @@ export function assertRequirementTestGrain(grain: unknown): asserts grain is Req } } +/** + * What a requirement-test renderer is handed for one stub. Declared here, beside the + * walk that fills it, because it is the contract a renderer is written against: the + * packaged renderer and an owned one read the same record. + */ +export interface RequirementTestArgs { + readonly view: RequirementView; + /** The fan-out key this stub stands for: `.` under the default grain, + * the reference as authored under `grain: "member"`, `*` when the requirement + * resolves no target. Always equal to `unit`, which is the name the other language + * ports use for it. */ + readonly concern: string; + readonly statement: string; + readonly counterexample: string; + /** The claims this stub covers. Each carries its own `concern`, which stays the + * target's `.` in both grains. */ + readonly targets: readonly ResolvedClaim[]; + readonly disposition?: string | undefined; + readonly trackedBy?: readonly string[] | undefined; + // The test's identity record (`RequirementTestIdentity`) — the same six values in + // every language port. They are DATA for an application's renderer: the default + // renderer below reads none of them, so its output does not move when they do. + readonly package: string; + readonly unit: string; + readonly id: string; + readonly witnessKey: string; + readonly skip: "planned" | "retired" | null; + readonly digest: string; +} + +/** Turns one stub's record into the text of its test file. */ +export type RequirementTestRenderer = (a: RequirementTestArgs) => string; + /** One generated test. The same record in every language port. */ export interface RequirementTestIdentity { /** The requirement's effective package. */ diff --git a/server/typescript/packages/codegen-ts/src/templates/requirement-test.ts b/server/typescript/packages/codegen-ts/src/templates/requirement-test.ts index dd55d76c2..ccccbed92 100644 --- a/server/typescript/packages/codegen-ts/src/templates/requirement-test.ts +++ b/server/typescript/packages/codegen-ts/src/templates/requirement-test.ts @@ -16,32 +16,11 @@ import { REQUIREMENT_STATUS_RETIRED, } from "@metaobjectsdev/metadata"; import { GENERATED_HEADER } from "../constants.js"; -import type { RequirementView, ResolvedClaim } from "../requirement-walk.js"; +import type { RequirementTestArgs, ResolvedClaim } from "../requirement-walk.js"; -export interface RequirementTestArgs { - readonly view: RequirementView; - /** The fan-out key this stub stands for: `.` under the default grain, - * the reference as authored under `grain: "member"`, `*` when the requirement - * resolves no target. Always equal to `unit`, which is the name the other language - * ports use for it. */ - readonly concern: string; - readonly statement: string; - readonly counterexample: string; - /** The claims this stub covers. Each carries its own `concern`, which stays the - * target's `.` in both grains. */ - readonly targets: readonly ResolvedClaim[]; - readonly disposition?: string | undefined; - readonly trackedBy?: readonly string[] | undefined; - // The test's identity record (`RequirementTestIdentity`) — the same six values in - // every language port. They are DATA for an application's renderer: the default - // renderer below reads none of them, so its output does not move when they do. - readonly package: string; - readonly unit: string; - readonly id: string; - readonly witnessKey: string; - readonly skip: "planned" | "retired" | null; - readonly digest: string; -} +// Declared with the walk: it is the contract between the walk and ANY renderer, so an +// owned generator reads it from there and not from this module. +export type { RequirementTestArgs }; /** * Statuses whose stub is SKIPPED rather than failing. diff --git a/server/typescript/packages/codegen-ts/test/generator-registry.test.ts b/server/typescript/packages/codegen-ts/test/generator-registry.test.ts index cf12c582b..4b0ddf5f7 100644 --- a/server/typescript/packages/codegen-ts/test/generator-registry.test.ts +++ b/server/typescript/packages/codegen-ts/test/generator-registry.test.ts @@ -39,7 +39,7 @@ const EXPECTED_NATIVE = [ // Neutral / `meta docs`-owned (Tier-2). Present in the registry for identity + // discoverability, but flagged so they are NOT part of the recommended native // `meta gen` surface. -const EXPECTED_NEUTRAL = ["docs"] as const; +const EXPECTED_NEUTRAL = ["docs", "requirements", "agent-docs"] as const; describe("generator-registry (ADR-0021 D3)", () => { test("contains every expected stable name", () => { @@ -88,12 +88,15 @@ describe("generator-registry (ADR-0021 D3)", () => { }); // The other direction. The standalone `mermaid-er` generator was the one docs-layer - // entry `meta gen` still ran; 1.1 removed it, so every docs-layer entry is one - // `meta docs` runs. - test("no docs-layer entry is left that `meta gen` runs", () => { + // entry `meta gen` still ran by design; 1.1 removed it. + test("the docs-layer entries `meta gen` does not skip are the two `meta docs` engines", () => { const run = Object.values(generatorRegistry) .filter((e) => e.layer === "docs" && !isPackagedDocsGenerator(e.factory().name)); - expect(run.map((e) => e.name)).toEqual([]); + // `requirements` and `agent-docs` are the engines behind `meta docs` and + // `meta docs --agent`. They were in no registry until the eject census found them + // (ADR-0034 Amendment 8), and `meta gen` does not skip a wired factory of either, as + // it did not before they had rows. Pinned, so a third cannot join unnoticed. + expect(run.map((e) => e.name).sort()).toEqual(["agent-docs", "requirements"]); }); // Two definitions have to agree: the runner skips by implementation name diff --git a/spec/decisions/ADR-0034-codegen-scaffold-and-own.md b/spec/decisions/ADR-0034-codegen-scaffold-and-own.md index 4cb1c7511..fe8b74f20 100644 --- a/spec/decisions/ADR-0034-codegen-scaffold-and-own.md +++ b/spec/decisions/ADR-0034-codegen-scaffold-and-own.md @@ -48,7 +48,8 @@ component library — code you copy into your project and own." framework: read the project → pick/compose → copy → wire → customize). The actor — a human or Claude — does the copy + config wiring with plain file operations. A copied generator imports the *primitives* from the package but **owns the emit - logic**. Tooling stays minimal: `meta init` scaffolds a sensible default generator + logic** (this was the intent, not yet what every generator shipped: see Amendment 8). + Tooling stays minimal: `meta init` scaffolds a sensible default generator set + local-import config for a running start; it does **not** interview the project or select templates. **Adding a new starting point is adding a documented file, never a CLI change.** @@ -226,7 +227,9 @@ reference is scaffolding; this amendment stops describing it as anything else. framework-specific traps. They stay in the package and keep their tests and the api-contract lanes, but they are not a promise. An adopter who ejects the route generator owns the call into them and can mount routes by hand instead. -- **Ejecting a helper hands over ALL of its code (follow-through, 2026-09-26).** "An adopter +- **Ejecting a helper hands over ALL of its code (follow-through, 2026-09-26; true of the + runtime helpers and the `routes` composition described here, and made the rule for every + generator's emit by Amendment 8).** "An adopter who ejects the route generator owns the call into them" was half of owning it: the output still imported the adapter from the package, so an adapter defect still waited on a release. `meta eject routes` / `routes-hono` / `entity` now also copy the adapter @@ -640,3 +643,302 @@ emit logic of its own, so a copy would own nothing. TypeScript, Java, C# and Pyt the same way, and `meta gen --list` marks it `package-only`. This is the one entry the rule "every stock generator can be ejected" does not reach, and it is not a gap to close. The rest of the audit's list is outside TypeScript and is not covered by this amendment. + +### Amendment 8 (2026-10-11) — the eject contract: every generator hands over its emit, and a test in every port holds it to that + +**Ruling (maintainer).** Asked whether every stock generator should be ejectable: *"Yes, +everything should be ejectable."* An audit of all five ports followed. It found that three +separate pieces of work had each made a named list ejectable, that the rule had become +stricter each time, and that the stricter rule was never applied back to the whole catalog. +The newest generator had been built as a thin copy two weeks after "ejecting a helper hands +over all of its code" was written, with every lane green. No test stated the rule, so nothing +failed when a generator broke it. The maintainer accepted the audit's six recommendations: + +1. **The owned copy holds the emit, in TypeScript too.** A TypeScript reference template that + calls a packaged `render*` composer hands over which objects get a file and where it + lands, and nothing that writes the file. That is not ownership. The TypeScript references + are made whole by copying the template modules a reference calls into the project with + it, the way `meta eject` already copies the HTTP adapter, and not by inlining them by + hand. +2. **Naming and type-mapping tables are engine.** They stay packaged and public. Everything + that returns emitted text is handed over. +3. **The docs command's emitter is in scope in C#, Python, Java and Kotlin**, as the + TypeScript docs tier already is. The docs-site builders are exempt: `meta docs + --scaffold-site` already copies the site's templates and assets, and those are what an + adopter changes. +4. **The Java generators that no registry listed are registered**, and the four that are not + deprecated get eject source. The deprecated Mustache generator is registered and exempt. +5. **`template` is exempt in every port that ships it.** It is the Mustache primitive: the + template and the walk the adopter supplies are its emit logic. +6. **The prompt-tier emitters are handed over in every port**, although a shared corpus + pins their output byte for byte. `shared-model` got the same answer for the same + objection. + +**Supersedes.** + +- **FR-040 section 4.2**, "Not chosen: shipping fat reference templates that inline the + renderer". The choice it rejected is the rule now, reached by copying the template + modules and not by inlining. +- **The eject-in-every-port design's rule** "A copy imports only public API. Where a + reference generator uses an internal helper, the helper is made public in the same + change." A helper that writes output is copied with the generator. Making it public + leaves the adopter calling code they cannot edit. +- **Decision 2's clause** "A copied generator imports the primitives from the package but + owns the emit logic", as a description of what shipped. It described the intent. Until + this amendment's pending list is empty it does not describe every generator. +- **Amendment 3's "Ejecting a helper hands over ALL of its code"**, as a description of what + shipped. It was true of the runtime helpers a generator's output imports and of the + TypeScript `routes` composition. It was not yet true of the emit behind `entity`, + `queries`, `names`, the prompt tier or the UI tier. + +**The standard.** For every stock generator, in every port: + +- **It is in the registry.** A generator that is not registered is outside the catalog, the + eject command and every test that reads them. +- **It ships eject source**, unless the shared manifest exempts it with a written reason + and the ruling that grants it. +- **Its owned copy holds its emit.** Ejected alone into an empty project, the copy compiles + or loads, calls nothing that writes output from the package, and emits what the packaged + generator emits. + +**The engine surface.** An owned copy still imports the package. What it may import is +written down per port, in the shared manifest +(`fixtures/generator-registry-conformance/registry.json`, `eject.engineSurface`): + +- `codegenPackages` names the namespaces that hold generators and their emit code. +- `engine` lists the members of those namespaces an owned copy may still import: the + generator base and runner, the file writer and formatter, questions about the model (which + objects are served, the TPH plan, which templates respond), and the naming and + type-mapping tables of ruling 2. +- Everything else inside `codegenPackages` writes output and is handed over with the + generator. +- Everything outside `codegenPackages` is core by Amendment 3 (the loader and metamodel, + the runtime, render and the reply parser) and is imported freely. + +Adding a module to `engine` is a reviewed decision in one file. It cannot happen by leaving +a name off a list. + +**Engine-surface names that were a judgment call.** Most of each `engine` list is a base +class, a runner, a writer or a naming table, and needs no argument. These names sit closer +to the line. Each answers a question about the model and returns no emitted text, which is +the rule of ruling 2, but each was classified by that rule and not ruled on by name. They +are listed so a maintainer can review them in one place. A name on an `engine` list is not +checked further by the guard. + +| Port | Names | +|---|---| +| 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` | +| Python | `value_objects`, `find_inbound`, `tph_plan`, `requirement_walk` | + +**Exemptions.** Each is an entry in the shared manifest with its reason. + +| What | Port | Why | +|---|---|---| +| `template` | TypeScript, C#, Java, Python | The Mustache primitive. The adopter's template and walk are its emit logic (ruling 5). | +| `mustache` | Java | Deprecated in favour of `template` (ADR-0021 D4), kept so existing poms build (ruling 4). | +| The docs-site builders | TypeScript | Not a generator. The site's templates and assets are already copied into the project (ruling 3). | + +Angular's generators are outside the census because the package is not published +(ADR-0048). The census reads the publish set, so they enter it on promotion. + +**The guard.** Every port has an eject-contract test, driven by the shared manifest. Each +generator there carries `"eject": "full"` or an exemption. + +1. **Census.** The test scans what the port ships for every generator implementation and + fails on one the registry does not hold: every exported factory whose type returns a + `Generator` in the published TypeScript codegen packages, every concrete `Generator` + class on the Maven plugin's classpath, every `IGenerator` in the shipped C# assemblies, + every class with a `generate` method under `metaobjects.codegen`. It also holds the + registry to the names the manifest gives the port. +2. **Completeness.** Every registered generator ships eject source unless exempt. The other + hand-kept lists are derived from the registry or held equal to it: the TypeScript + reference directories, the JVM reference sources each jar ships, the C# embedded + resources, and the compile-test lists. +3. **Depth.** Each generator is ejected alone and compiled or loaded. The test resolves what + the copy references in the codegen packages and fails on anything outside the engine + surface. The unchanged copy must emit what the packaged generator emits, over shared + models that make every generator emit something. One difference is allowed: an owned + copy's output imports the helper runtime the adopter owns. + + How each port resolves a reference: + + | Port | What is read | What counts | + |---|---|---| + | TypeScript | The compiler's checker, each binding followed through any barrel to the file that declares it | A static import or re-export, type-only ones too; an inline `import("…").Type`; a dynamic `import("…")`, counted as the module it names | + | C# | The Roslyn semantic model of the owned file | Every type the file names | + | Java, Kotlin | The constant pool of the compiled copy | Every class the bytecode refers to | + | Python | The syntax tree of the owned module | Import statements, and `importlib.import_module` / `__import__` on a string literal | + + A type declared in an emit module counts in TypeScript because a copy that imports it is + still tied to that module's shape. + +The tests that asserted a gap were removed with the lists they pinned: a `--list` test that +required named generators to be `package-only`, and a Java test that pinned the set of +generators with no eject source. + +**The pending list, and the release gate.** The standard is ahead of the code. Every place +it does not hold yet is one entry in the manifest's `eject.pending` list: the generator, the +port, the assertion and the gap number below, and for a depth entry the packaged modules the +owned copy still references. The list works in both directions. + +- A gap that is not listed fails the port's test. +- A listed entry that now passes fails it too, so closing a gap means deleting its entry in + the same change. A depth entry is compared module by module, so a copy that stops + importing one of three packaged modules fails until the entry says so. + +**1.1.0 does not ship with a non-empty pending list.** `scripts/eject-release-gate.mjs` +refuses a version with no prerelease suffix while the list holds an entry. A manifest that +cannot be read is refused. A prerelease version (`1.1.0-rc.3`, or `1.1.0rc3` as PyPI spells +it) is allowed, because a release candidate is how the remaining entries get exercised by an +adopter. An empty list is a +proven one: each port's test fails on a gap that is not listed, so nothing can be left out +of the list to pass the gate. + +Every scripted path that publishes or tags a release calls the gate: + +- `scripts/release.mjs` calls it first in its preflight, before anything is built or + published. +- `scripts/finish-release.mjs` calls it as gate 0, before the tag. +- The four publish workflows (`publish-npm.yml`, `publish-python.yml`, `publish-java.yml`, + `publish-csharp.yml`) run it as the step after checkout, with the version that workflow + is about to publish: the committed one, or in `publish-csharp.yml` the `version` input + when one is given. + +A tag push cannot be exercised in the repository's own checks, so a test is what holds the +workflows to the rule. `scripts/eject-release-gate.test.ts` parses each workflow, walks +each job's steps in order to check that the gate step comes before the first publishing +step, and runs the gate step's own shell against a throwaway repository. The same test +fails when a publish workflow is added that it does not list. + +Four publishes are typed by hand, and only the procedure holds them to the rule. They are +the `bun publish` loop that promotes npm (`docs/RELEASING.md` step 3), the `bun publish` of +an isolated one-package patch, the `uv publish` that is the PyPI fallback when its workflow +cannot run, and the local `mvn -Prelease deploy`. `docs/RELEASING.md` has the gate as the +command before each. An operator who skips it is not stopped at the publish. The first +refusal is then `scripts/finish-release.mjs`, which withholds the tag. + +**The gaps.** `gap` in a pending entry is a number from this list. + +| # | Gap | State | +|---|---|---| +| 0 | TypeScript generators with no reference template: `shared-model`, `docs`, `api-docs`. `mermaid-er` is removed, not ejected. | Closed by Amendment 7. The three copies call packaged builders, which is gap 3 | +| 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 | +| 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 | + +**What the guard found on its first run**, beyond the gaps the audit listed: + +- Gap 13. +- The Java PlantUML generator threw on any model whose package has one segment. It had no + registry entry, so no registry-driven test had ever run it. Registering it put it in two + such suites, both failed, and the generator is fixed. +- The registry-driven suites in each port now run the newly registered generators too. +- The TypeScript `requirement-tests` copy imported two types, the renderer's argument record + and the renderer signature, from the packaged generator and its template. They are the + contract a renderer is written against, so they are now declared beside the requirement + walk, which is engine. The package's exports are unchanged. + +**Stated limits of the guard.** Each of these is a place where the guard checks less than +the standard says. They are written down so that a green run is not read as more than it +is. + +*What the reference scan cannot see.* + +- **JVM: a dependency that leaves no class reference in bytecode.** The scan reads the + compiled constant pool. `javac` inlines a `static final` String or primitive constant, and + the Kotlin compiler inlines a `const val` and the body of an `inline` function, so a copy + that reads one of those from an emit class shows no reference to it. The constant pool is + used because it is one reader for both JVM languages and it sees what the code calls + after resolution, with no second parser to keep in step with two compilers. +- **TypeScript and Python: a module name built at run time.** `import(name)` and + `import_module(name)` with a computed name cannot be resolved by reading the file. A + string literal is read in both. + +*What is compiled, and from what.* + +- **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. +- **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. + +*What "emits what the packaged generator emits" allows.* + +- **One difference per port: the helper-runtime import.** Amendment 3 hands the runtime + over, so an owned copy's output imports it from the project and the packaged output + imports it from the package. TypeScript compares the import block as a set for the three + generators that hand the HTTP adapter over. Python compares `routes` as a multiset of + lines. C# rewrites one `using`. Java points the packaged generator at the owned runtime + package. +- **"Emits something" is per generator, across the shared models.** No one model makes + every generator emit: a prompt generator needs a template, a grid generator a layout. + Each generator must emit over at least one model, and is compared over empty output on + the models that do not concern it. +- **TypeScript `shared-model` refuses one of the shared models**, because it re-reads the + sources strictly. The packaged and the owned generator must refuse it with the same + message. That is a comparison of two refusals, not of two outputs. +- **Java compares `entity` in one flavour** (`type=interface`). The flavours are covered by + `EntityExtractorEjectRoundTripTest`. + +*What the census does not reach.* + +- **Angular**, because its package is not published (above). +- **Python modules outside `metaobjects.codegen`.** The scan walks that package, which is + where every generator lives today. +- **`exemptEmitters`.** An entry is checked for a path that exists, a reason and a ruling. + That the code at the path is not a generator is the ruling's claim, and no test checks + it. +- **One hand-kept list is left:** the compile test in the Java `codegen-base` module, which + cannot see the registry from where it sits. + +*What changed in existing tests so the new rows could run.* + +- **The older registry-conformance tests in C#, Java, Kotlin and Python subtract the names + with a pending census entry.** The manifest lists `api-docs` for those ports (gap 6) and + the Java, Kotlin and Python registries do not hold it yet. The pending entry is the one + record of that difference. Deleting the entry removes the subtraction, as it has in C#. +- **Two registry-driven Java suites pass an `outputFilename` argument** to generators that + write one file, so the five newly registered rows run in them. It is a test argument; + no generator changed. +- **The TypeScript test "no docs-layer entry is left that `meta gen` runs" now names two:** + `requirements` and `agent-docs`. `meta gen` runs a wired factory of either. That was + true before they were registered and is visible now. + +**Document corrections.** Each of these claimed more than the code delivered. + +- Decision 2 and Amendment 3 of this ADR, superseded above. +- `docs/features/own-your-codegen.md` said owning a generator means owning the code it + writes and the code that code calls. It now says where that is not yet true and points + here. +- `server/java/README.md` listed the PlantUML and Mustache modules as ejectable codegen. + Neither had eject source. +- The Java registry's documentation pointed at a design passage for "why some registered + generators deliberately carry no eject source". No such passage exists. It now points at + the shared manifest, where each exemption has its reason. +- `fixtures/generator-registry-conformance/README.md` gained the `eject` contract. + +**What does not change.** + +- **The core and helper line of Amendment 3.** A generator is still a reference helper. +- **Amendment 5.** Every stock generator still runs as shipped, by import or by name. + Ejecting is how an adopter changes what one emits. +- **Existing owned copies.** They keep running. A copy made before a gap closes keeps + calling the package where it did; re-ejecting takes the whole emit. +- **Packaged output.** This amendment adds registry rows and tests. No generator emits + different bytes because of it. diff --git a/tsconfig.scripts.json b/tsconfig.scripts.json index aaa6228ac..9f6410b68 100644 --- a/tsconfig.scripts.json +++ b/tsconfig.scripts.json @@ -146,6 +146,7 @@ "scripts/finish-release.mjs", "scripts/site-inject-ci.mjs", "scripts/check-migration-guides-vs-registry.mjs", - "scripts/maven-coordinate.mjs" + "scripts/maven-coordinate.mjs", + "scripts/eject-release-gate.mjs" ] }