From a826ed31d98743b6981147248ca1b2bae4538a4c Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Wed, 14 Oct 2026 18:50:26 -0400 Subject: [PATCH 01/23] fix: close the 1.1.0 skill, migration-guide, CLI and prerelease gaps the rc.3 application-build test found An agent following the shipped 1.1.0 skills and the migration guide got wrong output or broken builds in four places. Each gets its root-cause fix: - Python and C# declare the run's column naming (and Python its template directory) in the generator list: `columnNaming` / `templates` in metaobjects.config.yaml, `ColumnNaming` on a C# GeneratorList. gen and verify --codegen read them, a contradicting flag is refused, and verify names a column-naming mismatch instead of advising a regeneration. - The Java and Kotlin skill pom examples list the whole REST tier, and the Maven plugin refuses a list whose generators' requires are not declared. Every requires closure and both examples are compiled over the fitness corpus in tests; that found the Kotlin extractor's dependency on the parser tier. - Every docs example gives each port its own docs directory, with the reason. - Every removal message and skill names the migration guide by its full public URL. Also: C# --list prints each generator's Generators.cs expression and options; the C# staleness notice ignores build metadata; meta verify --docs prints once; Python verify --templates reads the config; the skills stop asserting metaobjects/ is where metadata lives; prerelease:publish reads "released" from npm and the tags, refuses to skip an unpublished CHANGELOG section, pages the iteration counter and restores on SIGTERM; prerelease-link.sh handles nested per-port projects and the .NET tool manifest. --- CHANGELOG.md | 50 ++- .../skills/metaobjects-authoring/SKILL.md | 6 +- .../references/requirements.md | 2 +- .../skills/metaobjects-codegen/SKILL.md | 5 +- .../metaobjects-codegen/references/csharp.md | 74 ++-- .../metaobjects-codegen/references/java.md | 40 +- .../metaobjects-codegen/references/kotlin.md | 50 ++- .../metaobjects-codegen/references/python.md | 25 +- .../references/typescript-docs.md | 6 +- .../references/typescript.md | 5 + agent-context/templates/always-on.md.mustache | 5 +- docs/features/cli.md | 4 +- .../migrations/docs-are-generators.md | 66 ++- docs/features/own-your-codegen.md | 4 +- docs/features/prerelease.md | 21 +- docs/ports/csharp.md | 10 +- docs/ports/python.md | 25 +- .../skills/metaobjects-authoring/SKILL.md | 6 +- .../skills/metaobjects-codegen/SKILL.md | 5 +- .../metaobjects-codegen/references/java.md | 40 +- .../metaobjects-codegen/references/kotlin.md | 50 ++- .../expected/.metaobjects/AGENTS.md | 5 +- .../expected/.metaobjects/CLAUDE.md | 5 +- .../skills/metaobjects-authoring/SKILL.md | 6 +- .../skills/metaobjects-codegen/SKILL.md | 5 +- .../metaobjects-codegen/references/java.md | 40 +- .../expected/.metaobjects/AGENTS.md | 5 +- .../expected/.metaobjects/CLAUDE.md | 5 +- .../skills/metaobjects-authoring/SKILL.md | 6 +- .../skills/metaobjects-codegen/SKILL.md | 5 +- .../metaobjects-codegen/references/python.md | 25 +- .../python/expected/.metaobjects/AGENTS.md | 5 +- .../python/expected/.metaobjects/CLAUDE.md | 5 +- .../skills/metaobjects-authoring/SKILL.md | 6 +- .../skills/metaobjects-codegen/SKILL.md | 5 +- .../references/typescript-docs.md | 6 +- .../references/typescript.md | 5 + .../expected/.metaobjects/AGENTS.md | 5 +- .../expected/.metaobjects/CLAUDE.md | 5 +- .../skills/metaobjects-authoring/SKILL.md | 6 +- .../references/requirements.md | 2 +- .../skills/metaobjects-codegen/SKILL.md | 5 +- .../references/typescript-docs.md | 6 +- .../references/typescript.md | 5 + .../expected/.metaobjects/AGENTS.md | 5 +- .../expected/.metaobjects/CLAUDE.md | 5 +- scripts/ci-local.sh | 2 +- scripts/prerelease-version.mjs | 105 +++++ scripts/prerelease.mjs | 88 ++-- scripts/test-prerelease-version.mjs | 117 ++++++ .../AgentContextStalenessTests.cs | 10 + .../ColumnNamingFlagTests.cs | 83 ++++ .../EjectCommandTests.cs | 13 + .../EjectEndToEndTests.cs | 4 +- .../OneGeneratorListTests.cs | 15 +- server/csharp/MetaObjects.Cli/GenCommand.cs | 7 +- server/csharp/MetaObjects.Cli/InitCommand.cs | 20 +- .../GeneratorListEntryTests.cs | 77 ++++ .../csharp/MetaObjects.Codegen/CodegenCli.cs | 111 ++++- .../MetaObjects.Codegen/GeneratorList.cs | 50 +++ .../MetaObjects.Codegen/GeneratorRegistry.cs | 30 +- .../AgentContext/AgentContextScaffold.cs | 18 +- .../generator/kotlin/GeneratorRegistry.kt | 16 + .../generator/GeneratorRegistry.java | 24 ++ server/java/integration-tests-kotlin/pom.xml | 8 + .../codegen/GeneratorRequiresCompileTest.kt | 131 ++++++ .../java/com/metaobjects/mojo/DocsMojo.java | 2 +- .../mojo/MetaDataGeneratorMojo.java | 32 ++ .../com/metaobjects/mojo/RequiresCheck.java | 100 +++++ .../mojo/GeneratorRequiresCompileTest.java | 264 ++++++++++++ .../metaobjects/mojo/RemovedDocsGoalTest.java | 2 +- server/python/src/metaobjects/cli.py | 383 ++++++++++++------ .../codegen/metaobjects-config.schema.json | 9 + .../src/metaobjects/codegen/project_config.py | 30 ++ .../tests/codegen/test_config_run_settings.py | 199 +++++++++ .../tests/codegen/test_one_generator_list.py | 8 +- .../tests/codegen/test_project_config.py | 6 +- .../packages/cli/src/commands/verify.ts | 5 +- .../packages/cli/src/lib/docs-removed.ts | 2 +- .../cli/test/gen-docs-generators.test.ts | 22 +- .../ADR-0034-codegen-scaffold-and-own.md | 19 +- tools/prerelease/prerelease-link.sh | 172 +++++--- tools/prerelease/test-prerelease-link.sh | 85 ++++ 83 files changed, 2495 insertions(+), 456 deletions(-) create mode 100644 scripts/prerelease-version.mjs create mode 100644 scripts/test-prerelease-version.mjs create mode 100644 server/csharp/MetaObjects.Codegen.Tests/GeneratorListEntryTests.cs create mode 100644 server/csharp/MetaObjects.Codegen/GeneratorList.cs create mode 100644 server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/codegen/GeneratorRequiresCompileTest.kt create mode 100644 server/java/maven-plugin/src/main/java/com/metaobjects/mojo/RequiresCheck.java create mode 100644 server/java/maven-plugin/src/test/java/com/metaobjects/mojo/GeneratorRequiresCompileTest.java create mode 100644 server/python/tests/codegen/test_config_run_settings.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 45cb6b224..3d528e433 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,54 @@ here.** ## [Unreleased] +### Added + +- **Python: `columnNaming` and `templates` in `metaobjects.config.yaml`.** The column naming + and the template directory were flags only, so replacing a script's + `metaobjects gen --out ... --column-naming snake_case` with a plain `metaobjects gen`, + as the 1.1.0 migration guide said to, rewrote every `*_names.py` with camelCase column + names the database does not have. `gen`, `verify --codegen` and `verify --templates` read + both keys; a `--column-naming` or `--templates` that contradicts the config is refused. +- **C#: `ColumnNaming` on the generator list.** `codegen/Generators.cs` may declare + `All { get; } = new GeneratorList { ColumnNaming = ColumnNamingStrategy.SnakeCase, Entries = [ ... ] };`. + `dotnet meta gen` and `verify --codegen` read it; a contradicting `--column-naming` is + refused. A plain collection keeps working. `dotnet meta init` scaffolds the new form. +- **Maven: `requires` is checked.** `mvn metaobjects:generate` fails, naming the missing + companion and its class, when a listed generator's output references what an unlisted one + writes (a Spring controller without the DTO, repository and filter allowlist; a Kotlin + controller without the Exposed tables, filter allowlists and relation queries), where it + used to exit 0 and leave the build to fail in `javac` or `kotlinc`. + `true` runs such a list when the files are + supplied another way. Every Java and Kotlin `requires` closure, and the skills' pom + examples, are compiled over the fitness corpus in a test. + ### Fixed +- `verify --codegen` (Python, C#) says when the committed output was generated with another + column naming, and how to declare it, instead of advising a regeneration that would commit + the wrong column names. +- Every "removed in 1.1.0" message and skill reference names the migration guide by its full + public URL; the repository path they printed exists in no installed package. +- The Java and Kotlin skills' only `pom.xml` examples generated code that did not compile; + each now lists the whole REST tier, and the Kotlin skill lists the runtime dependencies the + generated code needs and that the controller needs `KotlinRelationsGenerator`. +- The migration guide and the skills give each port's documentation its own directory: in a + shared one, every port's `verify --codegen` reported the other ports' pages as drift. +- `dotnet meta gen --list` prints the expression that lists each generator in + `codegen/Generators.cs` (`new MetaObjects.Codegen.Generators.RenderHelperGenerator("prompts")`) + and its options. +- The C# "agent context is stale" notice no longer fires on build metadata alone + (`1.1.0-rc.3` against `1.1.0-rc.3+`). +- `meta verify --docs` prints its removal line once, as plain text. +- `metaobjects verify --templates` reads `metadata` and `templates` from + `metaobjects.config.yaml` instead of refusing the config. +- The skills no longer say metadata lives under `metaobjects/` (it is the default only), and + name C#'s positional `` (there is no `--metadata-dir`). +- Pre-release tooling: `prerelease:publish` reads what is released from public npm and the + release tags, not `package.json`, so a cut during release preparation is not numbered one + minor too high, and it refuses a `--base` that skips an unpublished CHANGELOG section; the + iteration counter reads every page of the registry listing; a SIGTERM restores the version + files. `prerelease-link.sh` links and repins every Maven and Python project at any depth and + repins the .NET local tool manifest. - A generated documentation page that no current object produces (for example the `.md` page of an object that was removed) is reported as drift. 1.0's @@ -1187,7 +1234,8 @@ until you regenerate. ADR-0034 Amendment 9. - `meta docs` prints `` `meta docs` was removed in 1.1.0. `meta gen` writes documentation now: declare each docs generator in `generators` in metaobjects.config.ts. See - docs/features/migrations/docs-are-generators.md, section "TypeScript". `` and exits 2. + https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/docs-are-generators.md, + section "TypeScript". `` and exits 2. `meta verify --docs` prints the same shape, naming `meta verify --codegen`, and exits 2. - A config with a `docs` key fails `meta gen` and `meta verify --codegen` before anything is written, naming the block and the migration section. A project with no diff --git a/agent-context/skills/metaobjects-authoring/SKILL.md b/agent-context/skills/metaobjects-authoring/SKILL.md index 3b164338e..14c60029b 100644 --- a/agent-context/skills/metaobjects-authoring/SKILL.md +++ b/agent-context/skills/metaobjects-authoring/SKILL.md @@ -10,7 +10,9 @@ that drive code generation, runtime behavior, and drift detection. You author it the loader reads it, the codegen emits idiomatic per-language code from it. This skill is the procedure for writing it correctly. -Metadata lives in files under `metaobjects/` at project root, one file per domain +Metadata lives in files under the project's metadata directory (`metaobjects/` unless +the project's configuration names another: `sources` in `.metaobjects/config.json`, or the +port's own setting), one file per domain concept (`meta.commerce.json`, `meta.users.yaml`, …). Each file declares a `package` on its root node. Files in the same `package` with the same object `name` are merged by the loader. @@ -450,7 +452,7 @@ immutable. Supported in all five ports; the worked example is in `references/inh ## Requirements — capability ledger (opt-in) -**This capability exists whether or not the project uses it yet.** `requirement.functional` and `requirement.architectural` are registered metadata types, declared in `metaobjects/` beside the entities they describe and loaded by the same loader — no side file, no bespoke parser. They record *why* each part of the model exists, so a field with no reason to exist becomes visible as one. +**This capability exists whether or not the project uses it yet.** `requirement.functional` and `requirement.architectural` are registered metadata types, declared in the metadata directory beside the entities they describe and loaded by the same loader — no side file, no bespoke parser. They record *why* each part of the model exists, so a field with no reason to exist becomes visible as one. Reach for it when the project needs to answer any of: diff --git a/agent-context/skills/metaobjects-authoring/references/requirements.md b/agent-context/skills/metaobjects-authoring/references/requirements.md index 9007a9c5e..e35490d5a 100644 --- a/agent-context/skills/metaobjects-authoring/references/requirements.md +++ b/agent-context/skills/metaobjects-authoring/references/requirements.md @@ -1,6 +1,6 @@ # Requirements — `requirement.functional` / `requirement.architectural` -Capabilities are **metadata**, declared in `metaobjects/` beside the entities they +Capabilities are **metadata**, declared in the metadata directory beside the entities they describe. Read the existing requirement nodes before designing anything. Two rules matter more than the rest. diff --git a/agent-context/skills/metaobjects-codegen/SKILL.md b/agent-context/skills/metaobjects-codegen/SKILL.md index 0a93a084f..fa28663b9 100644 --- a/agent-context/skills/metaobjects-codegen/SKILL.md +++ b/agent-context/skills/metaobjects-codegen/SKILL.md @@ -135,7 +135,10 @@ systems — and have it import the generated types. You run a `gen` step. The runner: -1. Loads all metadata under `metaobjects/` (the same loader the runtime uses). +1. Loads the project's metadata from wherever its configuration points (the same loader + the runtime uses): `sources` in `.metaobjects/config.json` (TypeScript, and C# when gen + names no ``), `metadata` in `metaobjects.config.yaml` (Python), the + plugin's `` (Java, Kotlin). `metaobjects/` is only the default. 2. Resolves output targets and precomputes shared render state. 3. Runs each configured **generator** — most emit one file per entity; some emit a single shared file (a barrel, a DB-context, an app-config). diff --git a/agent-context/skills/metaobjects-codegen/references/csharp.md b/agent-context/skills/metaobjects-codegen/references/csharp.md index 601539ff7..8a0d6a810 100644 --- a/agent-context/skills/metaobjects-codegen/references/csharp.md +++ b/agent-context/skills/metaobjects-codegen/references/csharp.md @@ -41,16 +41,30 @@ using MetaObjects.Codegen; public static class Generators { - public static IReadOnlyList All { get; } = - [ - new MetaObjects.Codegen.Generators.EntityGenerator(), - new MetaObjects.Codegen.Generators.DbContextGenerator(), - new MetaObjects.Codegen.Generators.FilterAllowlistGenerator(), - new MetaObjects.Codegen.Generators.RoutesGenerator(), - ]; + public static IReadOnlyList All { get; } = new GeneratorList + { + // The naming your database's columns use: Literal (the default), SnakeCase or KebabCase. + ColumnNaming = ColumnNamingStrategy.SnakeCase, + Entries = + [ + new MetaObjects.Codegen.Generators.EntityGenerator(), + new MetaObjects.Codegen.Generators.DbContextGenerator(), + new MetaObjects.Codegen.Generators.FilterAllowlistGenerator(), + new MetaObjects.Codegen.Generators.RoutesGenerator(), + ], + }; } ``` +**Declare `ColumnNaming` on the list, never only on a command line.** It decides every +column name the entity mapping and the `names` constants use, for every entry. `gen` and +`verify --codegen` both read it from the list, so a plain `dotnet meta gen` cannot fall +back to `Literal` and rebind the entities to camelCase columns the database does not have. +A `--column-naming` that contradicts the list is refused, and `verify --codegen` names a +naming mismatch as one rather than telling you to regenerate. A plain collection +(`All { get; } = [ ... ];`) still works: it declares no naming, so `--column-naming`, else +`Literal`, applies. + ```bash dotnet meta gen metaobjects --out Generated --namespace Acme.Generated # runs the list dotnet meta gen --list # list registered generators @@ -63,6 +77,15 @@ no `codegen/Generators.cs` they exit 2 and write nothing. `--generators` narrows declared entries named and never adds one: naming an entry the list does not declare is refused. `verify --codegen` regenerates the same list, so it never disagrees with `gen`. +**Where `codegen/` goes, and sharing it with a TypeScript port.** `gen` looks for +`codegen/` in the project root: the parent of the `` it is given, or the +directory it runs in when given none. Run `dotnet meta init` there (or pass that directory as +`--root `); a `codegen/` anywhere else is never run. In a repository that also has a +TypeScript port, that is the same `codegen/` the TypeScript side keeps its owned generators +in, and C# ejects land in its `codegen/generators/` beside the `.ts` copies. They coexist: +`Codegen.csproj` compiles only `*.cs` and `tsconfig.codegen.json` only `*.ts`. Ignore the +build output (`codegen/bin/`, `codegen/obj/`) in `.gitignore`. + `dotnet meta verify` defaults to `--templates` (the FR-004 prompt/template drift gate, see the prompts reference); `--codegen` is the codegen-output drift gate. **Schema migration + live-DB drift are NOT `dotnet meta`** — they run through the @@ -78,23 +101,26 @@ is a two-line entry point that passes the list to `CodegenCli.Run`. `dotnet meta set and no separate docs command. `--generators ` narrows a run to entries the list already declares, by stable name; it never adds one, and naming an entry the list does not declare is refused. `dotnet meta gen --list` is the catalog of what the port -registers, not what this project runs. Output lands under `--namespace` in +registers, not what this project runs; under each name it prints the expression that lists +that generator in `codegen/Generators.cs` and its options. Output lands under `--namespace` in `--out` unless a generator's own init properties say otherwise — documentation does (below). -| Stable name | Output | -|---|---| -| `entity` | `.g.cs` — an EF Core entity class per `object.entity` / projection: PascalCase props mapped via `[Column]`, `[Table]`, `[Key]` (or class-level `[PrimaryKey]` for composites), `[MaxLength]`/validators, nullability from `@required`. Enum fields → a nested (or shared) C# `enum`; object fields → owned-type navigations; every concrete value object and sourceless projection → a POCO, which is also the template tier's payload/response type (ADR-0056; package-qualified, e.g. `AcmeAlphaNote`, when another object shares its short name). A TPH `@discriminator` base is emitted `abstract` with `: Base` subtypes (single-table). | -| `db-context` | one `AppDbContext` — a `DbSet` per entity + `OnModelCreating`: `.HasConversion()` (enums), `.OwnsOne(...)`/`.ToJson(...)` (owned/jsonb object fields), `.HasPrecision(p,s)` (decimals), `.UsingEntity<>(...)` (M:N), `.HasDiscriminator(e => e.Type).HasValue(...)` (TPH). | -| `routes` | `Routes.cs` — ASP.NET **Minimal API** CRUD per writable entity (`source.rdb @kind="table"`) on the cross-port REST contract (`?filter[field][op]=`, `?sort=field:asc`, `?limit`/`?offset`, `?withCount=1` envelope, 400/404 envelopes). A TPH base emits polymorphic `GET /(+/{id})` + a per-subtype CRUD set at `//` (create injects the discriminator, cross-subtype get/update/delete → 404). | -| `filter-allowlist` | per-entity `FilterAllowlist` (FR-009 — the server-side field+operator allowlist the routes validate against). | -| `callable` | `.callable.g.cs` — an FR-015 calling method for a `source.rdb @kind="storedProc"|"tableFunction"`, via EF `FromSqlInterpolated` (args from the `@parameterRef` value object in declaration order). | -| `output-parser` / `extractor` / `output-prompt` / `render-helper` | the prompt-pillar artifacts for a **responding `template.prompt`** — one carrying `@responseRef` (ADR-0052: these tiers are INBOUND; `template.output` is outbound only and emits no parser). The strict parser, the tolerant `extract`, the **output-format prompt fragment** (`output-prompt`; presentation via `@promptStyle: guide`/`inline`/`exampleOnly`), and the typed render helper. They reference the value objects' POCOs from `entity` and declare no payload type — wire `entity` too (`--list` says so). See the **prompts** reference. | -| `names` | `Names.g.cs` — `public abstract class Names` of `const` physical database names, MIRRORING THE METADATA TREE: the object's own `Type`/`SubType`/`Name` (`Name` is the OBJECT's name, never a physical one), then per `source.rdb` keyed by `@role` — `SourcePrimaryType`/`SubType`/`Kind`, `SourcePrimarySchema` when declared, and the physical name under the alias for its `@kind` (`SourcePrimaryTable` / `SourceReplicaView` / `SourcePrimaryProc` / `…Function` / `…MaterializedView`) — then a `Field`/`Column` pair per field, then each identity and index (`IdentityPkName`, and `IdentityUqCustEmailIndex` / `IndexIxCustStatusIndex` — the DATABASE name, carried for `identity.secondary` and `index.lookup` only), then a complete `ColumnsByField`. There is no `ReadOnly`: it is a derivation over `@kind`, so ask `SourcePrimaryKind`. Emitted for every object with a declared or inherited primary source, PLUS a fragment for any abstract base such an object extends (its own identity + columns, and NO source members — it has no table). A class whose object extends another one **inherits** it (`class CopayAuthNames : AuthNames`) rather than restating its constants — a C# `const` is inherited, so `CopayAuthNames.IdColumn` and `CopayAuthNames.SourcePrimaryTable` resolve through the base (`Type`/`SubType`/`Name` are redeclared with `new`, since every artifact carries its own). `abstract`, not `static`, precisely so it can be inherited; it still cannot be instantiated. In the run, `entity` and `db-context` reference the constants (`[Table(OrderNames.SourcePrimaryTable)]`, `[Column(OrderNames.StatusColumn)]`, `.ToView(OrderNames.SourceReplicaView)`); a run without `names` falls back to literals. | -| `template` | the generic Mustache `templateGenerator()` primitive. | - -Metadata lives under `metaobjects/` (or wherever you point `--metadata-dir`) in the -same canonical JSON every port reads — fused-key form, `source.rdb` + `@table`, +| Stable name | Entry in `codegen/Generators.cs` (`new MetaObjects.Codegen.Generators.…`) | Output | +|---|---|---| +| `entity` | `EntityGenerator()` | `.g.cs` — an EF Core entity class per `object.entity` / projection: PascalCase props mapped via `[Column]`, `[Table]`, `[Key]` (or class-level `[PrimaryKey]` for composites), `[MaxLength]`/validators, nullability from `@required`. Enum fields → a nested (or shared) C# `enum`; object fields → owned-type navigations; every concrete value object and sourceless projection → a POCO, which is also the template tier's payload/response type (ADR-0056; package-qualified, e.g. `AcmeAlphaNote`, when another object shares its short name). A TPH `@discriminator` base is emitted `abstract` with `: Base` subtypes (single-table). | +| `db-context` | `DbContextGenerator()` | one `AppDbContext` — a `DbSet` per entity + `OnModelCreating`: `.HasConversion()` (enums), `.OwnsOne(...)`/`.ToJson(...)` (owned/jsonb object fields), `.HasPrecision(p,s)` (decimals), `.UsingEntity<>(...)` (M:N), `.HasDiscriminator(e => e.Type).HasValue(...)` (TPH). | +| `routes` | `RoutesGenerator()` | `Routes.cs` — ASP.NET **Minimal API** CRUD per writable entity (`source.rdb @kind="table"`) on the cross-port REST contract (`?filter[field][op]=`, `?sort=field:asc`, `?limit`/`?offset`, `?withCount=1` envelope, 400/404 envelopes). A TPH base emits polymorphic `GET /(+/{id})` + a per-subtype CRUD set at `//` (create injects the discriminator, cross-subtype get/update/delete → 404). | +| `filter-allowlist` | `FilterAllowlistGenerator()` | per-entity `FilterAllowlist` (FR-009 — the server-side field+operator allowlist the routes validate against). | +| `callable` | `CallableGenerator()` | `.callable.g.cs` — an FR-015 calling method for a `source.rdb @kind="storedProc"|"tableFunction"`, via EF `FromSqlInterpolated` (args from the `@parameterRef` value object in declaration order). | +| `output-parser` / `extractor` / `output-prompt` / `render-helper` | `OutputParserGenerator()`, `ExtractorGenerator()`, `OutputPromptGenerator()`, `RenderHelperGenerator("prompts")` (its constructor argument is the template root its refs resolve from) | the prompt-pillar artifacts for a **responding `template.prompt`** — one carrying `@responseRef` (ADR-0052: these tiers are INBOUND; `template.output` is outbound only and emits no parser). The strict parser, the tolerant `extract`, the **output-format prompt fragment** (`output-prompt`; presentation via `@promptStyle: guide`/`inline`/`exampleOnly`), and the typed render helper. They reference the value objects' POCOs from `entity` and declare no payload type — wire `entity` too (`--list` says so). See the **prompts** reference. | +| `names` | `NamesGenerator()` | `Names.g.cs` — `public abstract class Names` of `const` physical database names, MIRRORING THE METADATA TREE: the object's own `Type`/`SubType`/`Name` (`Name` is the OBJECT's name, never a physical one), then per `source.rdb` keyed by `@role` — `SourcePrimaryType`/`SubType`/`Kind`, `SourcePrimarySchema` when declared, and the physical name under the alias for its `@kind` (`SourcePrimaryTable` / `SourceReplicaView` / `SourcePrimaryProc` / `…Function` / `…MaterializedView`) — then a `Field`/`Column` pair per field, then each identity and index (`IdentityPkName`, and `IdentityUqCustEmailIndex` / `IndexIxCustStatusIndex` — the DATABASE name, carried for `identity.secondary` and `index.lookup` only), then a complete `ColumnsByField`. There is no `ReadOnly`: it is a derivation over `@kind`, so ask `SourcePrimaryKind`. Emitted for every object with a declared or inherited primary source, PLUS a fragment for any abstract base such an object extends (its own identity + columns, and NO source members — it has no table). A class whose object extends another one **inherits** it (`class CopayAuthNames : AuthNames`) rather than restating its constants — a C# `const` is inherited, so `CopayAuthNames.IdColumn` and `CopayAuthNames.SourcePrimaryTable` resolve through the base (`Type`/`SubType`/`Name` are redeclared with `new`, since every artifact carries its own). `abstract`, not `static`, precisely so it can be inherited; it still cannot be instantiated. In the run, `entity` and `db-context` reference the constants (`[Table(OrderNames.SourcePrimaryTable)]`, `[Column(OrderNames.StatusColumn)]`, `.ToView(OrderNames.SourceReplicaView)`); a run without `names` falls back to literals. | +| `template` | `TemplateGenerator.Create(name, template, walk, provider)` (a factory, not `new`) | the generic Mustache `templateGenerator()` primitive. | + +Metadata lives in the directory the gen run's positional `` argument +names (`dotnet meta gen ./model --out …`); with none, `sources` in +`.metaobjects/config.json` decides, and `metaobjects/` is only that file's default. There +is no `--metadata-dir` flag. It is the same canonical JSON every port reads — fused-key form, `source.rdb` + `@table`, `@column` for a renamed physical column. **Projections (read-only views).** An `object.projection` (read-only `source.rdb` @@ -161,7 +187,7 @@ reference is `ApiDocsGenerator`, an ordinary entry in the same list as every oth generator: ```csharp -new ApiDocsGenerator { OutDir = "Docs" } // → Docs/api/csharp (AGENT-API.md + per-entity pages) +new ApiDocsGenerator { OutDir = "docs/csharp" } // → docs/csharp/api/csharp (AGENT-API.md + per-entity pages) ``` `dotnet meta gen` writes it, alongside everything else `codegen/Generators.cs` declares. @@ -169,8 +195,8 @@ new ApiDocsGenerator { OutDir = "Docs" } // → Docs/api/csharp (AGENT-API.md generated code. **Before calling any generated code, read `api/csharp/AGENT-API.md`.** Options (init properties): `OutDir` (give it a directory of generated files only; unset, the pages land under `--out`), `ApiSubDir` (default `api/csharp`), `ProjectName`, `ModelBaseUrl`. To write only the documentation, -`dotnet meta gen --generators api-docs`. Migrating a project that ran `dotnet meta docs`: -`docs/features/migrations/docs-are-generators.md`, section "C#". +`dotnet meta gen --generators api-docs`. In a repository with more than one port, give each port's documentation its own directory: another port's generated pages count as foreign files, and in a shared directory each port's `verify --codegen` reports the other ports' pages as drift. Migrating a project that ran `dotnet meta docs`: +, section "C#". ## Persistence + routes are the deployed artifact diff --git a/agent-context/skills/metaobjects-codegen/references/java.md b/agent-context/skills/metaobjects-codegen/references/java.md index c02465c15..2b5d0a0e0 100644 --- a/agent-context/skills/metaobjects-codegen/references/java.md +++ b/agent-context/skills/metaobjects-codegen/references/java.md @@ -32,7 +32,13 @@ All artifacts share `groupId` `com.metaobjects` and one version property: The plugin's `generate` goal binds to the `generate-sources` phase. Point its `` at your metadata and list one `` per output you -want, by fully-qualified class name. +want, by fully-qualified class name. The example is the whole Spring REST tier: the +controller's output names the DTO, the repository and the filter allowlist, and a DTO +names the value-object records its `field.object` columns carry, so the five move +together. `mvn metaobjects:generate` refuses a list that declares a generator without +the ones its output references (each registry entry's `requires`), naming what to add; +set `true` only when you supply those files by +hand or declare them in another ``. ```xml @@ -52,19 +58,25 @@ want, by fully-qualified class name. - com.metaobjects.generator.spring.SpringControllerGenerator - - ${project.build.directory}/generated-sources/java - + com.metaobjects.generator.spring.SpringDtoGenerator + ${project.build.directory}/generated-sources/java - com.metaobjects.generator.spring.SpringDtoGenerator + com.metaobjects.generator.spring.SpringValueObjectGenerator ${project.build.directory}/generated-sources/java com.metaobjects.generator.spring.SpringRepositoryGenerator ${project.build.directory}/generated-sources/java + + com.metaobjects.generator.spring.SpringFilterAllowlistGenerator + ${project.build.directory}/generated-sources/java + + + com.metaobjects.generator.spring.SpringControllerGenerator + ${project.build.directory}/generated-sources/java + @@ -154,7 +166,12 @@ payload field shapes for the generated code — plus a page per entity, under ``` Optional ``: `apiSubDir` (default `api/java`), `layout` (`flat` or `package`), -`projectName`, `modelBaseUrl`. Give it an `outputDir` of generated files only. +`projectName`, `modelBaseUrl`. Give it an `outputDir` of generated files only. In a repository with more than one port, give each port's documentation its own +directory: another port's generated pages count as foreign files, and in a shared +directory each port's `verify --codegen` reports the other ports' pages as drift. +Migrating a pom that bound `metaobjects:docs`: +, +section "Java and Kotlin". **Before calling any generated code, read `api/java/AGENT-API.md`** — it carries the concrete imports and signatures so you don't have to guess them. @@ -162,8 +179,10 @@ concrete imports and signatures so you don't have to guess them. ## `codegen-spring` generators Most live in `metaobjects-codegen-spring` under -`com.metaobjects.generator.spring.*`; wire any subset, typically all three of the -first group together. (`JavaObjectCodeGenerator`, last row below, lives in the +`com.metaobjects.generator.spring.*`. A subset must be closed over what its output +references: the controller needs the DTO, repository and filter allowlist; the DTO and +the template-tier generators need the value-object records; the repository needs the DTO. +`mvn metaobjects:generate` refuses a list that is not. (`JavaObjectCodeGenerator`, last row below, lives in the separate `metaobjects-codegen-base` module instead.) | Generator | Output | @@ -264,7 +283,8 @@ backed by an `object.value`: `SpringValueObjectGenerator` emits that VO as a Jav and OMDB's Jackson codec serializes it to/from the column (a single VO, or `List` for an array) — the same typed-jsonb round-trip the other ports ship. -Metadata lives under `src/main/metaobjects/` in the same canonical JSON the other +Metadata lives wherever the plugin's `` points (the examples here use +`src/main/metaobjects`; nothing requires that path), in the same canonical JSON the other ports read — fused-key form, `source.rdb` + `@table`, `@column` for a renamed physical column. diff --git a/agent-context/skills/metaobjects-codegen/references/kotlin.md b/agent-context/skills/metaobjects-codegen/references/kotlin.md index 0f470d98a..6ccae0b58 100644 --- a/agent-context/skills/metaobjects-codegen/references/kotlin.md +++ b/agent-context/skills/metaobjects-codegen/references/kotlin.md @@ -29,17 +29,33 @@ All artifacts share `groupId` `com.metaobjects` and one version property: | `metaobjects-codegen-kotlin` | the Kotlin (KotlinPoet) generators | | `metaobjects-maven-plugin` | the build-time codegen plugin | -Generated entities and Exposed tables need the consumer's own runtime deps — -`org.jetbrains.exposed:exposed-core` and -`org.jetbrains.kotlinx:kotlinx-serialization-json` (the latter for -`@Serializable`/parser output). +The generated code compiles against the consumer's own runtime dependencies, which the +plugin does not bring: + +| Dependency | Why | +|---|---| +| `org.jetbrains.exposed:exposed-core` | every generated `Table` | +| `org.jetbrains.exposed:exposed-java-time` | date, time and timestamp columns | +| `org.jetbrains.exposed:exposed-json` | `@storage: jsonb` columns | +| `org.jetbrains.exposed:exposed-jdbc` | the controller's and repository's queries, and `Database.connect` at run time | +| `com.fasterxml.jackson.core:jackson-databind`, `com.fasterxml.jackson.module:jackson-module-kotlin`, `com.fasterxml.jackson.datatype:jackson-datatype-jsr310` | the generated jsonb mapper and the controller's request binding | +| `org.jetbrains.kotlinx:kotlinx-serialization-json` | the parser tier, and `@dbColumnType: jsonb` open bags | +| `org.springframework:spring-web` (for example through `spring-boot-starter-web`) and `jakarta.validation:jakarta.validation-api` | the controller | + +Keep all four Exposed artifacts on one version. The port guide's `pom.xml` lists them +with versions. ## Plugin config in `pom.xml` The plugin's `generate` goal binds to the `generate-sources` phase. Point its `` at your metadata and list one `` per output you want, by fully-qualified class name. The Kotlin generators run through the same -Java plugin via the shared SPI: +Java plugin via the shared SPI. The example is the whole REST tier: the controller's +output names the entity data classes, the Exposed tables, the filter allowlists and the +relation queries `KotlinRelationsGenerator` writes, so the five move together. +`mvn metaobjects:generate` refuses a list that declares a generator without the ones its +output references, naming what to add (`true` +only when you supply those files by hand or declare them in another ``): ```xml @@ -66,6 +82,14 @@ Java plugin via the shared SPI: com.metaobjects.generator.kotlin.KotlinExposedTableGenerator ${project.build.directory}/generated-sources/kotlin + + com.metaobjects.generator.kotlin.KotlinRelationsGenerator + ${project.build.directory}/generated-sources/kotlin + + + com.metaobjects.generator.kotlin.KotlinFilterAllowlistGenerator + ${project.build.directory}/generated-sources/kotlin + com.metaobjects.generator.kotlin.KotlinSpringControllerGenerator ${project.build.directory}/generated-sources/kotlin @@ -152,7 +176,12 @@ payload field shapes for the generated code — plus a page per entity, under ``` Optional ``: `apiSubDir` (default `api/kotlin`), `layout` (`flat` or `package`), -`projectName`, `modelBaseUrl`. Give it an `outputDir` of generated files only. +`projectName`, `modelBaseUrl`. Give it an `outputDir` of generated files only. In a repository with more than one port, give each port's documentation its own +directory: another port's generated pages count as foreign files, and in a shared +directory each port's `verify --codegen` reports the other ports' pages as drift. +Migrating a pom that bound `metaobjects:docs`: +, +section "Java and Kotlin". **Before calling any generated code, read `api/kotlin/AGENT-API.md`.** @@ -165,11 +194,11 @@ All live in `metaobjects-codegen-kotlin` under |---|---| | `KotlinEntityGenerator` | `.kt` — `data class` (no `@Serializable`; Jackson-compatible) per `object.entity` / `object.value` / `object.projection`. A value object's data class is also the template tier's payload/response type (ADR-0056) — wire this generator with any template-tier one. A TPH `@discriminator` base's data class is the **union** of every subtype's columns (each folded nullable, validation dropped) so one wire shape backs the polymorphic + per-subtype endpoints. | | `KotlinExposedTableGenerator` | `Table.kt` — Exposed `Table` object (PK + FK + `@storage` columns) for entities with `source.rdb`. A TPH `@discriminator` base emits ONE `Table` for the whole hierarchy — every subtype-only column folded in `.nullable()` (a row of another subtype stores null there) — single-table inheritance; subtype entities emit no table of their own. | -| `KotlinRelationsGenerator` | `Relations.kt` — extension fns for `@cardinality="many"` query helpers | -| `KotlinSpringControllerGenerator` | `Controller.kt` — Spring `@RestController`, five CRUD endpoints on the cross-port REST contract, for writable entities (`source.rdb` `@kind="table"`). A TPH `@discriminator` base emits ONE controller: polymorphic `GET /(+/{id})` plus a per-subtype CRUD set at `//` — create injects the discriminator from the URL (never the body); get/update/delete are scoped to the subtype (cross-subtype → 404); the discriminator is immutable. | +| `KotlinRelationsGenerator` | `Relations.kt` — extension fns for `@cardinality="many"` query helpers (`Query`). The controller's traversal routes call them, so wire it whenever you wire the controller. | +| `KotlinSpringControllerGenerator` | `Controller.kt` — Spring `@RestController`, five CRUD endpoints on the cross-port REST contract, for writable entities (`source.rdb` `@kind="table"`). Needs `KotlinEntityGenerator`, `KotlinExposedTableGenerator`, `KotlinFilterAllowlistGenerator` and `KotlinRelationsGenerator` in the same list. A TPH `@discriminator` base emits ONE controller: polymorphic `GET /(+/{id})` plus a per-subtype CRUD set at `//` — create injects the discriminator from the URL (never the body); get/update/delete are scoped to the subtype (cross-subtype → 404); the discriminator is immutable. | | `KotlinRepositoryGenerator` | `RepositoryBase.kt` — an `open class` consumer persistence seam per writable entity (the Kotlin peer of Java's `SpringRepositoryGenerator`, but with method bodies). Carries the #203 `@autoSet` CRUD stamping: `onCreate` columns stamped once at insert, `onUpdate` columns re-stamped on every write, both excluded from the caller-supplied set. | | `KotlinOutputParserGenerator` | the strict parser-on-receipt for a **responding `template.prompt`** — one carrying `@responseRef` (ADR-0052: INBOUND; a `template.output` emits no parser). See the prompts reference. | -| `KotlinExtractorGenerator` | the FR-010 strict `extract` for a responding `template.prompt` (the value object's all-nullable `Extracted` mirror → its data class) | +| `KotlinExtractorGenerator` | the FR-010 strict `extract` for a responding `template.prompt` (the value object's all-nullable `Extracted` mirror → its data class). Needs `KotlinOutputParserGenerator` and `KotlinEntityGenerator` in the same list: it calls the parser and its mirror. | | `KotlinOutputPromptGenerator` | the FR-010 output-format prompt fragment for a responding `template.prompt` (presentation via `@promptStyle: guide`/`inline`/`exampleOnly`) | | `KotlinRenderHelperGenerator` | the typed render helper for a `template.prompt` payload | | `KotlinValidatorGenerator` | `MetadataStartupValidator.kt` + `ExposedTableValidator.kt` (once per project) | @@ -242,7 +271,8 @@ JUnit 4 project uses `renderer` or ejects. its default rendering (pass `-Dport`: the name is ejectable on both JVM ports). The requirement checks in `mvn metaobjects:verify` are core and are not ejectable. -Metadata lives under `src/main/metaobjects/` in the same canonical JSON the other +Metadata lives wherever the plugin's `` points (the examples here use +`src/main/metaobjects`; nothing requires that path), in the same canonical JSON the other ports read — fused-key form, `source.rdb` + `@table`, `@column` for a renamed physical column. diff --git a/agent-context/skills/metaobjects-codegen/references/python.md b/agent-context/skills/metaobjects-codegen/references/python.md index d2ff1bab7..5c8a9bdc5 100644 --- a/agent-context/skills/metaobjects-codegen/references/python.md +++ b/agent-context/skills/metaobjects-codegen/references/python.md @@ -37,12 +37,22 @@ generated that the config does not declare. ```yaml # metaobjects.config.yaml metadata: metadata +columnNaming: snake_case # the naming your database's columns use: literal (default) | snake_case | kebab-case +templates: prompts # where template refs resolve from (default: prompts/, else templates/) targets: app: outDir: generated generators: [entity, filter-allowlist, routes] ``` +**Declare `columnNaming` in the config, never only on a command line.** It decides every +column name the `names` constants and the generated queries use. A plain `metaobjects gen` +reads it from the config; a script that passed `--column-naming snake_case` and is replaced +by a plain `gen` would otherwise fall back to `literal` and rewrite every `*_names.py` with +camelCase names the database does not have. A `--column-naming` or `--templates` that +contradicts the config is refused, and `verify --codegen` names a naming mismatch as one +rather than telling you to regenerate. + ```bash metaobjects gen # runs every target's generators into its outDir metaobjects gen --generators entity # narrow to declared entries (never adds one) @@ -51,8 +61,11 @@ metaobjects verify --codegen # regenerate the same list into a With no `metaobjects.config.yaml`, `gen` and `verify --codegen` exit 2 and write nothing. The 1.0 form `metaobjects gen --out --generators ` is not read; -`--target`, `--entities`, `--column-naming`, `--provider`, `--templates` and `--baseline` -still apply to the list. `metaobjects verify` defaults to `--codegen` (it shares the exact +`--target`, `--entities`, `--provider` and `--baseline` still apply to the list; +`--column-naming` and `--templates` only where the config declares no `columnNaming` / +`templates` (a flag that disagrees with the config is refused). `verify --templates` reads +`metadata` and `templates` from the same config (`--prompts` overrides only when the config +names no `templates`). `metaobjects verify` defaults to `--codegen` (it shares the exact `gen` code path so the two can't diverge). `--templates` is the prompt/template drift gate (see the prompts reference). Schema migration + live-DB drift are **not** `metaobjects` — they run through the Node `meta` tool (see the migration reference). @@ -66,7 +79,7 @@ target whose `outDir` holds generated files only: ```yaml targets: docs: - outDir: docs # → docs/api/python + outDir: docs/python # → docs/python/api/python generators: [api-docs] apiDocs: # optional: apiSubDir (default api/python), projectName, modelBaseUrl projectName: acme @@ -76,8 +89,10 @@ apiDocs: # optional: apiSubDir (default api/python), pro the config declares, and `verify --codegen` checks the pages like code. `AGENT-API.md` has the exact imports, signatures, and payload field shapes for the generated code. **Before calling any generated code, read `api/python/AGENT-API.md`.** To write only the -documentation, `metaobjects gen --generators api-docs`. Migrating a project that ran -`metaobjects docs`: `docs/features/migrations/docs-are-generators.md`, section "Python". +documentation, `metaobjects gen --generators api-docs`. In a repository with more than one port, give each port's documentation its own +directory: another port's generated pages count as foreign files, and in a shared +directory each port's `verify --codegen` reports the other ports' pages as drift. Migrating a project that ran +`metaobjects docs`: , section "Python". **The neutral model pages are a Node `meta gen` generator, not something this port emits.** `meta docs`'s language-neutral model surface was retired along with the rest of diff --git a/agent-context/skills/metaobjects-codegen/references/typescript-docs.md b/agent-context/skills/metaobjects-codegen/references/typescript-docs.md index 016c7dc9e..2159716b4 100644 --- a/agent-context/skills/metaobjects-codegen/references/typescript-docs.md +++ b/agent-context/skills/metaobjects-codegen/references/typescript-docs.md @@ -46,13 +46,15 @@ npx meta gen --generators docs,api-docs,requirements,agent-docs # documentation **Give docs factories a `docs` target.** Each defaults to the target named `docs` (`targets.docs.outDir`) unless you pass `target`; a run that lists one while `targets` has no `docs` entry is refused, naming the generator — documentation is never written into the -code's output directory by default. +code's output directory by default. In a repository with more than one port, give each port's documentation its own +directory (`./docs/generated` here, `docs/csharp` and `docs/python` for those ports): another port's generated pages count as foreign files, and in a shared +directory each port's `verify --codegen` reports the other ports' pages as drift. `--generators ` narrows a run to entries `generators` already declares, by registry name (`meta gen --list` prints them); it never adds one, and naming an entry the config does not declare is refused. A config still carrying a 1.0 `docs: { ... }` block fails `meta gen` and `meta verify --codegen` before writing anything, naming the block. Full -before/after and the flag-to-option table: `docs/features/migrations/docs-are-generators.md`, +before/after and the flag-to-option table: , section "TypeScript". **An owned docs generator goes in `generators`, like any other owned copy.** `meta eject diff --git a/agent-context/skills/metaobjects-codegen/references/typescript.md b/agent-context/skills/metaobjects-codegen/references/typescript.md index 6b767be20..8dadc108f 100644 --- a/agent-context/skills/metaobjects-codegen/references/typescript.md +++ b/agent-context/skills/metaobjects-codegen/references/typescript.md @@ -37,6 +37,11 @@ generator exporting `Generator()` — and adds its import and entry t prints the two lines to add). It refuses a reference generator's name (eject that one instead) and never overwrites your file without `--force`. Then edit the emit. +In a repository that also has a C# port, `dotnet meta init` writes its codegen project into +this same `codegen/` (`Generators.cs`, `Program.cs`, `Codegen.csproj`, and C# ejects in +`codegen/generators/`). The two coexist: `tsconfig.codegen.json` includes only +`codegen/**/*.ts`, and the C# project compiles only `*.cs`. + Everything a generator reads comes from `@metaobjectsdev/codegen-ts`: `perEntity` / `perPackage` / `perModel`, `isAbstract`, `hasAnyRdbSource`, `isProjection`, `servesReadApi` / `servesWriteApi`, `objectRefTarget`, `enumValues`, `effectivePackage`, diff --git a/agent-context/templates/always-on.md.mustache b/agent-context/templates/always-on.md.mustache index bedba3d09..e13486f46 100644 --- a/agent-context/templates/always-on.md.mustache +++ b/agent-context/templates/always-on.md.mustache @@ -5,8 +5,9 @@ > Generated by MetaObjects (refresh: `meta init --refresh-docs`). Put project notes in your > **root `CLAUDE.md`**, not here — this file is regenerated; see the note at the end before editing it. -MetaObjects is a metadata standard: typed metadata in `metaobjects/` is the durable -spine; generated code is the disposable artifact. Regenerate with `{{codegenCommand}}`. +MetaObjects is a metadata standard: typed metadata (in `metaobjects/` unless the project +configures another directory) is the durable spine; generated code is the disposable +artifact. Regenerate with `{{codegenCommand}}`. ## Read the generated reference before you touch a tier {{#nodeDocsSurface}} diff --git a/docs/features/cli.md b/docs/features/cli.md index ea4eb083f..b07746abd 100644 --- a/docs/features/cli.md +++ b/docs/features/cli.md @@ -352,6 +352,8 @@ requirementTests: # optional; options of the `requirement-tests` filter: codegen.requirement_hooks:include # "module:symbol", resolved config-relative renderer: codegen.requirement_hooks:render # "module:symbol", resolved config-relative warnUncovered: true # the default +columnNaming: snake_case # optional; the database's column naming: literal (default) | snake_case | kebab-case +templates: prompts # optional; where template refs resolve from (default prompts/, else templates/) apiDocs: # optional; options of the `api-docs` generator, every key optional apiSubDir: api/python # the default projectName: shop # default "project" @@ -366,7 +368,7 @@ targets: generators: [entity] entities: [Author, Book] # optional allowlist; omit = every entity docs: - outDir: docs/generated + outDir: docs/python # a directory of its own: another port's pages here read as drift generators: [api-docs] # the api reference is an entry like any other templateSpec: template-spec.json # optional; a declarative template-spec, relative to this file ``` diff --git a/docs/features/migrations/docs-are-generators.md b/docs/features/migrations/docs-are-generators.md index 6116da114..ca47e2e4c 100644 --- a/docs/features/migrations/docs-are-generators.md +++ b/docs/features/migrations/docs-are-generators.md @@ -25,20 +25,20 @@ before writing anything, so a build never passes while silently writing no docum **What you see.** `meta docs` fails (exit 2) with: ``` -`meta docs` was removed in 1.1.0. `meta gen` writes documentation now: declare each docs generator in `generators` in metaobjects.config.ts. See docs/features/migrations/docs-are-generators.md, section "TypeScript". +`meta docs` was removed in 1.1.0. `meta gen` writes documentation now: declare each docs generator in `generators` in metaobjects.config.ts. See https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/docs-are-generators.md, section "TypeScript". ``` `meta verify --docs` fails (exit 2) with: ``` -`meta verify --docs` was removed in 1.1.0. `meta verify --codegen` checks documentation now, with everything else `generators` declares. See docs/features/migrations/docs-are-generators.md, section "TypeScript". +`meta verify --docs` was removed in 1.1.0. `meta verify --codegen` checks documentation now, with everything else `generators` declares. See https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/docs-are-generators.md, section "TypeScript". ``` A config that still has a `docs` block fails `meta gen` and `meta verify --codegen` before anything is written: ``` -meta gen: found 1.0 docs configuration (the docs block in metaobjects.config.ts). Nothing reads it any more, so this run would write no documentation. Declare each docs generator in `generators` in metaobjects.config.ts. See docs/features/migrations/docs-are-generators.md, section "TypeScript". +meta gen: found 1.0 docs configuration (the docs block in metaobjects.config.ts). Nothing reads it any more, so this run would write no documentation. Declare each docs generator in `generators` in metaobjects.config.ts. See https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/docs-are-generators.md, section "TypeScript". ``` **Before (1.0).** `meta docs` wrote four sets of pages nobody listed (the model pages, the @@ -168,6 +168,10 @@ them only if you ran those flags. - `meta verify --codegen` now checks the pages as it checks code. A page a fresh run no longer writes (the page of an object you removed) is drift until you delete it; `meta gen` does not delete it for you. A hand-written file in the docs directory is left alone. +- In a repository with more than one port, give each port's documentation a directory of + its own (`docs/generated` for TypeScript, `docs/csharp`, `docs/python`): another port's + generated pages count as foreign files, so in a shared directory each port's + `verify --codegen` reports the other ports' pages as drift. - The site loads the same metadata `meta gen` loads, from the project's declared sources. **Scripts and CI.** Replace `meta docs` with `meta gen`, and `meta verify --docs` with @@ -202,7 +206,7 @@ all and wrote the model pages and the requirement ledger. In 1.1.0 nothing is ge until a config declares it, and `meta gen` with no config fails with: ``` -meta gen: no metaobjects.config.ts. Nothing is generated until a config declares it, documentation included; `meta init` writes one. A 1.0 project that ran `meta docs` with no config: see docs/features/migrations/docs-are-generators.md, section "TypeScript". +meta gen: no metaobjects.config.ts. Nothing is generated until a config declares it, documentation included; `meta init` writes one. A 1.0 project that ran `meta docs` with no config: see https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/docs-are-generators.md, section "TypeScript". ``` Write a `metaobjects.config.ts` at the project root with a `docs` target and `docsFile()` @@ -229,14 +233,14 @@ node, so drop it if yours declares none. Then run `meta gen`. **What you see.** `dotnet meta docs` prints this line and exits 2: ``` -dotnet meta docs was removed in 1.1.0. `dotnet meta gen` writes documentation now: declare each docs generator in codegen/Generators.cs. See docs/features/migrations/docs-are-generators.md, section "C#". +dotnet meta docs was removed in 1.1.0. `dotnet meta gen` writes documentation now: declare each docs generator in codegen/Generators.cs. See https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/docs-are-generators.md, section "C#". ``` `dotnet meta gen` (and `dotnet meta verify --codegen`) in a project with no `codegen/Generators.cs` prints this and exits 2, writing nothing: ``` -dotnet meta gen: no codegen/Generators.cs. The generator list lives there in 1.1.0, documentation included; `dotnet meta init` writes it. See docs/features/migrations/docs-are-generators.md, section "C#". +dotnet meta gen: no codegen/Generators.cs. The generator list lives there in 1.1.0, documentation included; `dotnet meta init` writes it. See https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/docs-are-generators.md, section "C#". ``` `dotnet meta gen` given `--project` or `--model-base-url` fails before writing anything, @@ -248,7 +252,7 @@ written by a second command: ``` dotnet meta gen ./metaobjects --out ./Generated --generators entity,db-context -dotnet meta docs ./metaobjects --out ./docs/generated --project shop --model-base-url https://docs.example.com/model +dotnet meta docs ./metaobjects --out ./docs/csharp --project shop --model-base-url https://docs.example.com/model ``` A project that had ejected a generator also had `codegen/Program.cs`, with the list inline: @@ -285,7 +289,7 @@ public static class Generators new MetaObjects.Codegen.Generators.DbContextGenerator(), new MetaObjects.Codegen.Generators.ApiDocsGenerator { - OutDir = "docs/generated", + OutDir = "docs/csharp", ProjectName = "shop", ModelBaseUrl = "https://docs.example.com/model", }, @@ -312,7 +316,7 @@ and replace `Program.cs` with the two lines above. `dotnet meta init` writes (`dotnet meta verify --codegen` in place of `dotnet meta gen` for the gate): ``` -dotnet meta gen: codegen/Program.cs does not run Generators.All, so it would ignore the generator list in codegen/Generators.cs. Replace it with the two-line entry `using MetaObjects.Codegen;` and `return CodegenCli.Run(args, Generators.All);`. See docs/features/migrations/docs-are-generators.md, section "C#". +dotnet meta gen: codegen/Program.cs does not run Generators.All, so it would ignore the generator list in codegen/Generators.cs. Replace it with the two-line entry `using MetaObjects.Codegen;` and `return CodegenCli.Run(args, Generators.All);`. See https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/docs-are-generators.md, section "C#". ``` `init` and `eject` say the same, and warn when `codegen/Codegen.csproj` still pins an older @@ -325,7 +329,7 @@ Neither is read in 1.1.0. A run that finds either, with no spec entry in the lis before writing anything: ``` -dotnet meta gen: found 1.0 template-spec configuration (template-spec.json). Nothing reads it any more, so this run would not render its templates. Declare it in codegen/Generators.cs as `new MetaObjects.Codegen.TemplateCodegen.TemplateSpecGenerator { Spec = "template-spec.json" }`. See docs/features/migrations/docs-are-generators.md, section "C#". +dotnet meta gen: found 1.0 template-spec configuration (template-spec.json). Nothing reads it any more, so this run would not render its templates. Declare it in codegen/Generators.cs as `new MetaObjects.Codegen.TemplateCodegen.TemplateSpecGenerator { Spec = "template-spec.json" }`. See https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/docs-are-generators.md, section "C#". ``` (`--template-spec` in place of `template-spec.json` when the flag was given; @@ -371,7 +375,8 @@ exists, else `templates/`. | `dotnet meta docs --model-base-url ` | `ModelBaseUrl` | | the `api/csharp` subdirectory under `--out` | `ApiSubDir`, same default | | `dotnet meta gen --template-spec `, or a project-root `template-spec.json` | a `TemplateSpecGenerator { Spec = "" }` entry | -| `dotnet meta gen --template-root ` | `TemplateRoot` on the spec entry, and the template root `render-helper` is constructed with in the list | +| `dotnet meta gen --template-root ` | `TemplateRoot` on the spec entry, and the constructor argument of the `render-helper` entry: `new MetaObjects.Codegen.Generators.RenderHelperGenerator("prompts")` | +| `dotnet meta gen --column-naming ` (and the same flag on `verify --codegen`) | `ColumnNaming` on the list: `All { get; } = new GeneratorList { ColumnNaming = ColumnNamingStrategy.SnakeCase, Entries = [ ... ] };`. Gen and `verify --codegen` both read it; a flag that contradicts it is refused | | `dotnet meta docs --namespace ` | the gen run's `--namespace`, as before | | whether you ran `dotnet meta docs` | whether `ApiDocsGenerator` is listed | | an owned api-docs copy, run by `dotnet meta docs` (1.1.0 release candidates only) | `new Codegen.Generators.ApiDocsGenerator { ... }` in the list, in place of the packaged one | @@ -379,6 +384,10 @@ exists, else `templates/`. **Give the api-docs entry an `OutDir` that holds generated files only.** `dotnet meta verify --codegen` now checks the pages, as it checks code: it regenerates each entry's `OutDir` and compares the whole tree, so a hand-written file there reads as stale. +In a repository with more than one port, give each port's documentation a directory of +its own (`docs/generated` for TypeScript, `docs/csharp`, `docs/python`): another port's +generated pages count as foreign files, so in a shared directory each port's +`verify --codegen` reports the other ports' pages as drift. **Scripts and CI.** Replace `dotnet meta docs ...` with `dotnet meta gen`. To write only the documentation, name the docs entry: @@ -388,14 +397,17 @@ dotnet meta gen ./metaobjects --out ./Generated --generators api-docs ``` Drop `--generators` from a `dotnet meta gen` or `verify --codegen` line that used it to choose -the generators: the list chooses them now. +the generators: the list chooses them now. Move `--column-naming` into the list as +`ColumnNaming` (above) before you shorten a script's command line: a run with neither +uses `literal`, and on a database with `snake_case` columns it rebinds every entity to +columns that do not exist. ## Python **What you see.** `metaobjects docs` prints this line and exits 2: ``` -metaobjects docs was removed in 1.1.0. `metaobjects gen` writes documentation now: declare each docs generator in a target's `generators` in metaobjects.config.yaml. See docs/features/migrations/docs-are-generators.md, section "Python". +metaobjects docs was removed in 1.1.0. `metaobjects gen` writes documentation now: declare each docs generator in a target's `generators` in metaobjects.config.yaml. See https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/docs-are-generators.md, section "Python". ``` `metaobjects gen` (and `metaobjects verify --codegen`) with no `metaobjects.config.yaml` @@ -403,20 +415,20 @@ prints this and exits 2, writing nothing. That includes the 1.0 form `metaobjects gen --out --generators `: ``` -metaobjects gen: no metaobjects.config.yaml. The generator list lives there in 1.1.0, documentation included. See docs/features/migrations/docs-are-generators.md, section "Python". +metaobjects gen: no metaobjects.config.yaml. The generator list lives there in 1.1.0, documentation included. See https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/docs-are-generators.md, section "Python". ``` A config that still has a top-level `docs` block fails `gen` before it writes anything: ``` -metaobjects gen: found 1.0 docs configuration (the docs block in metaobjects.config.yaml). Nothing reads it any more, so this run would write no documentation. Declare each docs generator in a target's `generators` in metaobjects.config.yaml. See docs/features/migrations/docs-are-generators.md, section "Python". +metaobjects gen: found 1.0 docs configuration (the docs block in metaobjects.config.yaml). Nothing reads it any more, so this run would write no documentation. Declare each docs generator in a target's `generators` in metaobjects.config.yaml. See https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/docs-are-generators.md, section "Python". ``` A target with no `generators` key fails `gen` (exit 2) before anything is written; list its generators, or write `generators: []` for a target that writes nothing: ``` -metaobjects gen: target(s) 'api' declare no `generators` key. In 1.1.0 a target runs only the generators it lists: list them, or write `generators: []` for a target that writes nothing. See docs/features/migrations/docs-are-generators.md, section "Python". +metaobjects gen: target(s) 'api' declare no `generators` key. In 1.1.0 a target runs only the generators it lists: list them, or write `generators: []` for a target that writes nothing. See https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/docs-are-generators.md, section "Python". ``` **Before (1.0).** Code from a config file, or from flags with no config, and the api @@ -432,7 +444,7 @@ targets: ``` metaobjects gen -metaobjects docs --out docs/generated --project shop --model-base-url https://docs.example.com/model +metaobjects docs --out docs/python --project shop --model-base-url https://docs.example.com/model ``` or, with no config: @@ -453,7 +465,7 @@ targets: outDir: src/generated generators: [entity, filter-allowlist, routes] docs: - outDir: docs/generated + outDir: docs/python generators: [api-docs] # an owned copy: "codegen.generators.api_docs:api_docs_generator" apiDocs: projectName: shop @@ -480,14 +492,24 @@ metaobjects gen | `gen --generators a,b,c` (the selection) | the target's `generators`; `--generators` now only narrows a run to declared entries, and naming one no target declares is refused | | `gen --template-spec `, or the `template-spec.json` beside `` that 1.0 found on its own | `templateSpec: ` on the target its output belongs in; a leftover `template-spec.json` that no target declares fails the run with a line that starts `metaobjects gen: found 1.0 template-spec configuration (` | | `gen --entities a,b` | the target's `entities` (`--entities` still narrows a run) | +| `gen --column-naming ` (and `verify --codegen --column-naming`) | top-level `columnNaming`, which `gen` and `verify --codegen` both read; a flag that contradicts it is refused | +| `gen --templates ` (and `verify --prompts `) | top-level `templates`, relative to the config file; `verify --templates` reads it, and `metadata`, from the config too | **Give the docs target an `outDir` that holds generated files only.** `metaobjects verify --codegen` now checks the pages, as it checks code: it regenerates each target's `outDir` and compares the files it would write. +In a repository with more than one port, give each port's documentation a directory of +its own (`docs/generated` for TypeScript, `docs/csharp`, `docs/python`): another port's +generated pages count as foreign files, so in a shared directory each port's +`verify --codegen` reports the other ports' pages as drift. **Scripts and CI.** Replace `metaobjects docs ...` with `metaobjects gen`, and `metaobjects gen --out ...` with `metaobjects gen` once the config -declares the list. To write only the documentation, name the docs entry (or its target): +declares the list **and every setting the old line passed**: `--column-naming` becomes +`columnNaming`, `--templates` becomes `templates`. A plain `metaobjects gen` with neither +uses `literal` naming, and on a database with `snake_case` columns it rewrites every +`*_names.py` with column names that do not exist. To write only the documentation, name the +docs entry (or its target): ``` metaobjects gen --generators api-docs @@ -499,7 +521,7 @@ metaobjects gen --target docs **What you see.** A pom that binds the `docs` goal fails with: ``` -metaobjects:docs was removed in 8.1.0. `mvn metaobjects:generate` writes documentation now: declare each docs generator as a entry in the plugin's . See docs/features/migrations/docs-are-generators.md, section "Java and Kotlin". +metaobjects:docs was removed in 8.1.0. `mvn metaobjects:generate` writes documentation now: declare each docs generator as a entry in the plugin's . See https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/docs-are-generators.md, section "Java and Kotlin". ``` A pom that still sets the goal's parameters fails in `generate` and `verify` with a line @@ -596,6 +618,10 @@ to the same paths as the 8.0 pom above: **Give each api-docs entry an `outputDir` that holds generated files only.** `metaobjects:verify` now checks the pages, as it checks code: it regenerates each entry's `outputDir` and compares the whole tree, so a hand-written file there reads as stale. +In a repository with more than one port, give each port's documentation a directory of +its own (the examples here write to `${project.build.directory}/docs`, which is already per module): another port's +generated pages count as foreign files, so in a shared directory each port's +`verify --codegen` reports the other ports' pages as drift. **Scripts and CI.** Replace `mvn metaobjects:docs` with `mvn metaobjects:generate`. To write only the documentation, give it an execution of its own and run that one: diff --git a/docs/features/own-your-codegen.md b/docs/features/own-your-codegen.md index 1e31a7293..9f19eb5d0 100644 --- a/docs/features/own-your-codegen.md +++ b/docs/features/own-your-codegen.md @@ -957,7 +957,7 @@ one: ```yaml targets: docs: - outDir: docs/generated + outDir: docs/python generators: [codegen.generators.api_docs:api_docs_generator] apiDocs: projectName: shop @@ -1440,7 +1440,7 @@ page model (`CSharpApiModel.cs`), the builder that decides what to document editing them, and list the copy in place of the packaged entry: ```csharp -new Codegen.Generators.ApiDocsGenerator { OutDir = "docs/generated", ProjectName = "shop" }, +new Codegen.Generators.ApiDocsGenerator { OutDir = "docs/csharp", ProjectName = "shop" }, ``` **It describes what the packaged generators emit.** The reference documents a route, an diff --git a/docs/features/prerelease.md b/docs/features/prerelease.md index 3367174be..e356ba482 100644 --- a/docs/features/prerelease.md +++ b/docs/features/prerelease.md @@ -32,12 +32,14 @@ One canonical internal string, normalized per ecosystem in exactly one place | form | `-rc.` | `0.24.0-rc.3` | `0.24.0rc3` | `0.24.0-rc.3` | `7.24.0-rc.3` | | why | | SemVer2 verbatim | PEP 440 canonical form | SemVer2 verbatim | same `minor.patch`, major is the npm major plus 7 (`1.1.0-rc.2` is `8.1.0-rc.2`) | -`` is the in-development version, read from **CHANGELOG.md's topmost `## [x.y.z]` -header** — the repository's own declaration of what is being worked on, written when the -work lands and therefore ahead of the `package.json` bump, which still carries the last -*released* version. It falls back to the next minor only when that top entry is already -released, i.e. when nobody has declared a next version yet; `--base` overrides both, and -the run prints which of the three it used. Deriving it as minor+1 unconditionally was +`` is the in-development version: the lowest **CHANGELOG.md `## [x.y.z]` section that is +not released yet** — the repository's own declaration of what is being worked on. "Released" +is read from where a release leaves its mark, public npm's `latest` for the CLI and the +`v` tags, never from `package.json`: release preparation bumps `package.json` first, +and reading it as released numbered every candidate cut during release prep one minor too +high (`1.2.0-rc.3` while `1.1.0` was the work in hand). The base falls back to the next minor +only when every section is released; `--base` overrides, but never to skip an unpublished +section (the run refuses), and the run prints which source it used. Deriving it as minor+1 unconditionally was wrong on every PATCH line — `0.23.3` after `0.23.2` is an ordinary outcome, and the tool would say `0.24.0` while the changelog said `0.23.3`, so every invocation needed `--base` to be talked out of it. One forgotten flag burns a version number permanently. @@ -143,7 +145,12 @@ tools/prerelease/prerelease-link.sh check tools/prerelease/prerelease-link.sh unlink --to 0.23.2 ``` -`link` detects which ecosystems the project uses and configures only those, scoped to the +`link` detects which ecosystems the project uses, at any depth, and configures only those: +every independent Maven build (a `pom.xml` with none above it; modules inherit) and every +Python project (`pyproject.toml`) gets its own block, so a repository with one project per +port under `ports/` needs one `link`, and every `pom.xml` is repinned, modules included. The +.NET local tool manifest (`.config/dotnet-tools.json`) is repinned with the packages, so +`dotnet meta` moves to the candidate too. Configuration is scoped to the vendor namespaces (`@metaobjectsdev/*`, `metaobjects`, `MetaObjects*`, `com.metaobjects`). Everything else keeps resolving from the public registry — verified: `zod` and `pyyaml` still come from npmjs.org and pypi.org while the vendor packages come from the pre-release diff --git a/docs/ports/csharp.md b/docs/ports/csharp.md index b2055aee3..080bae944 100644 --- a/docs/ports/csharp.md +++ b/docs/ports/csharp.md @@ -281,9 +281,13 @@ complete on every class, inherited entries included. The default column-naming strategy is `literal` here (unlike TypeScript's `snake_case`) — see the "Column naming" section of [`features/field-types.md`](../features/field-types.md) for why the defaults -disagree across ports and what to do about it. Both `dotnet meta gen` and -`dotnet meta verify` take the same `--column-naming` flag, so a -`verify --codegen` regen resolves the identical column strings `gen` did. +disagree across ports and what to do about it. Declare it once on the list, +`All { get; } = new GeneratorList { ColumnNaming = ColumnNamingStrategy.SnakeCase, Entries = [ ... ] };` +in `codegen/Generators.cs`: `dotnet meta gen` and `dotnet meta verify --codegen` both read it, +so a plain run and the drift gate resolve the identical column strings. `--column-naming` +still works for a list that declares no naming, and is refused when it contradicts one; +`verify --codegen` says so when the committed output was generated with another naming, +rather than telling you to regenerate. ## Use diff --git a/docs/ports/python.md b/docs/ports/python.md index 5e5d28001..dd8a318e9 100644 --- a/docs/ports/python.md +++ b/docs/ports/python.md @@ -139,8 +139,11 @@ The 1.0 form `metaobjects gen --out --generators ` i the metadata directory is `metadata` and each output directory is a target's `outDir`. `--generators ` now narrows a run to the declared entries named and never adds one (naming one no target declares is refused); `--target ` runs one target; -`--entities`, `--column-naming`, `--provider`, `--templates`, `--baseline` and `--config` -work as before. See [the migration section](../features/migrations/docs-are-generators.md#python) +`--entities`, `--provider`, `--baseline` and `--config` work as before. The column naming +and the template directory are settings of the run, so they are config keys too: +`columnNaming` and `templates` at the top level of `metaobjects.config.yaml`, read by `gen`, +`verify --codegen` and `verify --templates` alike; `--column-naming` and `--templates` apply +only where the config declares neither, and one that contradicts the config is refused. See [the migration section](../features/migrations/docs-are-generators.md#python) and the [config reference](../features/cli.md). ### Reports @@ -181,13 +184,18 @@ source declares a `@schema`. On the 16-entity persistence-conformance model the server-side selection emits 68 files and this emits 19 — so adopting the names tier does not drag a REST surface into a repo that does not want one. -**Pass the same `--column-naming` the schema was created with.** It defaults to +**Declare the column naming the schema was created with, as `columnNaming` in +`metaobjects.config.yaml`.** It defaults to `literal` here, matching this port's `ObjectManager` — **not** `meta migrate`'s `snake_case`. On a schema built by `meta migrate`, the default emits `PROGRAM_PRICE_CENTS_COLUMN = "priceCents"` for a column actually named `price_cents`: a constant that names a column which does not exist, which is worse -than the literal it replaced. Use `--column-naming snake_case` there. (A field -carrying an explicit `@column` is unaffected either way.) +than the literal it replaced. Use `columnNaming: snake_case` there. A flag alone does +not hold: a script that passed `--column-naming snake_case` and was replaced by a plain +`metaobjects gen` rewrote every `*_names.py` with the camelCase names, and the config key is +what a plain run reads. When `verify --codegen` finds the committed output was generated +with another naming, it says so rather than telling you to regenerate. (A field carrying +an explicit `@column` is unaffected either way.) **Both `pydantic` and `fastapi` are consumer-installed, not transitive deps of `metaobjects` itself** — the entity/router files this emits `import pydantic` @@ -394,11 +402,10 @@ adding a convenience alongside an existing typed one. The constants resolve through the **same column-naming strategy** `ObjectManager` uses (`literal` by default) — pass the identical value to -both (`--column-naming` at `metaobjects gen` time, `column_naming=` on +both (`columnNaming` in `metaobjects.config.yaml`, `column_naming=` on `ObjectManager`), or the constant a consumer imports names a different column -than the one a row actually lands in. `metaobjects verify` also takes -`--column-naming`, defaulting to the same `literal`, so a `verify --codegen` -regen resolves the same column strings `gen` did instead of reporting +than the one a row actually lands in. `metaobjects verify --codegen` reads the same +key, so its regen resolves the same column strings `gen` did instead of reporting spurious drift against a differently-configured run. ### Requirement tests diff --git a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md index 3b164338e..14c60029b 100644 --- a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md +++ b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md @@ -10,7 +10,9 @@ that drive code generation, runtime behavior, and drift detection. You author it the loader reads it, the codegen emits idiomatic per-language code from it. This skill is the procedure for writing it correctly. -Metadata lives in files under `metaobjects/` at project root, one file per domain +Metadata lives in files under the project's metadata directory (`metaobjects/` unless +the project's configuration names another: `sources` in `.metaobjects/config.json`, or the +port's own setting), one file per domain concept (`meta.commerce.json`, `meta.users.yaml`, …). Each file declares a `package` on its root node. Files in the same `package` with the same object `name` are merged by the loader. @@ -450,7 +452,7 @@ immutable. Supported in all five ports; the worked example is in `references/inh ## Requirements — capability ledger (opt-in) -**This capability exists whether or not the project uses it yet.** `requirement.functional` and `requirement.architectural` are registered metadata types, declared in `metaobjects/` beside the entities they describe and loaded by the same loader — no side file, no bespoke parser. They record *why* each part of the model exists, so a field with no reason to exist becomes visible as one. +**This capability exists whether or not the project uses it yet.** `requirement.functional` and `requirement.architectural` are registered metadata types, declared in the metadata directory beside the entities they describe and loaded by the same loader — no side file, no bespoke parser. They record *why* each part of the model exists, so a field with no reason to exist becomes visible as one. Reach for it when the project needs to answer any of: diff --git a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-codegen/SKILL.md b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-codegen/SKILL.md index 0a93a084f..fa28663b9 100644 --- a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-codegen/SKILL.md +++ b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-codegen/SKILL.md @@ -135,7 +135,10 @@ systems — and have it import the generated types. You run a `gen` step. The runner: -1. Loads all metadata under `metaobjects/` (the same loader the runtime uses). +1. Loads the project's metadata from wherever its configuration points (the same loader + the runtime uses): `sources` in `.metaobjects/config.json` (TypeScript, and C# when gen + names no ``), `metadata` in `metaobjects.config.yaml` (Python), the + plugin's `` (Java, Kotlin). `metaobjects/` is only the default. 2. Resolves output targets and precomputes shared render state. 3. Runs each configured **generator** — most emit one file per entity; some emit a single shared file (a barrel, a DB-context, an app-config). diff --git a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/java.md b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/java.md index c02465c15..2b5d0a0e0 100644 --- a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/java.md +++ b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/java.md @@ -32,7 +32,13 @@ All artifacts share `groupId` `com.metaobjects` and one version property: The plugin's `generate` goal binds to the `generate-sources` phase. Point its `` at your metadata and list one `` per output you -want, by fully-qualified class name. +want, by fully-qualified class name. The example is the whole Spring REST tier: the +controller's output names the DTO, the repository and the filter allowlist, and a DTO +names the value-object records its `field.object` columns carry, so the five move +together. `mvn metaobjects:generate` refuses a list that declares a generator without +the ones its output references (each registry entry's `requires`), naming what to add; +set `true` only when you supply those files by +hand or declare them in another ``. ```xml @@ -52,19 +58,25 @@ want, by fully-qualified class name. - com.metaobjects.generator.spring.SpringControllerGenerator - - ${project.build.directory}/generated-sources/java - + com.metaobjects.generator.spring.SpringDtoGenerator + ${project.build.directory}/generated-sources/java - com.metaobjects.generator.spring.SpringDtoGenerator + com.metaobjects.generator.spring.SpringValueObjectGenerator ${project.build.directory}/generated-sources/java com.metaobjects.generator.spring.SpringRepositoryGenerator ${project.build.directory}/generated-sources/java + + com.metaobjects.generator.spring.SpringFilterAllowlistGenerator + ${project.build.directory}/generated-sources/java + + + com.metaobjects.generator.spring.SpringControllerGenerator + ${project.build.directory}/generated-sources/java + @@ -154,7 +166,12 @@ payload field shapes for the generated code — plus a page per entity, under ``` Optional ``: `apiSubDir` (default `api/java`), `layout` (`flat` or `package`), -`projectName`, `modelBaseUrl`. Give it an `outputDir` of generated files only. +`projectName`, `modelBaseUrl`. Give it an `outputDir` of generated files only. In a repository with more than one port, give each port's documentation its own +directory: another port's generated pages count as foreign files, and in a shared +directory each port's `verify --codegen` reports the other ports' pages as drift. +Migrating a pom that bound `metaobjects:docs`: +, +section "Java and Kotlin". **Before calling any generated code, read `api/java/AGENT-API.md`** — it carries the concrete imports and signatures so you don't have to guess them. @@ -162,8 +179,10 @@ concrete imports and signatures so you don't have to guess them. ## `codegen-spring` generators Most live in `metaobjects-codegen-spring` under -`com.metaobjects.generator.spring.*`; wire any subset, typically all three of the -first group together. (`JavaObjectCodeGenerator`, last row below, lives in the +`com.metaobjects.generator.spring.*`. A subset must be closed over what its output +references: the controller needs the DTO, repository and filter allowlist; the DTO and +the template-tier generators need the value-object records; the repository needs the DTO. +`mvn metaobjects:generate` refuses a list that is not. (`JavaObjectCodeGenerator`, last row below, lives in the separate `metaobjects-codegen-base` module instead.) | Generator | Output | @@ -264,7 +283,8 @@ backed by an `object.value`: `SpringValueObjectGenerator` emits that VO as a Jav and OMDB's Jackson codec serializes it to/from the column (a single VO, or `List` for an array) — the same typed-jsonb round-trip the other ports ship. -Metadata lives under `src/main/metaobjects/` in the same canonical JSON the other +Metadata lives wherever the plugin's `` points (the examples here use +`src/main/metaobjects`; nothing requires that path), in the same canonical JSON the other ports read — fused-key form, `source.rdb` + `@table`, `@column` for a renamed physical column. diff --git a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/kotlin.md b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/kotlin.md index 0f470d98a..6ccae0b58 100644 --- a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/kotlin.md +++ b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/kotlin.md @@ -29,17 +29,33 @@ All artifacts share `groupId` `com.metaobjects` and one version property: | `metaobjects-codegen-kotlin` | the Kotlin (KotlinPoet) generators | | `metaobjects-maven-plugin` | the build-time codegen plugin | -Generated entities and Exposed tables need the consumer's own runtime deps — -`org.jetbrains.exposed:exposed-core` and -`org.jetbrains.kotlinx:kotlinx-serialization-json` (the latter for -`@Serializable`/parser output). +The generated code compiles against the consumer's own runtime dependencies, which the +plugin does not bring: + +| Dependency | Why | +|---|---| +| `org.jetbrains.exposed:exposed-core` | every generated `Table` | +| `org.jetbrains.exposed:exposed-java-time` | date, time and timestamp columns | +| `org.jetbrains.exposed:exposed-json` | `@storage: jsonb` columns | +| `org.jetbrains.exposed:exposed-jdbc` | the controller's and repository's queries, and `Database.connect` at run time | +| `com.fasterxml.jackson.core:jackson-databind`, `com.fasterxml.jackson.module:jackson-module-kotlin`, `com.fasterxml.jackson.datatype:jackson-datatype-jsr310` | the generated jsonb mapper and the controller's request binding | +| `org.jetbrains.kotlinx:kotlinx-serialization-json` | the parser tier, and `@dbColumnType: jsonb` open bags | +| `org.springframework:spring-web` (for example through `spring-boot-starter-web`) and `jakarta.validation:jakarta.validation-api` | the controller | + +Keep all four Exposed artifacts on one version. The port guide's `pom.xml` lists them +with versions. ## Plugin config in `pom.xml` The plugin's `generate` goal binds to the `generate-sources` phase. Point its `` at your metadata and list one `` per output you want, by fully-qualified class name. The Kotlin generators run through the same -Java plugin via the shared SPI: +Java plugin via the shared SPI. The example is the whole REST tier: the controller's +output names the entity data classes, the Exposed tables, the filter allowlists and the +relation queries `KotlinRelationsGenerator` writes, so the five move together. +`mvn metaobjects:generate` refuses a list that declares a generator without the ones its +output references, naming what to add (`true` +only when you supply those files by hand or declare them in another ``): ```xml @@ -66,6 +82,14 @@ Java plugin via the shared SPI: com.metaobjects.generator.kotlin.KotlinExposedTableGenerator ${project.build.directory}/generated-sources/kotlin + + com.metaobjects.generator.kotlin.KotlinRelationsGenerator + ${project.build.directory}/generated-sources/kotlin + + + com.metaobjects.generator.kotlin.KotlinFilterAllowlistGenerator + ${project.build.directory}/generated-sources/kotlin + com.metaobjects.generator.kotlin.KotlinSpringControllerGenerator ${project.build.directory}/generated-sources/kotlin @@ -152,7 +176,12 @@ payload field shapes for the generated code — plus a page per entity, under ``` Optional ``: `apiSubDir` (default `api/kotlin`), `layout` (`flat` or `package`), -`projectName`, `modelBaseUrl`. Give it an `outputDir` of generated files only. +`projectName`, `modelBaseUrl`. Give it an `outputDir` of generated files only. In a repository with more than one port, give each port's documentation its own +directory: another port's generated pages count as foreign files, and in a shared +directory each port's `verify --codegen` reports the other ports' pages as drift. +Migrating a pom that bound `metaobjects:docs`: +, +section "Java and Kotlin". **Before calling any generated code, read `api/kotlin/AGENT-API.md`.** @@ -165,11 +194,11 @@ All live in `metaobjects-codegen-kotlin` under |---|---| | `KotlinEntityGenerator` | `.kt` — `data class` (no `@Serializable`; Jackson-compatible) per `object.entity` / `object.value` / `object.projection`. A value object's data class is also the template tier's payload/response type (ADR-0056) — wire this generator with any template-tier one. A TPH `@discriminator` base's data class is the **union** of every subtype's columns (each folded nullable, validation dropped) so one wire shape backs the polymorphic + per-subtype endpoints. | | `KotlinExposedTableGenerator` | `Table.kt` — Exposed `Table` object (PK + FK + `@storage` columns) for entities with `source.rdb`. A TPH `@discriminator` base emits ONE `Table` for the whole hierarchy — every subtype-only column folded in `.nullable()` (a row of another subtype stores null there) — single-table inheritance; subtype entities emit no table of their own. | -| `KotlinRelationsGenerator` | `Relations.kt` — extension fns for `@cardinality="many"` query helpers | -| `KotlinSpringControllerGenerator` | `Controller.kt` — Spring `@RestController`, five CRUD endpoints on the cross-port REST contract, for writable entities (`source.rdb` `@kind="table"`). A TPH `@discriminator` base emits ONE controller: polymorphic `GET /(+/{id})` plus a per-subtype CRUD set at `//` — create injects the discriminator from the URL (never the body); get/update/delete are scoped to the subtype (cross-subtype → 404); the discriminator is immutable. | +| `KotlinRelationsGenerator` | `Relations.kt` — extension fns for `@cardinality="many"` query helpers (`Query`). The controller's traversal routes call them, so wire it whenever you wire the controller. | +| `KotlinSpringControllerGenerator` | `Controller.kt` — Spring `@RestController`, five CRUD endpoints on the cross-port REST contract, for writable entities (`source.rdb` `@kind="table"`). Needs `KotlinEntityGenerator`, `KotlinExposedTableGenerator`, `KotlinFilterAllowlistGenerator` and `KotlinRelationsGenerator` in the same list. A TPH `@discriminator` base emits ONE controller: polymorphic `GET /(+/{id})` plus a per-subtype CRUD set at `//` — create injects the discriminator from the URL (never the body); get/update/delete are scoped to the subtype (cross-subtype → 404); the discriminator is immutable. | | `KotlinRepositoryGenerator` | `RepositoryBase.kt` — an `open class` consumer persistence seam per writable entity (the Kotlin peer of Java's `SpringRepositoryGenerator`, but with method bodies). Carries the #203 `@autoSet` CRUD stamping: `onCreate` columns stamped once at insert, `onUpdate` columns re-stamped on every write, both excluded from the caller-supplied set. | | `KotlinOutputParserGenerator` | the strict parser-on-receipt for a **responding `template.prompt`** — one carrying `@responseRef` (ADR-0052: INBOUND; a `template.output` emits no parser). See the prompts reference. | -| `KotlinExtractorGenerator` | the FR-010 strict `extract` for a responding `template.prompt` (the value object's all-nullable `Extracted` mirror → its data class) | +| `KotlinExtractorGenerator` | the FR-010 strict `extract` for a responding `template.prompt` (the value object's all-nullable `Extracted` mirror → its data class). Needs `KotlinOutputParserGenerator` and `KotlinEntityGenerator` in the same list: it calls the parser and its mirror. | | `KotlinOutputPromptGenerator` | the FR-010 output-format prompt fragment for a responding `template.prompt` (presentation via `@promptStyle: guide`/`inline`/`exampleOnly`) | | `KotlinRenderHelperGenerator` | the typed render helper for a `template.prompt` payload | | `KotlinValidatorGenerator` | `MetadataStartupValidator.kt` + `ExposedTableValidator.kt` (once per project) | @@ -242,7 +271,8 @@ JUnit 4 project uses `renderer` or ejects. its default rendering (pass `-Dport`: the name is ejectable on both JVM ports). The requirement checks in `mvn metaobjects:verify` are core and are not ejectable. -Metadata lives under `src/main/metaobjects/` in the same canonical JSON the other +Metadata lives wherever the plugin's `` points (the examples here use +`src/main/metaobjects`; nothing requires that path), in the same canonical JSON the other ports read — fused-key form, `source.rdb` + `@table`, `@column` for a renamed physical column. diff --git a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.metaobjects/AGENTS.md b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.metaobjects/AGENTS.md index 393535ac4..d69ed2f6d 100644 --- a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.metaobjects/AGENTS.md +++ b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.metaobjects/AGENTS.md @@ -5,8 +5,9 @@ > Generated by MetaObjects (refresh: `meta init --refresh-docs`). Put project notes in your > **root `CLAUDE.md`**, not here — this file is regenerated; see the note at the end before editing it. -MetaObjects is a metadata standard: typed metadata in `metaobjects/` is the durable -spine; generated code is the disposable artifact. Regenerate with `mvn metaobjects:generate`. +MetaObjects is a metadata standard: typed metadata (in `metaobjects/` unless the project +configures another directory) is the durable spine; generated code is the disposable +artifact. Regenerate with `mvn metaobjects:generate`. ## Read the generated reference before you touch a tier `meta gen` writes four reference files under this project's docs target diff --git a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.metaobjects/CLAUDE.md b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.metaobjects/CLAUDE.md index 393535ac4..d69ed2f6d 100644 --- a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.metaobjects/CLAUDE.md +++ b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.metaobjects/CLAUDE.md @@ -5,8 +5,9 @@ > Generated by MetaObjects (refresh: `meta init --refresh-docs`). Put project notes in your > **root `CLAUDE.md`**, not here — this file is regenerated; see the note at the end before editing it. -MetaObjects is a metadata standard: typed metadata in `metaobjects/` is the durable -spine; generated code is the disposable artifact. Regenerate with `mvn metaobjects:generate`. +MetaObjects is a metadata standard: typed metadata (in `metaobjects/` unless the project +configures another directory) is the durable spine; generated code is the disposable +artifact. Regenerate with `mvn metaobjects:generate`. ## Read the generated reference before you touch a tier `meta gen` writes four reference files under this project's docs target diff --git a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/SKILL.md b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/SKILL.md index 3b164338e..14c60029b 100644 --- a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/SKILL.md +++ b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/SKILL.md @@ -10,7 +10,9 @@ that drive code generation, runtime behavior, and drift detection. You author it the loader reads it, the codegen emits idiomatic per-language code from it. This skill is the procedure for writing it correctly. -Metadata lives in files under `metaobjects/` at project root, one file per domain +Metadata lives in files under the project's metadata directory (`metaobjects/` unless +the project's configuration names another: `sources` in `.metaobjects/config.json`, or the +port's own setting), one file per domain concept (`meta.commerce.json`, `meta.users.yaml`, …). Each file declares a `package` on its root node. Files in the same `package` with the same object `name` are merged by the loader. @@ -450,7 +452,7 @@ immutable. Supported in all five ports; the worked example is in `references/inh ## Requirements — capability ledger (opt-in) -**This capability exists whether or not the project uses it yet.** `requirement.functional` and `requirement.architectural` are registered metadata types, declared in `metaobjects/` beside the entities they describe and loaded by the same loader — no side file, no bespoke parser. They record *why* each part of the model exists, so a field with no reason to exist becomes visible as one. +**This capability exists whether or not the project uses it yet.** `requirement.functional` and `requirement.architectural` are registered metadata types, declared in the metadata directory beside the entities they describe and loaded by the same loader — no side file, no bespoke parser. They record *why* each part of the model exists, so a field with no reason to exist becomes visible as one. Reach for it when the project needs to answer any of: diff --git a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-codegen/SKILL.md b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-codegen/SKILL.md index 0a93a084f..fa28663b9 100644 --- a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-codegen/SKILL.md +++ b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-codegen/SKILL.md @@ -135,7 +135,10 @@ systems — and have it import the generated types. You run a `gen` step. The runner: -1. Loads all metadata under `metaobjects/` (the same loader the runtime uses). +1. Loads the project's metadata from wherever its configuration points (the same loader + the runtime uses): `sources` in `.metaobjects/config.json` (TypeScript, and C# when gen + names no ``), `metadata` in `metaobjects.config.yaml` (Python), the + plugin's `` (Java, Kotlin). `metaobjects/` is only the default. 2. Resolves output targets and precomputes shared render state. 3. Runs each configured **generator** — most emit one file per entity; some emit a single shared file (a barrel, a DB-context, an app-config). diff --git a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-codegen/references/java.md b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-codegen/references/java.md index c02465c15..2b5d0a0e0 100644 --- a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-codegen/references/java.md +++ b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-codegen/references/java.md @@ -32,7 +32,13 @@ All artifacts share `groupId` `com.metaobjects` and one version property: The plugin's `generate` goal binds to the `generate-sources` phase. Point its `` at your metadata and list one `` per output you -want, by fully-qualified class name. +want, by fully-qualified class name. The example is the whole Spring REST tier: the +controller's output names the DTO, the repository and the filter allowlist, and a DTO +names the value-object records its `field.object` columns carry, so the five move +together. `mvn metaobjects:generate` refuses a list that declares a generator without +the ones its output references (each registry entry's `requires`), naming what to add; +set `true` only when you supply those files by +hand or declare them in another ``. ```xml @@ -52,19 +58,25 @@ want, by fully-qualified class name. - com.metaobjects.generator.spring.SpringControllerGenerator - - ${project.build.directory}/generated-sources/java - + com.metaobjects.generator.spring.SpringDtoGenerator + ${project.build.directory}/generated-sources/java - com.metaobjects.generator.spring.SpringDtoGenerator + com.metaobjects.generator.spring.SpringValueObjectGenerator ${project.build.directory}/generated-sources/java com.metaobjects.generator.spring.SpringRepositoryGenerator ${project.build.directory}/generated-sources/java + + com.metaobjects.generator.spring.SpringFilterAllowlistGenerator + ${project.build.directory}/generated-sources/java + + + com.metaobjects.generator.spring.SpringControllerGenerator + ${project.build.directory}/generated-sources/java + @@ -154,7 +166,12 @@ payload field shapes for the generated code — plus a page per entity, under ``` Optional ``: `apiSubDir` (default `api/java`), `layout` (`flat` or `package`), -`projectName`, `modelBaseUrl`. Give it an `outputDir` of generated files only. +`projectName`, `modelBaseUrl`. Give it an `outputDir` of generated files only. In a repository with more than one port, give each port's documentation its own +directory: another port's generated pages count as foreign files, and in a shared +directory each port's `verify --codegen` reports the other ports' pages as drift. +Migrating a pom that bound `metaobjects:docs`: +, +section "Java and Kotlin". **Before calling any generated code, read `api/java/AGENT-API.md`** — it carries the concrete imports and signatures so you don't have to guess them. @@ -162,8 +179,10 @@ concrete imports and signatures so you don't have to guess them. ## `codegen-spring` generators Most live in `metaobjects-codegen-spring` under -`com.metaobjects.generator.spring.*`; wire any subset, typically all three of the -first group together. (`JavaObjectCodeGenerator`, last row below, lives in the +`com.metaobjects.generator.spring.*`. A subset must be closed over what its output +references: the controller needs the DTO, repository and filter allowlist; the DTO and +the template-tier generators need the value-object records; the repository needs the DTO. +`mvn metaobjects:generate` refuses a list that is not. (`JavaObjectCodeGenerator`, last row below, lives in the separate `metaobjects-codegen-base` module instead.) | Generator | Output | @@ -264,7 +283,8 @@ backed by an `object.value`: `SpringValueObjectGenerator` emits that VO as a Jav and OMDB's Jackson codec serializes it to/from the column (a single VO, or `List` for an array) — the same typed-jsonb round-trip the other ports ship. -Metadata lives under `src/main/metaobjects/` in the same canonical JSON the other +Metadata lives wherever the plugin's `` points (the examples here use +`src/main/metaobjects`; nothing requires that path), in the same canonical JSON the other ports read — fused-key form, `source.rdb` + `@table`, `@column` for a renamed physical column. diff --git a/fixtures/agent-context-conformance/java-react/expected/.metaobjects/AGENTS.md b/fixtures/agent-context-conformance/java-react/expected/.metaobjects/AGENTS.md index 098e03a82..ccd4313a3 100644 --- a/fixtures/agent-context-conformance/java-react/expected/.metaobjects/AGENTS.md +++ b/fixtures/agent-context-conformance/java-react/expected/.metaobjects/AGENTS.md @@ -5,8 +5,9 @@ > Generated by MetaObjects (refresh: `meta init --refresh-docs`). Put project notes in your > **root `CLAUDE.md`**, not here — this file is regenerated; see the note at the end before editing it. -MetaObjects is a metadata standard: typed metadata in `metaobjects/` is the durable -spine; generated code is the disposable artifact. Regenerate with `mvn metaobjects:generate`. +MetaObjects is a metadata standard: typed metadata (in `metaobjects/` unless the project +configures another directory) is the durable spine; generated code is the disposable +artifact. Regenerate with `mvn metaobjects:generate`. ## Read the generated reference before you touch a tier `meta gen` writes four reference files under this project's docs target diff --git a/fixtures/agent-context-conformance/java-react/expected/.metaobjects/CLAUDE.md b/fixtures/agent-context-conformance/java-react/expected/.metaobjects/CLAUDE.md index 098e03a82..ccd4313a3 100644 --- a/fixtures/agent-context-conformance/java-react/expected/.metaobjects/CLAUDE.md +++ b/fixtures/agent-context-conformance/java-react/expected/.metaobjects/CLAUDE.md @@ -5,8 +5,9 @@ > Generated by MetaObjects (refresh: `meta init --refresh-docs`). Put project notes in your > **root `CLAUDE.md`**, not here — this file is regenerated; see the note at the end before editing it. -MetaObjects is a metadata standard: typed metadata in `metaobjects/` is the durable -spine; generated code is the disposable artifact. Regenerate with `mvn metaobjects:generate`. +MetaObjects is a metadata standard: typed metadata (in `metaobjects/` unless the project +configures another directory) is the durable spine; generated code is the disposable +artifact. Regenerate with `mvn metaobjects:generate`. ## Read the generated reference before you touch a tier `meta gen` writes four reference files under this project's docs target diff --git a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/SKILL.md b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/SKILL.md index 3b164338e..14c60029b 100644 --- a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/SKILL.md +++ b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/SKILL.md @@ -10,7 +10,9 @@ that drive code generation, runtime behavior, and drift detection. You author it the loader reads it, the codegen emits idiomatic per-language code from it. This skill is the procedure for writing it correctly. -Metadata lives in files under `metaobjects/` at project root, one file per domain +Metadata lives in files under the project's metadata directory (`metaobjects/` unless +the project's configuration names another: `sources` in `.metaobjects/config.json`, or the +port's own setting), one file per domain concept (`meta.commerce.json`, `meta.users.yaml`, …). Each file declares a `package` on its root node. Files in the same `package` with the same object `name` are merged by the loader. @@ -450,7 +452,7 @@ immutable. Supported in all five ports; the worked example is in `references/inh ## Requirements — capability ledger (opt-in) -**This capability exists whether or not the project uses it yet.** `requirement.functional` and `requirement.architectural` are registered metadata types, declared in `metaobjects/` beside the entities they describe and loaded by the same loader — no side file, no bespoke parser. They record *why* each part of the model exists, so a field with no reason to exist becomes visible as one. +**This capability exists whether or not the project uses it yet.** `requirement.functional` and `requirement.architectural` are registered metadata types, declared in the metadata directory beside the entities they describe and loaded by the same loader — no side file, no bespoke parser. They record *why* each part of the model exists, so a field with no reason to exist becomes visible as one. Reach for it when the project needs to answer any of: diff --git a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-codegen/SKILL.md b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-codegen/SKILL.md index 0a93a084f..fa28663b9 100644 --- a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-codegen/SKILL.md +++ b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-codegen/SKILL.md @@ -135,7 +135,10 @@ systems — and have it import the generated types. You run a `gen` step. The runner: -1. Loads all metadata under `metaobjects/` (the same loader the runtime uses). +1. Loads the project's metadata from wherever its configuration points (the same loader + the runtime uses): `sources` in `.metaobjects/config.json` (TypeScript, and C# when gen + names no ``), `metadata` in `metaobjects.config.yaml` (Python), the + plugin's `` (Java, Kotlin). `metaobjects/` is only the default. 2. Resolves output targets and precomputes shared render state. 3. Runs each configured **generator** — most emit one file per entity; some emit a single shared file (a barrel, a DB-context, an app-config). diff --git a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-codegen/references/python.md b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-codegen/references/python.md index d2ff1bab7..5c8a9bdc5 100644 --- a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-codegen/references/python.md +++ b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-codegen/references/python.md @@ -37,12 +37,22 @@ generated that the config does not declare. ```yaml # metaobjects.config.yaml metadata: metadata +columnNaming: snake_case # the naming your database's columns use: literal (default) | snake_case | kebab-case +templates: prompts # where template refs resolve from (default: prompts/, else templates/) targets: app: outDir: generated generators: [entity, filter-allowlist, routes] ``` +**Declare `columnNaming` in the config, never only on a command line.** It decides every +column name the `names` constants and the generated queries use. A plain `metaobjects gen` +reads it from the config; a script that passed `--column-naming snake_case` and is replaced +by a plain `gen` would otherwise fall back to `literal` and rewrite every `*_names.py` with +camelCase names the database does not have. A `--column-naming` or `--templates` that +contradicts the config is refused, and `verify --codegen` names a naming mismatch as one +rather than telling you to regenerate. + ```bash metaobjects gen # runs every target's generators into its outDir metaobjects gen --generators entity # narrow to declared entries (never adds one) @@ -51,8 +61,11 @@ metaobjects verify --codegen # regenerate the same list into a With no `metaobjects.config.yaml`, `gen` and `verify --codegen` exit 2 and write nothing. The 1.0 form `metaobjects gen --out --generators ` is not read; -`--target`, `--entities`, `--column-naming`, `--provider`, `--templates` and `--baseline` -still apply to the list. `metaobjects verify` defaults to `--codegen` (it shares the exact +`--target`, `--entities`, `--provider` and `--baseline` still apply to the list; +`--column-naming` and `--templates` only where the config declares no `columnNaming` / +`templates` (a flag that disagrees with the config is refused). `verify --templates` reads +`metadata` and `templates` from the same config (`--prompts` overrides only when the config +names no `templates`). `metaobjects verify` defaults to `--codegen` (it shares the exact `gen` code path so the two can't diverge). `--templates` is the prompt/template drift gate (see the prompts reference). Schema migration + live-DB drift are **not** `metaobjects` — they run through the Node `meta` tool (see the migration reference). @@ -66,7 +79,7 @@ target whose `outDir` holds generated files only: ```yaml targets: docs: - outDir: docs # → docs/api/python + outDir: docs/python # → docs/python/api/python generators: [api-docs] apiDocs: # optional: apiSubDir (default api/python), projectName, modelBaseUrl projectName: acme @@ -76,8 +89,10 @@ apiDocs: # optional: apiSubDir (default api/python), pro the config declares, and `verify --codegen` checks the pages like code. `AGENT-API.md` has the exact imports, signatures, and payload field shapes for the generated code. **Before calling any generated code, read `api/python/AGENT-API.md`.** To write only the -documentation, `metaobjects gen --generators api-docs`. Migrating a project that ran -`metaobjects docs`: `docs/features/migrations/docs-are-generators.md`, section "Python". +documentation, `metaobjects gen --generators api-docs`. In a repository with more than one port, give each port's documentation its own +directory: another port's generated pages count as foreign files, and in a shared +directory each port's `verify --codegen` reports the other ports' pages as drift. Migrating a project that ran +`metaobjects docs`: , section "Python". **The neutral model pages are a Node `meta gen` generator, not something this port emits.** `meta docs`'s language-neutral model surface was retired along with the rest of diff --git a/fixtures/agent-context-conformance/python/expected/.metaobjects/AGENTS.md b/fixtures/agent-context-conformance/python/expected/.metaobjects/AGENTS.md index c2c533a23..4742a4c60 100644 --- a/fixtures/agent-context-conformance/python/expected/.metaobjects/AGENTS.md +++ b/fixtures/agent-context-conformance/python/expected/.metaobjects/AGENTS.md @@ -5,8 +5,9 @@ > Generated by MetaObjects (refresh: `meta init --refresh-docs`). Put project notes in your > **root `CLAUDE.md`**, not here — this file is regenerated; see the note at the end before editing it. -MetaObjects is a metadata standard: typed metadata in `metaobjects/` is the durable -spine; generated code is the disposable artifact. Regenerate with `metaobjects gen`. +MetaObjects is a metadata standard: typed metadata (in `metaobjects/` unless the project +configures another directory) is the durable spine; generated code is the disposable +artifact. Regenerate with `metaobjects gen`. ## Read the generated reference before you touch a tier `metaobjects gen` writes ONE reference file under this project's docs output directory (when a target's generator list declares `api-docs`). It diff --git a/fixtures/agent-context-conformance/python/expected/.metaobjects/CLAUDE.md b/fixtures/agent-context-conformance/python/expected/.metaobjects/CLAUDE.md index c2c533a23..4742a4c60 100644 --- a/fixtures/agent-context-conformance/python/expected/.metaobjects/CLAUDE.md +++ b/fixtures/agent-context-conformance/python/expected/.metaobjects/CLAUDE.md @@ -5,8 +5,9 @@ > Generated by MetaObjects (refresh: `meta init --refresh-docs`). Put project notes in your > **root `CLAUDE.md`**, not here — this file is regenerated; see the note at the end before editing it. -MetaObjects is a metadata standard: typed metadata in `metaobjects/` is the durable -spine; generated code is the disposable artifact. Regenerate with `metaobjects gen`. +MetaObjects is a metadata standard: typed metadata (in `metaobjects/` unless the project +configures another directory) is the durable spine; generated code is the disposable +artifact. Regenerate with `metaobjects gen`. ## Read the generated reference before you touch a tier `metaobjects gen` writes ONE reference file under this project's docs output directory (when a target's generator list declares `api-docs`). It diff --git a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md index 3b164338e..14c60029b 100644 --- a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md +++ b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md @@ -10,7 +10,9 @@ that drive code generation, runtime behavior, and drift detection. You author it the loader reads it, the codegen emits idiomatic per-language code from it. This skill is the procedure for writing it correctly. -Metadata lives in files under `metaobjects/` at project root, one file per domain +Metadata lives in files under the project's metadata directory (`metaobjects/` unless +the project's configuration names another: `sources` in `.metaobjects/config.json`, or the +port's own setting), one file per domain concept (`meta.commerce.json`, `meta.users.yaml`, …). Each file declares a `package` on its root node. Files in the same `package` with the same object `name` are merged by the loader. @@ -450,7 +452,7 @@ immutable. Supported in all five ports; the worked example is in `references/inh ## Requirements — capability ledger (opt-in) -**This capability exists whether or not the project uses it yet.** `requirement.functional` and `requirement.architectural` are registered metadata types, declared in `metaobjects/` beside the entities they describe and loaded by the same loader — no side file, no bespoke parser. They record *why* each part of the model exists, so a field with no reason to exist becomes visible as one. +**This capability exists whether or not the project uses it yet.** `requirement.functional` and `requirement.architectural` are registered metadata types, declared in the metadata directory beside the entities they describe and loaded by the same loader — no side file, no bespoke parser. They record *why* each part of the model exists, so a field with no reason to exist becomes visible as one. Reach for it when the project needs to answer any of: diff --git a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/SKILL.md b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/SKILL.md index 0a93a084f..fa28663b9 100644 --- a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/SKILL.md +++ b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/SKILL.md @@ -135,7 +135,10 @@ systems — and have it import the generated types. You run a `gen` step. The runner: -1. Loads all metadata under `metaobjects/` (the same loader the runtime uses). +1. Loads the project's metadata from wherever its configuration points (the same loader + the runtime uses): `sources` in `.metaobjects/config.json` (TypeScript, and C# when gen + names no ``), `metadata` in `metaobjects.config.yaml` (Python), the + plugin's `` (Java, Kotlin). `metaobjects/` is only the default. 2. Resolves output targets and precomputes shared render state. 3. Runs each configured **generator** — most emit one file per entity; some emit a single shared file (a barrel, a DB-context, an app-config). diff --git a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/typescript-docs.md b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/typescript-docs.md index 016c7dc9e..2159716b4 100644 --- a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/typescript-docs.md +++ b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/typescript-docs.md @@ -46,13 +46,15 @@ npx meta gen --generators docs,api-docs,requirements,agent-docs # documentation **Give docs factories a `docs` target.** Each defaults to the target named `docs` (`targets.docs.outDir`) unless you pass `target`; a run that lists one while `targets` has no `docs` entry is refused, naming the generator — documentation is never written into the -code's output directory by default. +code's output directory by default. In a repository with more than one port, give each port's documentation its own +directory (`./docs/generated` here, `docs/csharp` and `docs/python` for those ports): another port's generated pages count as foreign files, and in a shared +directory each port's `verify --codegen` reports the other ports' pages as drift. `--generators ` narrows a run to entries `generators` already declares, by registry name (`meta gen --list` prints them); it never adds one, and naming an entry the config does not declare is refused. A config still carrying a 1.0 `docs: { ... }` block fails `meta gen` and `meta verify --codegen` before writing anything, naming the block. Full -before/after and the flag-to-option table: `docs/features/migrations/docs-are-generators.md`, +before/after and the flag-to-option table: , section "TypeScript". **An owned docs generator goes in `generators`, like any other owned copy.** `meta eject 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 6b767be20..8dadc108f 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 @@ -37,6 +37,11 @@ generator exporting `Generator()` — and adds its import and entry t prints the two lines to add). It refuses a reference generator's name (eject that one instead) and never overwrites your file without `--force`. Then edit the emit. +In a repository that also has a C# port, `dotnet meta init` writes its codegen project into +this same `codegen/` (`Generators.cs`, `Program.cs`, `Codegen.csproj`, and C# ejects in +`codegen/generators/`). The two coexist: `tsconfig.codegen.json` includes only +`codegen/**/*.ts`, and the C# project compiles only `*.cs`. + Everything a generator reads comes from `@metaobjectsdev/codegen-ts`: `perEntity` / `perPackage` / `perModel`, `isAbstract`, `hasAnyRdbSource`, `isProjection`, `servesReadApi` / `servesWriteApi`, `objectRefTarget`, `enumValues`, `effectivePackage`, diff --git a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.metaobjects/AGENTS.md b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.metaobjects/AGENTS.md index 20212e140..10501d7e9 100644 --- a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.metaobjects/AGENTS.md +++ b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.metaobjects/AGENTS.md @@ -5,8 +5,9 @@ > Generated by MetaObjects (refresh: `meta init --refresh-docs`). Put project notes in your > **root `CLAUDE.md`**, not here — this file is regenerated; see the note at the end before editing it. -MetaObjects is a metadata standard: typed metadata in `metaobjects/` is the durable -spine; generated code is the disposable artifact. Regenerate with `npx meta gen`. +MetaObjects is a metadata standard: typed metadata (in `metaobjects/` unless the project +configures another directory) is the durable spine; generated code is the disposable +artifact. Regenerate with `npx meta gen`. ## Read the generated reference before you touch a tier `meta gen` writes four reference files under this project's docs target diff --git a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.metaobjects/CLAUDE.md b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.metaobjects/CLAUDE.md index 20212e140..10501d7e9 100644 --- a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.metaobjects/CLAUDE.md +++ b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.metaobjects/CLAUDE.md @@ -5,8 +5,9 @@ > Generated by MetaObjects (refresh: `meta init --refresh-docs`). Put project notes in your > **root `CLAUDE.md`**, not here — this file is regenerated; see the note at the end before editing it. -MetaObjects is a metadata standard: typed metadata in `metaobjects/` is the durable -spine; generated code is the disposable artifact. Regenerate with `npx meta gen`. +MetaObjects is a metadata standard: typed metadata (in `metaobjects/` unless the project +configures another directory) is the durable spine; generated code is the disposable +artifact. Regenerate with `npx meta gen`. ## Read the generated reference before you touch a tier `meta gen` writes four reference files under this project's docs target diff --git a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-authoring/SKILL.md b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-authoring/SKILL.md index 3b164338e..14c60029b 100644 --- a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-authoring/SKILL.md +++ b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-authoring/SKILL.md @@ -10,7 +10,9 @@ that drive code generation, runtime behavior, and drift detection. You author it the loader reads it, the codegen emits idiomatic per-language code from it. This skill is the procedure for writing it correctly. -Metadata lives in files under `metaobjects/` at project root, one file per domain +Metadata lives in files under the project's metadata directory (`metaobjects/` unless +the project's configuration names another: `sources` in `.metaobjects/config.json`, or the +port's own setting), one file per domain concept (`meta.commerce.json`, `meta.users.yaml`, …). Each file declares a `package` on its root node. Files in the same `package` with the same object `name` are merged by the loader. @@ -450,7 +452,7 @@ immutable. Supported in all five ports; the worked example is in `references/inh ## Requirements — capability ledger (opt-in) -**This capability exists whether or not the project uses it yet.** `requirement.functional` and `requirement.architectural` are registered metadata types, declared in `metaobjects/` beside the entities they describe and loaded by the same loader — no side file, no bespoke parser. They record *why* each part of the model exists, so a field with no reason to exist becomes visible as one. +**This capability exists whether or not the project uses it yet.** `requirement.functional` and `requirement.architectural` are registered metadata types, declared in the metadata directory beside the entities they describe and loaded by the same loader — no side file, no bespoke parser. They record *why* each part of the model exists, so a field with no reason to exist becomes visible as one. Reach for it when the project needs to answer any of: diff --git a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-authoring/references/requirements.md b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-authoring/references/requirements.md index 9007a9c5e..e35490d5a 100644 --- a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-authoring/references/requirements.md +++ b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-authoring/references/requirements.md @@ -1,6 +1,6 @@ # Requirements — `requirement.functional` / `requirement.architectural` -Capabilities are **metadata**, declared in `metaobjects/` beside the entities they +Capabilities are **metadata**, declared in the metadata directory beside the entities they describe. Read the existing requirement nodes before designing anything. Two rules matter more than the rest. diff --git a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-codegen/SKILL.md b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-codegen/SKILL.md index 0a93a084f..fa28663b9 100644 --- a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-codegen/SKILL.md +++ b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-codegen/SKILL.md @@ -135,7 +135,10 @@ systems — and have it import the generated types. You run a `gen` step. The runner: -1. Loads all metadata under `metaobjects/` (the same loader the runtime uses). +1. Loads the project's metadata from wherever its configuration points (the same loader + the runtime uses): `sources` in `.metaobjects/config.json` (TypeScript, and C# when gen + names no ``), `metadata` in `metaobjects.config.yaml` (Python), the + plugin's `` (Java, Kotlin). `metaobjects/` is only the default. 2. Resolves output targets and precomputes shared render state. 3. Runs each configured **generator** — most emit one file per entity; some emit a single shared file (a barrel, a DB-context, an app-config). diff --git a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-codegen/references/typescript-docs.md b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-codegen/references/typescript-docs.md index 016c7dc9e..2159716b4 100644 --- a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-codegen/references/typescript-docs.md +++ b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-codegen/references/typescript-docs.md @@ -46,13 +46,15 @@ npx meta gen --generators docs,api-docs,requirements,agent-docs # documentation **Give docs factories a `docs` target.** Each defaults to the target named `docs` (`targets.docs.outDir`) unless you pass `target`; a run that lists one while `targets` has no `docs` entry is refused, naming the generator — documentation is never written into the -code's output directory by default. +code's output directory by default. In a repository with more than one port, give each port's documentation its own +directory (`./docs/generated` here, `docs/csharp` and `docs/python` for those ports): another port's generated pages count as foreign files, and in a shared +directory each port's `verify --codegen` reports the other ports' pages as drift. `--generators ` narrows a run to entries `generators` already declares, by registry name (`meta gen --list` prints them); it never adds one, and naming an entry the config does not declare is refused. A config still carrying a 1.0 `docs: { ... }` block fails `meta gen` and `meta verify --codegen` before writing anything, naming the block. Full -before/after and the flag-to-option table: `docs/features/migrations/docs-are-generators.md`, +before/after and the flag-to-option table: , section "TypeScript". **An owned docs generator goes in `generators`, like any other owned copy.** `meta eject 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 6b767be20..8dadc108f 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 @@ -37,6 +37,11 @@ generator exporting `Generator()` — and adds its import and entry t prints the two lines to add). It refuses a reference generator's name (eject that one instead) and never overwrites your file without `--force`. Then edit the emit. +In a repository that also has a C# port, `dotnet meta init` writes its codegen project into +this same `codegen/` (`Generators.cs`, `Program.cs`, `Codegen.csproj`, and C# ejects in +`codegen/generators/`). The two coexist: `tsconfig.codegen.json` includes only +`codegen/**/*.ts`, and the C# project compiles only `*.cs`. + Everything a generator reads comes from `@metaobjectsdev/codegen-ts`: `perEntity` / `perPackage` / `perModel`, `isAbstract`, `hasAnyRdbSource`, `isProjection`, `servesReadApi` / `servesWriteApi`, `objectRefTarget`, `enumValues`, `effectivePackage`, diff --git a/fixtures/agent-context-conformance/ts-requirements/expected/.metaobjects/AGENTS.md b/fixtures/agent-context-conformance/ts-requirements/expected/.metaobjects/AGENTS.md index d6f70faed..41e31dd36 100644 --- a/fixtures/agent-context-conformance/ts-requirements/expected/.metaobjects/AGENTS.md +++ b/fixtures/agent-context-conformance/ts-requirements/expected/.metaobjects/AGENTS.md @@ -5,8 +5,9 @@ > Generated by MetaObjects (refresh: `meta init --refresh-docs`). Put project notes in your > **root `CLAUDE.md`**, not here — this file is regenerated; see the note at the end before editing it. -MetaObjects is a metadata standard: typed metadata in `metaobjects/` is the durable -spine; generated code is the disposable artifact. Regenerate with `npx meta gen`. +MetaObjects is a metadata standard: typed metadata (in `metaobjects/` unless the project +configures another directory) is the durable spine; generated code is the disposable +artifact. Regenerate with `npx meta gen`. ## Read the generated reference before you touch a tier `meta gen` writes four reference files under this project's docs target diff --git a/fixtures/agent-context-conformance/ts-requirements/expected/.metaobjects/CLAUDE.md b/fixtures/agent-context-conformance/ts-requirements/expected/.metaobjects/CLAUDE.md index d6f70faed..41e31dd36 100644 --- a/fixtures/agent-context-conformance/ts-requirements/expected/.metaobjects/CLAUDE.md +++ b/fixtures/agent-context-conformance/ts-requirements/expected/.metaobjects/CLAUDE.md @@ -5,8 +5,9 @@ > Generated by MetaObjects (refresh: `meta init --refresh-docs`). Put project notes in your > **root `CLAUDE.md`**, not here — this file is regenerated; see the note at the end before editing it. -MetaObjects is a metadata standard: typed metadata in `metaobjects/` is the durable -spine; generated code is the disposable artifact. Regenerate with `npx meta gen`. +MetaObjects is a metadata standard: typed metadata (in `metaobjects/` unless the project +configures another directory) is the durable spine; generated code is the disposable +artifact. Regenerate with `npx meta gen`. ## Read the generated reference before you touch a tier `meta gen` writes four reference files under this project's docs target diff --git a/scripts/ci-local.sh b/scripts/ci-local.sh index 9942b429b..611442883 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 scripts/eject-release-gate.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 && node scripts/test-prerelease-version.mjs && bash tools/prerelease/test-prerelease-link.sh; } # ── repo-root scripts/ typechecks ───────────────────────────────────────────── # `bun test` transpiles per file and never typechecks, and `bun run --filter '*' diff --git a/scripts/prerelease-version.mjs b/scripts/prerelease-version.mjs new file mode 100644 index 000000000..d20f8fdf8 --- /dev/null +++ b/scripts/prerelease-version.mjs @@ -0,0 +1,105 @@ +// The decisions scripts/prerelease.mjs makes before it publishes anything, as pure functions so +// scripts/test-prerelease-version.mjs can hold them to the cases that burned version numbers. +// +// A version number is spent the moment it is published, so each of these answers "what number +// does this cut get?" from a source that cannot be wrong in the way the old one was. + +/** Compare two plain x.y.z versions: negative, zero or positive. */ +export function cmpVer(a, b) { + const [x, y] = [a, b].map((v) => v.split(".").map(Number)); + return x[0] - y[0] || x[1] - y[1] || x[2] - y[2]; +} + +const PLAIN = /^\d+\.\d+\.\d+$/; + +/** Every `## [x.y.z]` section header in a CHANGELOG, in file order. `[Unreleased]` is not one. */ +export function changelogVersions(markdown) { + return [...markdown.matchAll(/^## \[(\d+\.\d+\.\d+)\]/gm)].map((m) => m[1]); +} + +/** The highest release tag `v.y.z` (no prerelease suffix), or null. Only the npm major + * line counts: the repository also carries `v7.*` tags from the Maven numbering of 0.x. */ +export function latestReleaseTag(tags, major) { + const versions = tags + .map((t) => t.trim().replace(/^v/, "")) + .filter((v) => PLAIN.test(v) && Number(v.split(".")[0]) === major); + return versions.sort(cmpVer).pop() ?? null; +} + +/** + * What is RELEASED: the newer of public npm's `latest` for the CLI and the highest release tag. + * + * Not `package.json`. Release preparation bumps `package.json` to the version being prepared + * before anything is published, so reading it as "released" made the CHANGELOG's top section look + * already shipped, and every rc cut between the prep PR and the release was numbered one minor + * too high (`1.2.0-rc.3` while `1.1.0` was the work in hand). Null when neither source answers. + */ +export function releasedVersion({ npmLatest, tags, major }) { + const candidates = [npmLatest, latestReleaseTag(tags, major)].filter((v) => v && PLAIN.test(v)); + return candidates.sort(cmpVer).pop() ?? null; +} + +/** + * The version this cut is FOR: `{ base, source }`, or throws an Error whose message says why not. + * + * - A CHANGELOG section newer than the release is unpublished work; the lowest one is the next + * release, and the cut is for it. + * - `--base` may name that version, or any version when every section is published. It may not + * name an already released version, and it may not SKIP an unpublished section: that is the + * mis-numbering this exists to refuse, reached by hand instead of by guess. + * - With every section published and no `--base`, the next minor is guessed. + */ +export function decideBase({ released, changelog, explicit }) { + if (explicit != null && !PLAIN.test(explicit)) throw new Error(`--base must be a plain x.y.z, got ${explicit}`); + if (released == null) { + if (explicit != null) return { base: explicit, source: "--base (what is released could not be read)" }; + throw new Error( + "cannot tell which version is released: public npm did not answer and no release tag was found. " + + "Pass --base (the CHANGELOG section being prepared).", + ); + } + const unpublished = changelog.filter((v) => cmpVer(v, released) > 0).sort(cmpVer); + if (explicit != null) { + if (cmpVer(explicit, released) <= 0) + throw new Error(`--base ${explicit} is not newer than the released ${released}`); + if (unpublished.length > 0 && cmpVer(explicit, unpublished[0]) > 0) + throw new Error( + `--base ${explicit} would skip the unpublished CHANGELOG section [${unpublished[0]}]: ` + + `${unpublished[0]} has not been released, so a cut for a later version mis-numbers it.`, + ); + return { base: explicit, source: "--base" }; + } + if (unpublished.length > 0) return { base: unpublished[0], source: "CHANGELOG.md" }; + const [maj, min] = released.split(".").map(Number); + return { base: `${maj}.${min + 1}.0`, source: `guessed from released ${released}` }; +} + +/** + * Every item of a paged listing: `fetchPage(page, limit)` returns one page's array, and the walk + * stops at the first short page. A registry that caps a page (Gitea serves 50 whatever `limit` + * asks for) used to hide every used iteration past the first page from the counter. + */ +export async function collectPages(fetchPage, limit = 50, maxPages = 1000) { + const all = []; + for (let page = 1; page <= maxPages; page++) { + const items = await fetchPage(page, limit); + all.push(...items); + if (items.length < limit) return all; + } + throw new Error(`the listing did not end within ${maxPages} pages`); +} + +/** + * Run `restore` when the process ends by any route a supervisor or a terminal takes: a normal + * exit, Ctrl-C, a kill or a timeout (SIGTERM) and a closed terminal (SIGHUP). SIGTERM was missing, + * so a killed run left every `package.json`, pom and `pyproject.toml` bumped. + */ +export function installRestoreHandlers(restore, proc = process) { + proc.on("exit", restore); + for (const [signal, code] of [["SIGINT", 130], ["SIGTERM", 143], ["SIGHUP", 129]]) { + proc.on(signal, () => { + restore(); + proc.exit(code); + }); + } +} diff --git a/scripts/prerelease.mjs b/scripts/prerelease.mjs index c6b5aac9c..2788902d7 100644 --- a/scripts/prerelease.mjs +++ b/scripts/prerelease.mjs @@ -9,9 +9,11 @@ // bun run prerelease:publish --iter 5 # pin the iteration number // bun run prerelease:publish --base 0.25.0 # override the in-development version // -// The base version is read from CHANGELOG.md's topmost `## [x.y.z]` header — the -// repository's own declaration of what is being worked on — falling back to minor+1 when -// that entry is already released. `--base` overrides both. +// The base version is the lowest CHANGELOG.md `## [x.y.z]` section that is not released yet — +// the repository's own declaration of what is being worked on — falling back to minor+1 when +// every section is released. "Released" is read from public npm and the release tags, never +// from package.json (release prep bumps that first). `--base` may override, but never to skip +// an unpublished section. // bun run prerelease:publish --dry-run # build + normalize + gate, publish nothing // // Registry address and token come from tools/prerelease/registry.env (gitignored) or the @@ -59,6 +61,9 @@ import { execSync, execFileSync } from "node:child_process"; import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from "node:fs"; import { join } from "node:path"; import { mavenVersion } from "./maven-coordinate.mjs"; +import { + changelogVersions, collectPages, decideBase, installRestoreHandlers, releasedVersion, +} from "./prerelease-version.mjs"; const argv = process.argv.slice(2); const flag = (n, d) => { const i = argv.indexOf(`--${n}`); return i === -1 ? d : argv[i + 1]; }; @@ -122,56 +127,58 @@ const NUGET_SRC = assertTarget(`${BASE}/api/packages/${OWNER}/nuget/index.json`, const MAVEN_URL = assertTarget(`${BASE}/api/packages/${OWNER}/maven`, "maven"); // ── version scheme ──────────────────────────────────────────────────────────────────── +// What the repository says it is: the lockstep set is every package carrying this version. const cliPkg = JSON.parse(readFileSync(join(ROOT, "server/typescript/packages/cli/package.json"), "utf8")); -const RELEASED = cliPkg.version; +const COMMITTED = cliPkg.version; -const cmpVer = (a, b) => { - const [x, y] = [a, b].map((v) => v.split(".").map(Number)); - return x[0] - y[0] || x[1] - y[1] || x[2] - y[2]; +/** Public npm's `latest` for the CLI, or null when it cannot be read. */ +const npmLatest = async () => { + try { + const r = await fetch("https://registry.npmjs.org/@metaobjectsdev%2fcli", + { headers: { accept: "application/vnd.npm.install-v1+json" }, signal: AbortSignal.timeout(10_000) }); + return r.ok ? ((await r.json())["dist-tags"] || {}).latest ?? null : null; + } catch { return null; } }; /** - * The version this cut is FOR, read from the repository's own declaration of it. - * - * `package.json` still carries the last RELEASED version — versions bump at release time — - * so it cannot answer "what is being worked on". CHANGELOG.md can: its topmost `## [x.y.z]` - * header is written when the work lands, ahead of the bump. Guessing minor+1 instead had a - * standing cost, because a PATCH line is a normal outcome (0.23.3 after 0.23.2): the tool - * derived 0.24.0 while the changelog said 0.23.3, and every single invocation needed - * `--base` to talk it out of the wrong answer. One forgotten flag burns a version number on - * the registry, permanently. - * - * Falls back to minor+1 when the changelog's top entry is already released — i.e. nobody - * has declared the next version yet, which is exactly the case the old guess was written - * for. `--base` still overrides both. + * What is RELEASED, read from where a release leaves its mark: public npm and the `v` + * tags. Through 1.1.0's release candidates this was `package.json`, which release preparation + * bumps BEFORE anything is published, so the CHANGELOG's `[1.1.0]` looked shipped and every rc + * was cut as 1.2.0 unless `--base` was passed. See scripts/prerelease-version.mjs. */ -const declaredBase = (() => { - const m = readFileSync(join(ROOT, "CHANGELOG.md"), "utf8").match(/^## \[(\d+\.\d+\.\d+)\]/m); - return m && cmpVer(m[1], RELEASED) > 0 ? m[1] : null; -})(); -const BASE_SOURCE = flag("base", null) !== null ? "--base" - : declaredBase !== null ? "CHANGELOG.md" - : `guessed from released ${RELEASED}`; -const BASEVER = flag("base", declaredBase ?? (() => { - const [maj, min] = RELEASED.split(".").map(Number); - return `${maj}.${min + 1}.0`; -})()); -if (!/^\d+\.\d+\.\d+$/.test(BASEVER)) die(`--base must be a plain x.y.z, got ${BASEVER}`); +const RELEASED = releasedVersion({ + npmLatest: await npmLatest(), + tags: sh("git tag --list 'v*'").split("\n"), + major: Number(COMMITTED.split(".")[0]), +}); + +let decided; +try { + decided = decideBase({ + released: RELEASED, + changelog: changelogVersions(readFileSync(join(ROOT, "CHANGELOG.md"), "utf8")), + explicit: flag("base", null), + }); +} catch (e) { die(e.message); } +const BASEVER = decided.base; +const BASE_SOURCE = decided.source; // The lockstep package names, needed before the iteration number is chosen. const LOCKSTEP = sh("ls -d server/typescript/packages/*/ client/web/packages/*/").trim().split("\n") .map((d) => d.replace(/\/$/, "")) .map((dir) => ({ dir, pkg: JSON.parse(readFileSync(join(ROOT, dir, "package.json"), "utf8")) })) - .filter(({ pkg }) => !pkg.private && pkg.version === RELEASED); + .filter(({ pkg }) => !pkg.private && pkg.version === COMMITTED); const ITER_RE = new RegExp(`^\\d+\\.${BASEVER.split(".").slice(1).join("\\.")}(?:-rc\\.|rc)(\\d+)$`); // Iteration: explicit, else 1 + the highest already taken — across ALL FOUR ecosystems on // the pre-release registry, so `--only npm` today and `--only csharp` tomorrow cannot -// collide (a used number is a hard 409 on every one of them). -const usedIters = () => { +// collide (a used number is a hard 409 on every one of them). Every page: the registry serves +// 50 per page whatever `limit` asks, and a used number past the first page was invisible. +const usedIters = async () => { try { - const list = JSON.parse(sh(`curl -fsS --max-time 10 -u "${OWNER}:${TOKEN}" "${BASE}/api/v1/packages/${OWNER}?limit=1000"`)); + const list = await collectPages((page, limit) => + JSON.parse(sh(`curl -fsS --max-time 10 -u "${OWNER}:${TOKEN}" "${BASE}/api/v1/packages/${OWNER}?page=${page}&limit=${limit}"`))); return list.map((p) => (p.version.match(ITER_RE) || [])[1]).filter(Boolean).map(Number); } catch { return []; } }; @@ -198,8 +205,8 @@ const burnedPublicIters = async () => { const explicitIter = flag("iter", null); const burned = await burnedPublicIters(); -const ITER = Number(explicitIter ?? (() => { - const taken = [...usedIters(), ...burned]; +const ITER = Number(explicitIter ?? await (async () => { + const taken = [...await usedIters(), ...burned]; return taken.length ? Math.max(...taken) + 1 : 1; })()); if (!Number.isInteger(ITER) || ITER < 1) die(`--iter must be a positive integer, got ${ITER}`); @@ -232,7 +239,7 @@ const wants = (p) => ONLY.includes(p); console.log(`\n── pre-release ${CANON}${DRY ? " (DRY RUN)" : ""} → ${expectedHost} ──\n`); // Name where the base came from. A version number is spent permanently the moment it is // published, so the one value worth reading before the run is the one that was inferred. -info(`released: ${RELEASED} base: ${BASEVER} (from ${BASE_SOURCE}) iteration: ${ITER}`); +info(`released: ${RELEASED ?? "unknown"} committed: ${COMMITTED} base: ${BASEVER} (from ${BASE_SOURCE}) iteration: ${ITER}`); info(`npm ${V.npm} · pypi ${V.pypi} · nuget ${V.nuget} · maven ${V.maven}`); info(`ports: ${ONLY.join(", ")}\n`); ok(`publish target verified: ${expectedHost} (configured registry; not a public one)`); @@ -272,8 +279,7 @@ const restore = () => { if (!touched.length) return; try { sh(`git checkout -- ${touched.map((f) => `'${f}'`).join(" ")}`); } catch {} }; -process.on("exit", restore); -process.on("SIGINT", () => { restore(); process.exit(130); }); +installRestoreHandlers(restore); const scratch = (name) => { const d = join(SCRATCH, name); rmSync(d, { recursive: true, force: true }); mkdirSync(d, { recursive: true }); return d; }; // Kept between runs (so Maven does not re-download the world every time) and deliberately diff --git a/scripts/test-prerelease-version.mjs b/scripts/test-prerelease-version.mjs new file mode 100644 index 000000000..2468f8a8b --- /dev/null +++ b/scripts/test-prerelease-version.mjs @@ -0,0 +1,117 @@ +#!/usr/bin/env node +// Tests for scripts/prerelease-version.mjs — the decisions that number a pre-release cut. +// +// node scripts/test-prerelease-version.mjs +// +// P1 "released" comes from public npm and the release tags, not package.json, and a cut never +// skips an unpublished CHANGELOG section (release prep bumped package.json to 1.1.0 and the +// next rc went out as 1.2.0-rc.3). +// P2 the iteration counter reads every page of the registry's listing. +// P3 a SIGTERM restores the version files, as exit and SIGINT already did. +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { + changelogVersions, collectPages, decideBase, latestReleaseTag, releasedVersion, +} from "./prerelease-version.mjs"; + +let fails = 0; +const test = async (name, fn) => { + try { await fn(); console.log(`ok: ${name}`); } + catch (e) { fails++; console.error(`FAIL: ${name}\n ${e.message}`); } +}; + +const CHANGELOG = "# Changelog\n\n## [Unreleased]\n\n## [1.1.0] — 2026-10-10\n\n## [1.0.13] — 2026-10-03\n\n## [1.0.12]\n"; + +await test("changelog sections are read in order, [Unreleased] skipped", () => { + assert.deepEqual(changelogVersions(CHANGELOG), ["1.1.0", "1.0.13", "1.0.12"]); +}); + +await test("the release tag is the highest plain tag on the npm major line", () => { + assert.equal(latestReleaseTag(["v1.0.9", "v1.0.13", "v1.1.0-rc.3", "v7.20.12", "v1.0.10"], 1), "1.0.13"); + assert.equal(latestReleaseTag(["v7.20.12"], 1), null); +}); + +await test("P1: release prep's package.json bump does not make 1.1.0 'released'", () => { + // package.json says 1.1.0 (the prep PR); npm and the tags say 1.0.13. + const released = releasedVersion({ npmLatest: "1.0.13", tags: ["v1.0.13", "v1.1.0-rc.2"], major: 1 }); + assert.equal(released, "1.0.13"); + assert.deepEqual(decideBase({ released, changelog: changelogVersions(CHANGELOG), explicit: null }), + { base: "1.1.0", source: "CHANGELOG.md" }); +}); + +await test("released is the newer of npm and the tags (either may lag)", () => { + assert.equal(releasedVersion({ npmLatest: "1.0.12", tags: ["v1.0.13"], major: 1 }), "1.0.13"); + assert.equal(releasedVersion({ npmLatest: "1.0.13", tags: [], major: 1 }), "1.0.13"); + assert.equal(releasedVersion({ npmLatest: null, tags: [], major: 1 }), null); +}); + +await test("once 1.1.0 is released, the next minor is guessed", () => { + assert.deepEqual(decideBase({ released: "1.1.0", changelog: ["1.1.0", "1.0.13"], explicit: null }), + { base: "1.2.0", source: "guessed from released 1.1.0" }); +}); + +await test("a patch section ahead of the release is the base, not the next minor", () => { + assert.equal(decideBase({ released: "1.0.13", changelog: ["1.0.14", "1.0.13"], explicit: null }).base, "1.0.14"); +}); + +await test("--base may not skip an unpublished CHANGELOG section", () => { + assert.throws(() => decideBase({ released: "1.0.13", changelog: ["1.1.0", "1.0.13"], explicit: "1.2.0" }), + /would skip the unpublished CHANGELOG section \[1\.1\.0\]/); + assert.equal(decideBase({ released: "1.0.13", changelog: ["1.1.0"], explicit: "1.1.0" }).base, "1.1.0"); +}); + +await test("--base may not name a released version", () => { + assert.throws(() => decideBase({ released: "1.1.0", changelog: ["1.1.0"], explicit: "1.1.0" }), /not newer/); +}); + +await test("with nothing to read, only an explicit --base proceeds", () => { + assert.throws(() => decideBase({ released: null, changelog: ["1.1.0"], explicit: null }), /Pass --base/); + assert.equal(decideBase({ released: null, changelog: ["1.1.0"], explicit: "1.1.0" }).base, "1.1.0"); +}); + +await test("P2: every page is read, and the walk stops at the first short page", async () => { + const items = Array.from({ length: 352 }, (_, i) => i); + const asked = []; + const all = await collectPages((page, limit) => { + asked.push(page); + return items.slice((page - 1) * limit, page * limit); + }, 50); + assert.equal(all.length, 352); + assert.deepEqual(asked, [1, 2, 3, 4, 5, 6, 7, 8]); + // An exact multiple ends on the empty page that follows it. + const exact = await collectPages((page, limit) => items.slice(0, 100).slice((page - 1) * limit, page * limit), 50); + assert.equal(exact.length, 100); +}); + +await test("P3: SIGTERM runs the restore before the process ends", async () => { + const dir = mkdtempSync(join(tmpdir(), "prerelease-sigterm-")); + try { + const marker = join(dir, "restored"); + const child = join(dir, "child.mjs"); + const mod = pathToFileURL(join(import.meta.dirname, "prerelease-version.mjs")).href; + writeFileSync(child, ` + import { writeFileSync } from "node:fs"; + import { installRestoreHandlers } from ${JSON.stringify(mod)}; + installRestoreHandlers(() => writeFileSync(${JSON.stringify(marker)}, "yes")); + console.log("ready"); + setInterval(() => {}, 1000); + `); + const proc = spawn(process.execPath, [child], { stdio: ["ignore", "pipe", "inherit"] }); + await new Promise((resolve) => proc.stdout.once("data", resolve)); + const exited = new Promise((resolve) => proc.on("exit", (code, signal) => resolve({ code, signal }))); + proc.kill("SIGTERM"); + const { code } = await exited; + assert.equal(code, 143); + assert.ok(existsSync(marker), "restore did not run on SIGTERM"); + assert.equal(readFileSync(marker, "utf8"), "yes"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +if (fails) { console.error(`${fails} failure(s)`); process.exit(1); } +console.log("all prerelease-version tests passed"); diff --git a/server/csharp/MetaObjects.Cli.Tests/AgentContextStalenessTests.cs b/server/csharp/MetaObjects.Cli.Tests/AgentContextStalenessTests.cs index 9ce7bc3ce..2772b2b4c 100644 --- a/server/csharp/MetaObjects.Cli.Tests/AgentContextStalenessTests.cs +++ b/server/csharp/MetaObjects.Cli.Tests/AgentContextStalenessTests.cs @@ -66,6 +66,16 @@ public void Any_drift_nudges_no_semver_parsing() Assert.NotNull(AgentContextScaffold.AgentContextStaleness(m, "1.2.3-rc.1")); } + // Build metadata is not part of the version. The .NET SDK stamps the source revision into + // this tool's informational version; the manifest the Node side writes carries none. + [Fact] + public void Build_metadata_alone_is_in_sync() + { + Assert.Null(AgentContextScaffold.AgentContextStaleness(ManifestWith("1.1.0-rc.3"), "1.1.0-rc.3+0affc6adf1234")); + Assert.Null(AgentContextScaffold.AgentContextStaleness(ManifestWith("1.1.0+abc"), "1.1.0+def")); + Assert.NotNull(AgentContextScaffold.AgentContextStaleness(ManifestWith("1.1.0-rc.2"), "1.1.0-rc.3+0affc6adf1234")); + } + // ---- 2. version source -------------------------------------------------- [Fact] diff --git a/server/csharp/MetaObjects.Cli.Tests/ColumnNamingFlagTests.cs b/server/csharp/MetaObjects.Cli.Tests/ColumnNamingFlagTests.cs index bcf4e96b6..179e25bec 100644 --- a/server/csharp/MetaObjects.Cli.Tests/ColumnNamingFlagTests.cs +++ b/server/csharp/MetaObjects.Cli.Tests/ColumnNamingFlagTests.cs @@ -1,3 +1,4 @@ +using MetaObjects.Codegen; using Xunit; namespace MetaObjects.Cli.Tests; @@ -109,4 +110,86 @@ public void An_unknown_strategy_is_a_usage_error_naming_the_valid_values() Assert.Equal(2, exit); Assert.Contains("snake_case", stdout + stderr); } + + // ---- ColumnNaming declared on the list (ADR-0034 Amendment 9: inputs live in the list) ---- + + private GeneratorList ListWith(ColumnNamingStrategy? naming) => new() + { + ColumnNaming = naming, + Entries = OwnedRunner.Declared(_tmp, "entity", "names"), + }; + + private (int Exit, string Out) Gen(IReadOnlyList list, string outDir, params string[] extra) + { + var (exit, stdout, stderr) = OwnedRunner.Run( + _tmp, list, ["gen", MetaDir, "--out", outDir, "--namespace", "Acme.Generated", .. extra]); + return (exit, stdout + stderr); + } + + private (int Exit, string Out) Verify(IReadOnlyList list, string outDir, params string[] extra) + { + var (exit, stdout, stderr) = OwnedRunner.Run(_tmp, list, ["verify", "--codegen", MetaDir, "--out", outDir, .. extra]); + return (exit, stdout + stderr); + } + + [Fact] + public void A_list_that_declares_snake_case_generates_snake_case_with_no_flag_and_verify_agrees() + { + var outDir = Path.Combine(_tmp, "generated-list"); + var list = ListWith(ColumnNamingStrategy.SnakeCase); + var (exit, output) = Gen(list, outDir); + Assert.True(exit == 0, output); + Assert.Contains("public const string CreatedAtColumn = \"created_at\";", + File.ReadAllText(Path.Combine(outDir, "SubscriberNames.g.cs"))); + + var (verifyExit, verifyOut) = Verify(list, outDir); + Assert.True(verifyExit == 0, verifyOut); + Assert.Contains("OK (generated output is in sync)", verifyOut); + } + + [Fact] + public void A_flag_that_agrees_with_the_list_is_accepted_and_one_that_contradicts_it_is_refused() + { + var list = ListWith(ColumnNamingStrategy.SnakeCase); + Assert.Equal(0, Gen(list, Path.Combine(_tmp, "agree"), "--column-naming", "snake_case").Exit); + + var outDir = Path.Combine(_tmp, "contradict"); + var (exit, output) = Gen(list, outDir, "--column-naming", "literal"); + Assert.Equal(2, exit); + Assert.Contains( + "--column-naming literal contradicts ColumnNaming = ColumnNamingStrategy.SnakeCase in codegen/Generators.cs", + output); + Assert.False(Directory.Exists(outDir)); + Assert.Equal(2, Verify(list, Path.Combine(_tmp, "agree"), "--column-naming", "literal").Exit); + } + + [Fact] + public void Verify_with_the_wrong_naming_says_the_naming_differs_instead_of_regenerate() + { + // Committed with snake_case; the plain verify runs the default, literal. + var outDir = Path.Combine(_tmp, "generated-snake"); + Assert.Equal(0, Gen(ListWith(null), outDir, "--column-naming", "snake_case").Exit); + + var (exit, output) = Verify(ListWith(null), outDir); + Assert.Equal(1, exit); + Assert.Contains( + "The committed output was generated with column naming snake_case; this run used literal (the default). " + + "Do not regenerate", + output); + Assert.Contains("ColumnNaming = ColumnNamingStrategy.SnakeCase on the GeneratorList in codegen/Generators.cs", output); + Assert.DoesNotContain("Run 'dotnet meta gen' to regenerate", output); + } + + [Fact] + public void Real_drift_still_says_regenerate() + { + var outDir = Path.Combine(_tmp, "generated-edited"); + Assert.Equal(0, Gen(ListWith(ColumnNamingStrategy.SnakeCase), outDir).Exit); + File.AppendAllText(Path.Combine(outDir, "SubscriberNames.g.cs"), "// edited\n"); + + var (exit, output) = Verify(ListWith(ColumnNamingStrategy.SnakeCase), outDir); + Assert.Equal(1, exit); + Assert.Contains("Run 'dotnet meta gen' to regenerate", output); + Assert.DoesNotContain("column naming", output); + } } diff --git a/server/csharp/MetaObjects.Cli.Tests/EjectCommandTests.cs b/server/csharp/MetaObjects.Cli.Tests/EjectCommandTests.cs index 228711e9d..872d7eb79 100644 --- a/server/csharp/MetaObjects.Cli.Tests/EjectCommandTests.cs +++ b/server/csharp/MetaObjects.Cli.Tests/EjectCommandTests.cs @@ -273,6 +273,19 @@ public void Ejected_copy_is_content_identical_to_the_rewritten_reference() Assert.Equal(expected, File.ReadAllText(copyPath)); } + // `--list` gave stable names only, while codegen/Generators.cs holds constructed generators: + // an adopter guessed class names and learned RenderHelperGenerator's argument from CS7036. + [Fact] + public void GenListLines_print_the_entry_to_put_in_the_list_and_its_options() + { + var lines = GenCommand.ListLines(_root); + Assert.Contains(lines, l => l.Contains(" entity —") + && l.Contains("\n in codegen/Generators.cs: new MetaObjects.Codegen.Generators.EntityGenerator()")); + var renderHelper = Assert.Single(lines, l => l.Contains(" render-helper —")); + Assert.Contains("in codegen/Generators.cs: new MetaObjects.Codegen.Generators.RenderHelperGenerator(\"prompts\")", renderHelper); + Assert.Contains("options: templateRoot", renderHelper); + } + [Fact] public void GenListLines_marks_an_unmodified_ejected_copy_identical() { diff --git a/server/csharp/MetaObjects.Cli.Tests/EjectEndToEndTests.cs b/server/csharp/MetaObjects.Cli.Tests/EjectEndToEndTests.cs index 3e6ae9770..d011beade 100644 --- a/server/csharp/MetaObjects.Cli.Tests/EjectEndToEndTests.cs +++ b/server/csharp/MetaObjects.Cli.Tests/EjectEndToEndTests.cs @@ -143,10 +143,10 @@ public void Edited_ejected_copy_changes_gen_output_and_verify_codegen_passes() // Amendment 9): ejecting one generator must not drop the others the project runs. var listPath = Path.Combine(_root, "codegen", "Generators.cs"); var list = File.ReadAllText(listPath); - const string ownedEntry = " new Codegen.Generators.EntityGenerator(),\n"; + const string ownedEntry = " new Codegen.Generators.EntityGenerator(),\n"; Assert.Contains(ownedEntry, list); File.WriteAllText(listPath, list.Replace( - ownedEntry, ownedEntry + " new MetaObjects.Codegen.Generators.NamesGenerator(),\n")); + ownedEntry, ownedEntry + " new MetaObjects.Codegen.Generators.NamesGenerator(),\n")); // 2. Substitute PackageReference -> ProjectReference (see header comment). var codegenProjPath = Path.Combine(repoRoot, "server", "csharp", "MetaObjects.Codegen", "MetaObjects.Codegen.csproj"); diff --git a/server/csharp/MetaObjects.Cli.Tests/OneGeneratorListTests.cs b/server/csharp/MetaObjects.Cli.Tests/OneGeneratorListTests.cs index badb093dc..c9464ef62 100644 --- a/server/csharp/MetaObjects.Cli.Tests/OneGeneratorListTests.cs +++ b/server/csharp/MetaObjects.Cli.Tests/OneGeneratorListTests.cs @@ -165,7 +165,7 @@ public void The_docs_command_prints_the_removal_line_and_exits_2() Assert.False(Directory.Exists(DocsDir)); Assert.Equal( "dotnet meta docs was removed in 1.1.0. `dotnet meta gen` writes documentation now: declare each docs " + - "generator in codegen/Generators.cs. See docs/features/migrations/docs-are-generators.md, section \"C#\".", + "generator in codegen/Generators.cs. See https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/docs-are-generators.md, section \"C#\".", CodegenCli.DocsCommandRemoved); } @@ -184,7 +184,7 @@ public void The_codegen_projects_docs_command_prints_the_same_line() private static readonly string LegacyLine = "dotnet meta gen: found 1.0 docs configuration (--project, --model-base-url). Nothing reads it any more, " + "so this run would write no documentation. Declare each docs generator in codegen/Generators.cs. See " + - "docs/features/migrations/docs-are-generators.md, section \"C#\"."; + "https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/docs-are-generators.md, section \"C#\"."; [Fact] public void Gen_given_1_0_docs_flags_prints_the_amendment_line_and_writes_nothing() @@ -220,7 +220,7 @@ public void Gen_in_a_project_with_no_generator_list_prints_the_amendment_line() Assert.Equal(2, exit); Assert.Equal( "dotnet meta gen: no codegen/Generators.cs. The generator list lives there in 1.1.0, documentation " + - "included; `dotnet meta init` writes it. See docs/features/migrations/docs-are-generators.md, section \"C#\".", + "included; `dotnet meta init` writes it. See https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/docs-are-generators.md, section \"C#\".", stderr.Trim()); Assert.Equal(CodegenCli.NoGeneratorList("dotnet meta gen"), stderr.Trim()); Assert.Empty(FilesUnder(OutDir)); @@ -265,7 +265,8 @@ public void Init_writes_the_three_files_and_keeps_the_ones_that_exist() var list = File.ReadAllText(Path.Combine(_tmp, "codegen", "Generators.cs")); Assert.Equal(CodegenScaffold.GeneratorsCs([]), list); Assert.Contains("public static class Generators", list); - Assert.Contains("public static IReadOnlyList All { get; } =\n [\n ];", list); + Assert.Contains("public static IReadOnlyList All { get; } = new GeneratorList\n", list); + Assert.Contains(" // ColumnNaming = ColumnNamingStrategy.SnakeCase,\n Entries =\n [\n ],\n };", list); Assert.Contains($"Version=\"{EjectCommand.ToolVersion()}\"", File.ReadAllText(Path.Combine(_tmp, "codegen", "Codegen.csproj"))); // A second init keeps every file, edits included. @@ -320,7 +321,7 @@ private void WriteLegacyCodegenProject(bool withGeneratorList) private static string LegacyProgramLine(string command) => $"{command}: codegen/Program.cs does not run Generators.All, so it would ignore the generator list in " + "codegen/Generators.cs. Replace it with the two-line entry `using MetaObjects.Codegen;` and " + - "`return CodegenCli.Run(args, Generators.All);`. See docs/features/migrations/docs-are-generators.md, section \"C#\"."; + "`return CodegenCli.Run(args, Generators.All);`. See https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/docs-are-generators.md, section \"C#\"."; [Fact] public void Gen_with_a_1_0_program_refuses_with_the_migration_line_and_writes_nothing() @@ -358,7 +359,7 @@ public void Init_beside_a_1_0_program_says_to_replace_it_instead_of_claiming_gen Assert.DoesNotContain(lines, l => l.Contains("is the generator list `dotnet meta gen` runs", StringComparison.Ordinal)); Assert.Contains(lines, l => l.StartsWith("codegen/Program.cs does not run Generators.All", StringComparison.Ordinal)); Assert.Contains(" return CodegenCli.Run(args, Generators.All);", lines); - Assert.Contains("See docs/features/migrations/docs-are-generators.md, section \"C#\".", lines); + Assert.Contains("See https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/docs-are-generators.md, section \"C#\".", lines); Assert.Equal(LegacyProgramCs, File.ReadAllText(Path.Combine(_tmp, "codegen", "Program.cs"))); } @@ -374,7 +375,7 @@ public void Eject_beside_a_1_0_program_says_to_replace_it_instead_of_claiming_ge Assert.DoesNotContain(outcome.Out, l => l.Contains("run that list", StringComparison.Ordinal)); Assert.Contains(outcome.Out, l => l.StartsWith("codegen/Program.cs does not run Generators.All", StringComparison.Ordinal)); Assert.Contains(" return CodegenCli.Run(args, Generators.All);", outcome.Out); - Assert.Contains("See docs/features/migrations/docs-are-generators.md, section \"C#\".", outcome.Out); + Assert.Contains("See https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/docs-are-generators.md, section \"C#\".", outcome.Out); Assert.Equal(LegacyProgramCs, File.ReadAllText(Path.Combine(_tmp, "codegen", "Program.cs"))); } diff --git a/server/csharp/MetaObjects.Cli/GenCommand.cs b/server/csharp/MetaObjects.Cli/GenCommand.cs index 023172b14..abcaaed96 100644 --- a/server/csharp/MetaObjects.Cli/GenCommand.cs +++ b/server/csharp/MetaObjects.Cli/GenCommand.cs @@ -185,7 +185,8 @@ public static Outcome Run( /// /// The lines `dotnet meta gen --list` prints: one `<stable-name> — <description>` - /// per registered generator, native first, marked `[owned — identical]` / `[owned — + /// per registered generator, native first, each followed by the expression that lists it + /// in codegen/Generators.cs () and its options, marked `[owned — identical]` / `[owned — /// DIFFERS: N behind, M of your own]` when already has an /// ejected copy (), plus one line for an owned helper /// runtime copy () when there is one. Pure (no @@ -199,7 +200,9 @@ public static IReadOnlyList ListLines(string cwd) var owned = OwnedCopy.Status(cwd, e); var ownedMark = owned is not null ? $" [owned — {owned}]" : ""; var requires = e.Requires.Count > 0 ? $" (requires: {string.Join(", ", e.Requires)})" : ""; - return $" {e.Name} — {e.Description}" + requires + (e.Note is not null ? $" [{e.Note}]" : "") + ownedMark; + var options = e.Options is not null ? $"\n options: {e.Options}" : ""; + return $" {e.Name} — {e.Description}" + requires + (e.Note is not null ? $" [{e.Note}]" : "") + ownedMark + + $"\n in codegen/Generators.cs: {GeneratorRegistry.ListEntry(e)}" + options; }) .ToList(); // The helper runtime an ejected routes generator brought with it is owned code too, diff --git a/server/csharp/MetaObjects.Cli/InitCommand.cs b/server/csharp/MetaObjects.Cli/InitCommand.cs index 42b1145f3..517a1e296 100644 --- a/server/csharp/MetaObjects.Cli/InitCommand.cs +++ b/server/csharp/MetaObjects.Cli/InitCommand.cs @@ -54,7 +54,7 @@ public static IReadOnlyList ReplaceProgram() => "codegen/Program.cs does not run Generators.All, so `dotnet meta gen` and `dotnet meta verify --codegen` " + "refuse until it does. It is yours, so init and eject never edit it: replace it with the two-line entry", .. ProgramCs.TrimEnd('\n').Split('\n').Select(l => " " + l), - "See docs/features/migrations/docs-are-generators.md, section \"C#\".", + "See https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/docs-are-generators.md, section \"C#\".", ]; /// @@ -64,7 +64,7 @@ .. ProgramCs.TrimEnd('\n').Split('\n').Select(l => " " + l), /// public static string GeneratorsCs(IEnumerable entries) { - var lines = string.Concat(entries.Select(e => $" {e},\n")); + var lines = string.Concat(entries.Select(e => $" {e},\n")); return "// The generator list (ADR-0034 Amendment 9). `dotnet meta gen` runs exactly these entries and\n" + "// `dotnet meta verify --codegen` regenerates them and compares; nothing else is generated,\n" + @@ -72,19 +72,27 @@ public static string GeneratorsCs(IEnumerable entries) "// `dotnet meta gen --list` is the catalog. `dotnet meta eject ` copies a generator into\n" + "// codegen/generators/ to own; list the copy (Codegen.Generators.) in place of the\n" + "// packaged one. `dotnet meta gen --generators ` narrows a run to the entries named.\n" + + "// ColumnNaming applies to the whole run: set it to the naming your database uses, and gen and\n" + + "// verify --codegen both read it here, so no command line has to carry --column-naming.\n" + "//\n" + "// For example:\n" + "// new MetaObjects.Codegen.Generators.EntityGenerator(),\n" + "// new MetaObjects.Codegen.Generators.DbContextGenerator(),\n" + - "// new MetaObjects.Codegen.Generators.ApiDocsGenerator { OutDir = \"docs/generated\", ProjectName = \"shop\" },\n" + + "// new MetaObjects.Codegen.Generators.RenderHelperGenerator(\"prompts\"), // the template root\n" + + "// new MetaObjects.Codegen.Generators.ApiDocsGenerator { OutDir = \"docs/csharp\", ProjectName = \"shop\" },\n" + "using MetaObjects.Codegen;\n" + "\n" + "public static class Generators\n" + "{\n" + - " public static IReadOnlyList All { get; } =\n" + - " [\n" + + " public static IReadOnlyList All { get; } = new GeneratorList\n" + + " {\n" + + " // How a field with no @column becomes a column name: Literal (the default), SnakeCase or KebabCase.\n" + + " // ColumnNaming = ColumnNamingStrategy.SnakeCase,\n" + + " Entries =\n" + + " [\n" + lines + - " ];\n" + + " ],\n" + + " };\n" + "}\n"; } diff --git a/server/csharp/MetaObjects.Codegen.Tests/GeneratorListEntryTests.cs b/server/csharp/MetaObjects.Codegen.Tests/GeneratorListEntryTests.cs new file mode 100644 index 000000000..75b855ce8 --- /dev/null +++ b/server/csharp/MetaObjects.Codegen.Tests/GeneratorListEntryTests.cs @@ -0,0 +1,77 @@ +// `dotnet meta gen --list` prints, for every generator, the C# expression that lists it in +// codegen/Generators.cs (GeneratorRegistry.ListEntry). The stable name alone did not say which +// class to construct, and one class (RenderHelperGenerator) takes a constructor argument no +// document showed, so an adopter learned it from a compile error. These tests compile a +// Generators.cs made of exactly the printed expressions, so a wrong class name or a missing +// argument fails here instead of in an adopter's build. + +using System.Reflection; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using MetaObjects.Codegen; +using MetaObjects.Meta; +using Xunit; + +namespace MetaObjects.Codegen.Tests; + +public sealed class GeneratorListEntryTests +{ + /// The `template` primitive is built from a walk and a template the project writes, + /// so its printed form names the parameters rather than values that compile. + private const string TemplatePrimitive = "template"; + + [Fact] + public void Every_printed_entry_compiles_and_builds_the_generator_its_stable_name_names() + { + var entries = GeneratorRegistry.List().Where(e => e.Name != TemplatePrimitive).ToList(); + var source = + "using MetaObjects.Codegen;\n" + + "public static class Generators\n{\n" + + " public static System.Collections.Generic.IReadOnlyList All { get; } =\n [\n" + + string.Concat(entries.Select(e => $" {GeneratorRegistry.ListEntry(e)},\n")) + + " ];\n}\n"; + + var all = (IReadOnlyList)Compile(source).GetType("Generators")! + .GetProperty("All", BindingFlags.Public | BindingFlags.Static)!.GetValue(null)!; + + Assert.Equal( + entries.Select(e => e.Factory(new GeneratorBuildContext()).Name), + all.Select(g => g.Name)); + } + + [Fact] + public void The_render_helper_entry_shows_its_template_root_argument() + { + var entry = GeneratorRegistry.Get("render-helper")!; + Assert.Equal("new MetaObjects.Codegen.Generators.RenderHelperGenerator(\"prompts\")", GeneratorRegistry.ListEntry(entry)); + Assert.Contains("templateRoot", entry.Options); + } + + [Fact] + public void A_plain_entry_is_its_full_class_name() + { + Assert.Equal("new MetaObjects.Codegen.Generators.EntityGenerator()", GeneratorRegistry.ListEntry(GeneratorRegistry.Get("entity")!)); + } + + private static Assembly Compile(string source) + { + var refs = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!) + .Split(Path.PathSeparator).Where(p => p.Length > 0) + .Select(p => (MetadataReference)MetadataReference.CreateFromFile(p)).ToList(); + refs.Add(MetadataReference.CreateFromFile(typeof(IGenerator).Assembly.Location)); + refs.Add(MetadataReference.CreateFromFile(typeof(MetaObject).Assembly.Location)); + refs.Add(MetadataReference.CreateFromFile(typeof(MetaObjects.Render.Extract.ExtractSchema).Assembly.Location)); + + var comp = CSharpCompilation.Create( + "generator_list_" + Guid.NewGuid().ToString("N"), + [CSharpSyntaxTree.ParseText(source, new CSharpParseOptions(LanguageVersion.CSharp12))], + refs, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + using var ms = new MemoryStream(); + var emit = comp.Emit(ms); + var errors = emit.Diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error) + .Select(d => $"{d.Id}: {d.GetMessage()}").ToList(); + Assert.True(errors.Count == 0, "the printed Generators.cs entries should compile, got: " + string.Join("; ", errors)); + return Assembly.Load(ms.ToArray()); + } +} diff --git a/server/csharp/MetaObjects.Codegen/CodegenCli.cs b/server/csharp/MetaObjects.Codegen/CodegenCli.cs index 664c04cd4..185128104 100644 --- a/server/csharp/MetaObjects.Codegen/CodegenCli.cs +++ b/server/csharp/MetaObjects.Codegen/CodegenCli.cs @@ -15,6 +15,7 @@ using MetaObjects.Config; using MetaObjects.Source; using MetaObjects.Loader; +using MetaObjects.Meta; using MetaObjects.Render; namespace MetaObjects.Codegen; @@ -264,7 +265,7 @@ public static GenOutcome RunGen( /// What dotnet meta docs prints, exit 2: the command was removed. public const string DocsCommandRemoved = "dotnet meta docs was removed in 1.1.0. `dotnet meta gen` writes documentation now: declare each docs " + - "generator in codegen/Generators.cs. See docs/features/migrations/docs-are-generators.md, section \"C#\"."; + "generator in codegen/Generators.cs. See https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/docs-are-generators.md, section \"C#\"."; /// The flags a 1.0 project passed to gen for documentation. Nothing reads them in 1.1.0; /// they are 's init properties now. @@ -276,13 +277,13 @@ public static GenOutcome RunGen( public static string LegacyDocsConfiguration(string command, IEnumerable flags) => $"{command}: found 1.0 docs configuration ({string.Join(", ", flags)}). Nothing reads it any more, so this " + "run would write no documentation. Declare each docs generator in codegen/Generators.cs. See " + - "docs/features/migrations/docs-are-generators.md, section \"C#\"."; + "https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/docs-are-generators.md, section \"C#\"."; /// What a gen run in a project with no codegen/Generators.cs prints, exit 2. /// The command's own name: dotnet meta gen or dotnet meta verify --codegen. public static string NoGeneratorList(string command) => $"{command}: no codegen/Generators.cs. The generator list lives there in 1.1.0, documentation included; " + - "`dotnet meta init` writes it. See docs/features/migrations/docs-are-generators.md, section \"C#\"."; + "`dotnet meta init` writes it. See https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/docs-are-generators.md, section \"C#\"."; /// What a gen or verify --codegen run prints, exit 2, when codegen/Program.cs does not /// run Generators.All: a 1.0 entry point declares its generators inline and would ignore the list. @@ -290,7 +291,7 @@ public static string NoGeneratorList(string command) => public static string LegacyProgram(string command) => $"{command}: codegen/Program.cs does not run Generators.All, so it would ignore the generator list in " + "codegen/Generators.cs. Replace it with the two-line entry `using MetaObjects.Codegen;` and " + - "`return CodegenCli.Run(args, Generators.All);`. See docs/features/migrations/docs-are-generators.md, section \"C#\"."; + "`return CodegenCli.Run(args, Generators.All);`. See https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/docs-are-generators.md, section \"C#\"."; /// What a gen run prints, exit 2, when it finds 1.0 template-spec configuration: a /// project-root template-spec.json no entry declares, or --template-spec. Through @@ -302,7 +303,7 @@ public static string LegacyTemplateSpec(string command, string what) => $"{command}: found 1.0 template-spec configuration ({what}). Nothing reads it any more, so this run " + "would not render its templates. Declare it in codegen/Generators.cs as " + "`new MetaObjects.Codegen.TemplateCodegen.TemplateSpecGenerator { Spec = \"template-spec.json\" }`. " + - "See docs/features/migrations/docs-are-generators.md, section \"C#\"."; + "See https://github.com/metaobjectsdev/metaobjects/blob/main/docs/features/migrations/docs-are-generators.md, section \"C#\"."; /// /// The 1.0 template-spec configuration a run would have read and no longer does, or null: @@ -455,6 +456,62 @@ private static bool TryParseColumnNaming(string raw, out ColumnNamingStrategy st } } + /// The --column-naming spelling of . + public static string ColumnNamingFlag(ColumnNamingStrategy strategy) => strategy switch + { + ColumnNamingStrategy.SnakeCase => "snake_case", + ColumnNamingStrategy.KebabCase => "kebab-case", + _ => "literal", + }; + + /// Where a run's column naming came from, so the messages that name it can say so. + private enum NamingSource { Default, Flag, List } + + private static string Describe(ColumnNamingStrategy strategy, NamingSource source) => source switch + { + NamingSource.List => $"{ColumnNamingFlag(strategy)} (ColumnNaming in {GeneratorListPath})", + NamingSource.Flag => $"{ColumnNamingFlag(strategy)} (--column-naming)", + _ => $"{ColumnNamingFlag(strategy)} (the default)", + }; + + /// + /// The run's column naming: when the list declares + /// it, else --column-naming, else . Returns + /// the refusal to print (exit 2) for an unknown flag value, or for a flag that contradicts + /// the list: one declaration, never two that silently disagree. + /// + private static string? ResolveColumnNaming( + IReadOnlyList declared, string? raw, out ColumnNamingStrategy strategy, out NamingSource source) + { + strategy = ColumnNamingStrategy.Literal; + source = NamingSource.Default; + ColumnNamingStrategy? flag = null; + if (raw is not null) + { + // An unknown value is a usage error, never a silent fallback to the default — a + // typo'd strategy would otherwise generate a whole entity tree bound to the wrong columns. + if (!TryParseColumnNaming(raw, out var parsed)) + return $"error: unknown --column-naming \"{raw}\"; expected one of: literal, snake_case, kebab-case"; + flag = parsed; + } + if (declared is GeneratorList { ColumnNaming: { } listed }) + { + if (flag is { } given && given != listed) + return $"error: --column-naming {ColumnNamingFlag(given)} contradicts ColumnNaming = " + + $"ColumnNamingStrategy.{listed} in {GeneratorListPath}. The list declares it once; " + + "drop the flag, or change the list."; + strategy = listed; + source = NamingSource.List; + return null; + } + if (flag is { } only) + { + strategy = only; + source = NamingSource.Flag; + } + return null; + } + /// /// The 1.0 composition of an owned list with a --generators selection, which ADDED /// packaged generators the list did not name. @@ -591,15 +648,10 @@ private static int RunGenArgs(string[] rest, IReadOnlyList declared) return 2; } - // How a field with NO explicit `@column` becomes a physical column name. An unknown - // value is a usage error, never a silent fallback to the default — a typo'd strategy - // would otherwise generate a whole entity tree bound to the wrong columns. - var columnNaming = ColumnNamingStrategy.Literal; - if (a.ColumnNamingRaw is not null && !TryParseColumnNaming(a.ColumnNamingRaw, out columnNaming)) + // How a field with NO explicit `@column` becomes a physical column name. + if (ResolveColumnNaming(declared, a.ColumnNamingRaw, out var columnNaming, out _) is { } namingError) { - Console.Error.WriteLine( - $"error: unknown --column-naming \"{a.ColumnNamingRaw}\"; expected one of: " + - "literal, snake_case, kebab-case"); + Console.Error.WriteLine(namingError); return 2; } // Same rule. `fresh` is deliberately NOT accepted — TypeScript implements it and this @@ -683,6 +735,26 @@ private static int RunGenArgs(string[] rest, IReadOnlyList declared) return 0; } + /// The other column naming that reproduces the committed output exactly, or null. + private static ColumnNamingStrategy? MatchingColumnNaming( + GenConfig config, MetaRoot root, IReadOnlyList suite, bool narrowed, ColumnNamingStrategy used) + { + foreach (var other in Enum.GetValues().Where(s => s != used)) + { + var probe = CodegenDrift.Compute(config with { ColumnNamingStrategy = other }, root, suite, narrowed); + if (probe.Error is null && probe.Clean) return other; + } + return null; + } + + /// What verify --codegen says, in place of "regenerate", when the committed output was + /// generated with and this run used . + private static string ColumnNamingDiffers(ColumnNamingStrategy committed, ColumnNamingStrategy used, NamingSource source) => + $"The committed output was generated with column naming {ColumnNamingFlag(committed)}; this run used " + + $"{Describe(used, source)}. Do not regenerate: that rewrites every column name the database does not " + + $"have. Declare the naming once, as ColumnNaming = ColumnNamingStrategy.{committed} on the GeneratorList " + + $"in {GeneratorListPath}, and gen and verify --codegen both read it."; + private static int RunVerifyArgs(string[] rest, IReadOnlyList declared) { var a = OwnedArgs.Parse(rest, VerifyCommandName, VerifyValueFlags, VerifyBoolFlags); @@ -699,10 +771,9 @@ private static int RunVerifyArgs(string[] rest, IReadOnlyList declar return 2; } - var columnNaming = ColumnNamingStrategy.Literal; - if (a.ColumnNamingRaw is not null && !TryParseColumnNaming(a.ColumnNamingRaw, out columnNaming)) + if (ResolveColumnNaming(declared, a.ColumnNamingRaw, out var columnNaming, out var namingSource) is { } namingError) { - Console.Error.WriteLine($"error: unknown --column-naming \"{a.ColumnNamingRaw}\""); + Console.Error.WriteLine(namingError); return 2; } if (a.OutDir is null) @@ -773,6 +844,14 @@ private static int RunVerifyArgs(string[] rest, IReadOnlyList declar } Console.Error.WriteLine($"{VerifyCommandName}: drift ({result.DriftedFiles.Count} file(s) differ from a fresh regen):"); foreach (var line in result.Lines) Console.Error.WriteLine($" {line}"); + // The committed output may be right and this run's column naming wrong: the naming + // reaches every column name, so regenerating would commit the wrong names. When another + // naming reproduces the committed tree exactly, say that instead of "regenerate". + if (MatchingColumnNaming(config, load.Root, suite, a.Selection is { Count: > 0 }, columnNaming) is { } committed) + { + Console.Error.WriteLine(ColumnNamingDiffers(committed, columnNaming, namingSource)); + return 1; + } Console.Error.WriteLine("Run 'dotnet meta gen' to regenerate, then commit the result."); return 1; } diff --git a/server/csharp/MetaObjects.Codegen/GeneratorList.cs b/server/csharp/MetaObjects.Codegen/GeneratorList.cs new file mode 100644 index 000000000..91d00b5cd --- /dev/null +++ b/server/csharp/MetaObjects.Codegen/GeneratorList.cs @@ -0,0 +1,50 @@ +// The project's generator list with the settings that belong to the whole run (ADR-0034 +// Amendment 9: a generator's inputs live in the list, not on a command line that scripts +// carry and a plain `dotnet meta gen` forgets). codegen/Generators.cs declares it: +// +// public static IReadOnlyList All { get; } = new GeneratorList +// { +// ColumnNaming = ColumnNamingStrategy.SnakeCase, +// Entries = +// [ +// new MetaObjects.Codegen.Generators.EntityGenerator(), +// ], +// }; +// +// It IS an IReadOnlyList, so codegen/Program.cs stays the two-line entry +// `return CodegenCli.Run(args, Generators.All);` and a plain collection keeps working. + +using System.Collections; + +namespace MetaObjects.Codegen; + +/// +/// A generator list that also declares run-level settings. reads +/// them from the list it is given, so gen and verify --codegen agree without a +/// flag on either command line. +/// +public sealed class GeneratorList : IReadOnlyList +{ + /// The generators, in the order they run. + public required IReadOnlyList Entries { get; init; } + + /// + /// How a field with no @column becomes a physical column name, for every generator + /// in the run. It must match the database the generated code queries. null leaves it + /// to --column-naming, else . A + /// --column-naming that names a different strategy is refused rather than + /// silently winning or losing. + /// + public ColumnNamingStrategy? ColumnNaming { get; init; } + + /// + public IGenerator this[int index] => Entries[index]; + + /// + public int Count => Entries.Count; + + /// + public IEnumerator GetEnumerator() => Entries.GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); +} diff --git a/server/csharp/MetaObjects.Codegen/GeneratorRegistry.cs b/server/csharp/MetaObjects.Codegen/GeneratorRegistry.cs index 36b82c2e4..fc7444bed 100644 --- a/server/csharp/MetaObjects.Codegen/GeneratorRegistry.cs +++ b/server/csharp/MetaObjects.Codegen/GeneratorRegistry.cs @@ -89,6 +89,13 @@ public sealed record GeneratorRegistryEntry public required Func Factory { get; init; } /// Optional one-line options summary for --list. public string? Options { get; init; } + /// + /// The C# expression that lists this generator in codegen/Generators.cs, when the + /// class name and a parameterless constructor are not the whole story (a constructor + /// argument, or a factory method). null derives it: new <full class name>(). + /// Read it through . + /// + public string? Entry { get; init; } /// Optional note — e.g. pointing neutral entries at their door. public string? Note { get; init; } /// @@ -229,7 +236,8 @@ private static IGenerator RenderHelper(GeneratorBuildContext ctx) => Tier = GeneratorTier.Native, Layer = GeneratorLayer.Capability, Factory = RenderHelper, - Options = "template-root (required when selected)", + Options = "templateRoot: the directory its template refs resolve from (a constructor argument)", + Entry = "new MetaObjects.Codegen.Generators.RenderHelperGenerator(\"prompts\")", Note = "Needs `entity` in the same run: it references each value object's own POCO (ADR-0056).", SourceFileName = "RenderHelperGenerator.cs", // ADR-0056: the value object's own POCO, which EntityGenerator emits. @@ -280,6 +288,7 @@ private static IGenerator RenderHelper(GeneratorBuildContext ctx) => Layer = GeneratorLayer.Capability, Factory = TemplatePrimitive, Options = "name, walk, template, format? (config-only)", + Entry = "MetaObjects.Codegen.Generators.TemplateGenerator.Create(name, template, walk, provider)", }, // FR-015 — per-entity typed EF Core calling method for a callable source // (storedProc / tableFunction). Same stable name as the TS callable @@ -322,6 +331,25 @@ .. all.Where(e => e.Tier == GeneratorTier.Neutral).OrderBy(e => e.Name, StringCo ]; } + /// + /// The C# expression that lists in codegen/Generators.cs: + /// its own , else new <full class name>() + /// of the class its factory builds. --list prints it, because the list holds + /// constructed generators and the stable name alone does not say which class to construct + /// or what its constructor needs. A class with no parameterless constructor must carry an + /// explicit ; this throws for one that does not, + /// and a test constructs every entry through this method so that cannot ship. + /// + public static string ListEntry(GeneratorRegistryEntry entry) + { + if (entry.Entry is not null) return entry.Entry; + var type = entry.Factory(new GeneratorBuildContext()).GetType(); + if (type.GetConstructor(Type.EmptyTypes) is null) + throw new InvalidOperationException( + $"generator \"{entry.Name}\" ({type.FullName}) has no parameterless constructor; give its registry entry an Entry."); + return $"new {type.FullName}()"; + } + /// Resolve an entry by stable id, or null if unknown. public static GeneratorRegistryEntry? Get(string id) => Entries.TryGetValue(id, out var e) ? e : null; diff --git a/server/csharp/MetaObjects/AgentContext/AgentContextScaffold.cs b/server/csharp/MetaObjects/AgentContext/AgentContextScaffold.cs index 197267316..66873ebd8 100644 --- a/server/csharp/MetaObjects/AgentContext/AgentContextScaffold.cs +++ b/server/csharp/MetaObjects/AgentContext/AgentContextScaffold.cs @@ -37,9 +37,13 @@ public sealed record Manifest( { if (manifest is null) return null; // no agent context here → nothing to nudge // Exact-equality FIRST: ANY drift nudges (a re-scaffold is cheap + idempotent). A - // prerelease/build-metadata difference is still a reason to refresh, so this is not a - // semver compare — see ContextIsAheadOfInstall for the ONE case that is exempt. - if (manifest.GeneratedBy == currentVersion) return null; // in sync + // prerelease difference is still a reason to refresh, so this is not a semver compare — + // see ContextIsAheadOfInstall for the ONE case that is exempt. Build metadata is not + // part of the version (semver 2.0.0 §10): the .NET SDK appends the source revision to + // this tool's informational version (`1.1.0-rc.3+0affc6adf…`), while the Node side that + // writes the manifest stamps none, so comparing it nudged on every run with nothing to + // refresh, and re-running agent-docs could never clear it. + if (WithoutBuildMetadata(manifest.GeneratedBy) == WithoutBuildMetadata(currentVersion)) return null; // in sync if (ContextIsAheadOfInstall(manifest.GeneratedBy, currentVersion)) return null; var from = manifest.GeneratedBy ?? "an older MetaObjects"; return @@ -47,6 +51,14 @@ public sealed record Manifest( "Re-run 'npx meta agent-docs' to refresh the .claude/skills docs (it keeps the stack recorded in .metaobjects/)."; } + /// without its +build suffix, or null. + private static string? WithoutBuildMetadata(string? version) + { + if (version is null) return null; + var plus = version.IndexOf('+'); + return plus < 0 ? version : version[..plus]; + } + /// The sentinel a port stamps when it cannot resolve its own installed version. private const string UnresolvedVersion = "0.0.0"; diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/GeneratorRegistry.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/GeneratorRegistry.kt index bfc3c0c03..0f679a0c2 100644 --- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/GeneratorRegistry.kt +++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/GeneratorRegistry.kt @@ -107,6 +107,13 @@ data class GeneratorInfo @JvmOverloads constructor( * generator base) is not listed: it stays in the package (ADR-0034 Amendment 8). */ val ejectWith: List = emptyList(), + /** + * Stable names of the Kotlin generators whose output this one's output references: without + * them in the same ``, the generated code does not compile, and + * `mvn metaobjects:generate` refuses the list. `GeneratorRequiresCompileTest` (in + * `integration-tests-kotlin`) proves each closure compiles over the shared fitness corpus. + */ + val requires: List = emptyList(), ) /** Classpath root every ejectable Kotlin generator's reference source is shipped under. */ @@ -157,6 +164,7 @@ val GENERATOR_REGISTRY: Map = linkedMapOf( layer = GeneratorLayer.API, factory = ::KotlinSpringControllerGenerator, ejectResourcePath = ejectPath("KotlinSpringControllerGenerator"), + requires = listOf("entity", "exposed-table", "filter-allowlist", "relations"), ), "repository" to GeneratorInfo( name = "repository", @@ -165,6 +173,7 @@ val GENERATOR_REGISTRY: Map = linkedMapOf( layer = GeneratorLayer.PERSISTENCE, factory = ::KotlinRepositoryGenerator, ejectResourcePath = ejectPath("KotlinRepositoryGenerator"), + requires = listOf("entity", "exposed-table"), ), "output-parser" to GeneratorInfo( name = "output-parser", @@ -178,6 +187,7 @@ val GENERATOR_REGISTRY: Map = linkedMapOf( factory = ::KotlinOutputParserGenerator, ejectResourcePath = ejectPath("KotlinOutputParserGenerator"), ejectWith = PARSER_EMIT, + requires = listOf("entity"), ), "output-prompt" to GeneratorInfo( name = "output-prompt", @@ -195,6 +205,7 @@ val GENERATOR_REGISTRY: Map = linkedMapOf( layer = GeneratorLayer.CAPABILITY, factory = ::KotlinRenderHelperGenerator, ejectResourcePath = ejectPath("KotlinRenderHelperGenerator"), + requires = listOf("entity"), ), "extractor" to GeneratorInfo( name = "extractor", @@ -204,6 +215,7 @@ val GENERATOR_REGISTRY: Map = linkedMapOf( layer = GeneratorLayer.CAPABILITY, factory = ::KotlinExtractorGenerator, ejectResourcePath = ejectPath("KotlinExtractorGenerator"), + requires = listOf("entity", "output-parser"), ), "filter-allowlist" to GeneratorInfo( name = "filter-allowlist", @@ -228,6 +240,7 @@ val GENERATOR_REGISTRY: Map = linkedMapOf( layer = GeneratorLayer.PERSISTENCE, factory = ::KotlinExposedTableGenerator, ejectResourcePath = ejectPath("KotlinExposedTableGenerator"), + requires = listOf("entity"), ), "relations" to GeneratorInfo( name = "relations", @@ -236,6 +249,7 @@ val GENERATOR_REGISTRY: Map = linkedMapOf( layer = GeneratorLayer.PERSISTENCE, factory = ::KotlinRelationsGenerator, ejectResourcePath = ejectPath("KotlinRelationsGenerator"), + requires = listOf("entity", "exposed-table"), ), "spring-config" to GeneratorInfo( name = "spring-config", @@ -244,6 +258,7 @@ val GENERATOR_REGISTRY: Map = linkedMapOf( layer = GeneratorLayer.API, factory = ::KotlinSpringConfigGenerator, ejectResourcePath = ejectPath("KotlinSpringConfigGenerator"), + requires = listOf("validator"), ), "stored-proc" to GeneratorInfo( name = "stored-proc", @@ -260,6 +275,7 @@ val GENERATOR_REGISTRY: Map = linkedMapOf( layer = GeneratorLayer.API, factory = ::KotlinValidatorGenerator, ejectResourcePath = ejectPath("KotlinValidatorGenerator"), + requires = listOf("exposed-table"), ), "api-docs" to GeneratorInfo( name = "api-docs", 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 09e23d176..19827a714 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 @@ -254,6 +254,13 @@ public List ejectResources() { return ejectResources; } + /** Stable names of the Java generators whose output this one's output references: + * without them in the same {@code }, the generated code does not compile. + * {@code mvn metaobjects:generate} refuses such a list (see {@link #REQUIRES}). */ + public List requires() { + return REQUIRES.getOrDefault(stableName, List.of()); + } + @Override public String toString() { return "GeneratorInfo{" + stableName + " -> " + classname @@ -311,6 +318,23 @@ public String toString() { private static final String MUSTACHE_GENERATOR = "com.metaobjects.generator.mustache.MustacheTemplateGenerator"; + /** + * What each generator's output references from another generator's output, by stable name + * — the Java peer of the {@code requires} the TypeScript, C# and Python registries carry. + * Without it a pom that listed {@code SpringControllerGenerator} alone generated, exited 0, + * and failed to compile on {@code FilterAllowlist}, {@code Dto} and the value + * objects a DTO carries. {@code GeneratorRequiresCompileTest} proves each closure compiles + * over the shared fitness corpus. Declared before {@link #REGISTRY}: static initializers run + * in order. + */ + private static final Map> REQUIRES = Map.of( + "routes", List.of("dto", "repository", "filter-allowlist"), + "dto", List.of("value-object"), + "repository", List.of("dto"), + "output-parser", List.of("value-object"), + "render-helper", List.of("value-object"), + "extractor", List.of("entity")); + private static final Map REGISTRY = buildRegistry(); /** Classpath root every ejectable Java generator's reference source is shipped under diff --git a/server/java/integration-tests-kotlin/pom.xml b/server/java/integration-tests-kotlin/pom.xml index f24e794e9..1ae1c436f 100644 --- a/server/java/integration-tests-kotlin/pom.xml +++ b/server/java/integration-tests-kotlin/pom.xml @@ -86,6 +86,14 @@ ${project.version} test + + + com.metaobjects + metaobjects-om + ${project.version} + test +